Compare commits
82 Commits
bb8d415eb7
...
rebuild/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| 20aee9c566 | |||
| 45cc864971 | |||
| 3c6d8cb891 | |||
| 2392768868 | |||
| 3702e3d1f2 | |||
| 230006a905 | |||
| ba39376a0e | |||
| 1ce0f123fa | |||
| c99238c1e7 | |||
| e71c77b5ca | |||
| 27132e89c2 | |||
| 86a317c862 | |||
| 52d328ffc3 | |||
| 711cd8b759 | |||
| de2f5e8be8 | |||
| 5c774f9a83 | |||
| f3f50d7e90 | |||
| 67bef6dc08 | |||
| 23e304ac53 | |||
| cad53942e8 | |||
| 76be8926f2 | |||
| 4d93e455f9 | |||
| c958dcfd80 | |||
| 84bfbd9a3e | |||
| 500e452c2f | |||
| 1c0f7ede55 | |||
| 2e9c42a34c | |||
| dff60dde6a | |||
| ea9e0058d4 | |||
| d5aa36416c | |||
| 2159280af5 | |||
| bc9a031890 | |||
| 0d81787125 | |||
| 68713c053b | |||
| 94823c0821 | |||
| b608ec85ba | |||
| 6048d71759 | |||
| 37d14a22d5 | |||
| a02d103cfe | |||
| 21c6233fc0 | |||
| 4ca5c17e2e | |||
| cf7a37eccc | |||
| 44eed97fe0 | |||
| 9e134916a4 | |||
| ba60a9b1c4 | |||
| 132ca5c82c | |||
| 185649db7c | |||
| 9d926750b2 | |||
| a91d917fa9 | |||
| 34f3d088ad | |||
| c6ce44f8b4 | |||
| 429956c594 | |||
| 16b6a18096 | |||
| 95c2154087 | |||
| 0abfb32194 | |||
| bcfd306643 | |||
| 335b708b04 | |||
| a83f8bd670 | |||
| 4dc3bbb13f | |||
| 8cfe31da61 | |||
| 7965768f3d | |||
| 012cac23de | |||
| d929bf96aa | |||
| cce9fb9ed8 | |||
| 9eefe24a33 | |||
| f559fc4c4b | |||
| 55b1831da2 | |||
| c6df703b96 | |||
| 7cc574f9a1 | |||
| 2208af7070 | |||
| df19752a1e | |||
| c07d1fd2bd | |||
| 4f85a86077 | |||
| f0650a9c39 | |||
| 4f42a2faff | |||
| 536707f812 | |||
| 1550ccb693 | |||
| 4042395e3e | |||
| 383440ed5b | |||
| 94f6a9666b | |||
| 10185178e2 | |||
| 0f83183620 |
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
||||
# Git 与编辑器
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
|
||||
# 运行时数据与本地配置
|
||||
data/
|
||||
app.ini
|
||||
tmp-cookie.txt
|
||||
|
||||
# 编译产物与前端缓存
|
||||
dist/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
# 文档与截图(不参与镜像构建)
|
||||
docs/
|
||||
|
||||
# 计划文件
|
||||
*.plan.md
|
||||
7
.gitignore
vendored
@@ -8,11 +8,12 @@
|
||||
/node_modules/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/embed_static/static/spa/
|
||||
/.vite/
|
||||
|
||||
# Go 编译产物
|
||||
/dist/
|
||||
*.exe
|
||||
/jiang13-*
|
||||
|
||||
# 临时文件
|
||||
tmp-cookie.txt
|
||||
@@ -20,6 +21,10 @@ tmp-cookie.txt
|
||||
# 编辑器 / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.trae/documents/
|
||||
.trae/chat/
|
||||
.trae/image/
|
||||
*.swp
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
|
||||
36
.trae/rules/build-scripts.mdc
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: 编译与构建脚本约定(Windows build.bat/ps1、Makefile、跨平台同步)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 编译与构建脚本
|
||||
|
||||
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
|
||||
|
||||
## 运行编译(优先用封装命令,不要猜命令)
|
||||
|
||||
| 平台 | 命令 |
|
||||
|------|------|
|
||||
| Windows | `build.bat` 或 `build.bat -Target <target>` |
|
||||
| Linux / macOS | `make` 或 `make <target>` |
|
||||
|
||||
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE,不兼容本项目 Makefile)。
|
||||
|
||||
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
||||
|
||||
常用 target:`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
||||
|
||||
## 修改构建脚本时的约定
|
||||
|
||||
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
|
||||
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
|
||||
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
|
||||
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
||||
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
|
||||
|
||||
## 新增 target 检查清单
|
||||
|
||||
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
|
||||
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
|
||||
- [ ] `build.bat -Target help` 说明(help 输出在 ps1 内)
|
||||
- [ ] README「快速开始」如需对外暴露再更新
|
||||
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# 姜十三论坛 — 多阶段构建:Node 前端(Vike SSR)+ Go API,双服务共用镜像
|
||||
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
||||
#
|
||||
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
||||
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
|
||||
# docker build --build-arg IMAGE_PREFIX= ...
|
||||
#
|
||||
# 运行形态(docker compose 双容器,同一镜像两种角色):
|
||||
# api —— Go 二进制,纯 API(:3001 内部,/data 数据卷)
|
||||
# web —— Node 渲染服务器(对外 :3000:SSR + 静态资源 + API 反代)
|
||||
# 镜像默认入口为 api 角色;web 角色在 compose 中以 node 用户覆盖 entrypoint。
|
||||
|
||||
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
|
||||
ARG VERSION=dev
|
||||
|
||||
# ── Stage 1: 前端构建(Vike → dist/client 静态资源 + dist/server SSR 入口)──
|
||||
FROM ${IMAGE_PREFIX}node:24-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;仅 API,无内嵌前端)─────
|
||||
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 . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/jiang13 ./cmd/jiang13
|
||||
|
||||
# ── Stage 3: 运行镜像(Node 运行时承载 SSR 渲染与静态资源,附带 Go API 二进制)
|
||||
FROM ${IMAGE_PREFIX}node:24-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gosu ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV TZ=Asia/Shanghai \
|
||||
NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/jiang13 /app/jiang13
|
||||
COPY --from=frontend /src/frontend /app/frontend
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /app/jiang13 /docker-entrypoint.sh
|
||||
|
||||
# 以 root 启动,entrypoint 修正 /data 卷权限后 gosu 降权为 uid 1000(node 用户)
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3000 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["--work-path", "/app", "--data", "/data", "--port", "3001"]
|
||||
70
Makefile
@@ -4,45 +4,48 @@
|
||||
APP_NAME := jiang13
|
||||
MAIN_PKG := ./cmd/jiang13
|
||||
BUILD_DIR := dist
|
||||
VERSION := 1.0.0
|
||||
DEV_DATA_DIR := dist/data
|
||||
VERSION := 1.1.8
|
||||
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 web-build build-windows build-linux build-darwin build-all clean run serve dev tidy help docker compose-up compose-down
|
||||
|
||||
all: build
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm install && npm run build
|
||||
## 前端构建(Vike:产出 frontend/dist/client 静态资源 + dist/server SSR 入口)
|
||||
web-build:
|
||||
cd frontend && (test -d node_modules || npm install) && npm run build
|
||||
|
||||
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
||||
build: frontend-build
|
||||
## 编译当前平台二进制(纯 Go SQLite,无需 CGO;Go 仅含 API,前端由 Node 渲染)
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
|
||||
|
||||
## Windows amd64(先打包前端再 embed)
|
||||
build-windows: frontend-build
|
||||
## Windows amd64
|
||||
build-windows:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
|
||||
|
||||
## Linux amd64(先打包前端再 embed)
|
||||
build-linux: frontend-build
|
||||
## Linux amd64
|
||||
build-linux:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
|
||||
|
||||
## macOS arm64 (Apple Silicon)(先打包前端再 embed)
|
||||
build-darwin: frontend-build
|
||||
## macOS arm64 (Apple Silicon)
|
||||
build-darwin:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
||||
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
|
||||
|
||||
## 跨平台全量编译(frontend-build 只跑一次)
|
||||
build-all: frontend-build
|
||||
## 跨平台全量编译
|
||||
build-all:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||
@@ -54,28 +57,55 @@ build-all: frontend-build
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
|
||||
## 本地运行(仅后端,使用已 embed 的前端)
|
||||
## 本地运行(仅 Go API :3000,无前端页面;数据目录与 dist 二进制一致)
|
||||
run:
|
||||
$(GO) run $(MAIN_PKG)
|
||||
@mkdir -p $(DEV_DATA_DIR)
|
||||
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR)
|
||||
|
||||
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出)
|
||||
## 生产模式本地运行(Node :3000 对外 SSR + Go API :3001)
|
||||
serve: build web-build
|
||||
@mkdir -p $(DEV_DATA_DIR)
|
||||
@trap 'kill 0' INT; \
|
||||
$(BUILD_DIR)/$(APP_NAME) --data $(DEV_DATA_DIR) --port 3001 & \
|
||||
cd frontend && PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
|
||||
|
||||
## 前端热更新开发(Vike dev :5173 + Go API :3000,Ctrl+C 同时退出;数据目录与 dist 二进制一致)
|
||||
dev:
|
||||
@echo "前端热更新: http://localhost:5173"
|
||||
@echo "后端 API : http://localhost:3000"
|
||||
@echo "数据目录 : $(DEV_DATA_DIR) (与 dist 二进制一致)"
|
||||
@mkdir -p $(DEV_DATA_DIR)
|
||||
@trap 'kill 0' INT; \
|
||||
$(GO) run $(MAIN_PKG) & \
|
||||
$(GO) run $(MAIN_PKG) --dev --data $(DEV_DATA_DIR) & \
|
||||
cd frontend && (test -d node_modules || npm install) && npm run dev
|
||||
|
||||
## 清理编译产物
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
## 构建 Docker 镜像
|
||||
docker:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
|
||||
|
||||
## Docker Compose 启动(web :3000 + api :3001 双容器)
|
||||
compose-up:
|
||||
docker compose up -d --build
|
||||
|
||||
## Docker Compose 停止
|
||||
compose-down:
|
||||
docker compose down
|
||||
|
||||
help:
|
||||
@echo "姜十三论坛编译命令:"
|
||||
@echo " make build - 编译当前平台"
|
||||
@echo " make build - 编译 Go API 二进制(当前平台)"
|
||||
@echo " make web-build - 构建前端(Vike:client + server 产物)"
|
||||
@echo " make serve - 生产模式本地运行(Node :3000 + Go API :3001)"
|
||||
@echo " make build-windows - 编译 Windows"
|
||||
@echo " make build-linux - 编译 Linux"
|
||||
@echo " make build-darwin - 编译 macOS"
|
||||
@echo " make build-all - 编译全部平台"
|
||||
@echo " make run - 仅启动后端(:3000)"
|
||||
@echo " make run - 仅启动 Go API(:3000)"
|
||||
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
||||
@echo " make docker - 构建 Docker 镜像"
|
||||
@echo " make compose-up - Docker Compose 启动"
|
||||
@echo " make compose-down - Docker Compose 停止"
|
||||
|
||||
201
README.md
@@ -11,14 +11,16 @@
|
||||
|
||||
[](https://bbs.iioio.com/)
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||
[](go.mod)
|
||||
[](frontend/package.json)
|
||||
[](frontend/package.json)
|
||||
[](#)
|
||||
|
||||
[在线演示](https://bbs.iioio.com/) ·
|
||||
[快速开始](#-快速开始) ·
|
||||
[界面预览](#-界面预览) ·
|
||||
[功能亮点](#-功能亮点) ·
|
||||
[更新日志](docs/site-posts/changelog.md) ·
|
||||
[路线图](ROADMAP.md) ·
|
||||
[参与贡献](CONTRIBUTING.md)
|
||||
|
||||
@@ -112,10 +114,12 @@
|
||||
|
||||
### 部署体验
|
||||
|
||||
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
|
||||
- **双服务架构** — Node(:3000 对外:SSR + 静态资源 + API 反代)+ Go(:3001 内部:纯 API),职责单一、可独立扩缩
|
||||
- **服务端渲染** — 首页 / 板块 / 帖子详情由 Vike SSR 真渲染,SEO 友好、首屏直出;其余页面 SPA 接管
|
||||
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译 Go API
|
||||
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||
- **Docker 双容器** — 同一镜像两种角色,`docker compose up` 一键起
|
||||
|
||||
---
|
||||
|
||||
@@ -123,7 +127,7 @@
|
||||
|
||||
### 1. 编译
|
||||
|
||||
**Windows(推荐):**
|
||||
Go API 二进制(不含前端,Windows):
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
@@ -139,9 +143,21 @@ make build
|
||||
|
||||
**手动分步(全平台):**
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||
```
|
||||
|
||||
前端构建(Vike,产出 `frontend/dist/client` + `dist/server`):
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run build
|
||||
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||
```
|
||||
|
||||
本地生产模式一键起双服务(Node :3000 对外 + Go API :3001):
|
||||
|
||||
```bash
|
||||
make serve # Linux / macOS
|
||||
build.bat -Target serve # Windows
|
||||
```
|
||||
|
||||
跨平台编译:
|
||||
@@ -152,27 +168,135 @@ build.bat -Target build-linux
|
||||
build.bat -Target build-all
|
||||
```
|
||||
|
||||
### 2. 启动
|
||||
### 2. Docker 部署(推荐)
|
||||
|
||||
把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
||||
**一键启动(本地构建):**
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\dist\jiang13.exe
|
||||
|
||||
# Linux / macOS
|
||||
./dist/jiang13
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
也可先复制示例配置再改端口 / 数据目录:
|
||||
```bat
|
||||
.\build.bat -Target compose-up
|
||||
```
|
||||
|
||||
```bash
|
||||
cp app.ini.example /opt/jiang13/app.ini
|
||||
# 编辑 app.ini 后:
|
||||
./jiang13
|
||||
make compose-up
|
||||
```
|
||||
|
||||
### 3. 首次使用
|
||||
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
|
||||
|
||||
版本变更与升级说明见 **[更新日志](docs/site-posts/changelog.md)**。Docker 与 Windows / Linux 单文件共用同一版本号。
|
||||
|
||||
- Docker 标签:[hub.docker.com/r/hangzhang714128/jiang13-forum/tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags)
|
||||
- 预编译包:[Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases)
|
||||
|
||||
**拉取已构建镜像(Docker Hub):**
|
||||
|
||||
同一镜像两种角色,建议直接用 `docker-compose.yml`(已含完整配置):
|
||||
|
||||
```bash
|
||||
docker pull hangzhang714128/jiang13-forum:latest
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Compose 会启动两个容器:
|
||||
|
||||
| 容器 | 角色 | 端口 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `web` | Node 渲染服务器 | `3000` 对外 | Vike SSR + 静态资源 + API 反代(`GO_API_URL=http://api:3001`) |
|
||||
| `api` | Go API | `3001` 内部 | 纯 API 服务,持有 `/data` 数据卷 |
|
||||
|
||||
**数据持久化:** `api` 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`,适配 1Panel 等面板挂载的目录。
|
||||
|
||||
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
||||
|
||||
**可选环境变量(容器编排):**
|
||||
|
||||
| 变量 | 容器 | 说明 |
|
||||
|------|------|------|
|
||||
| `GO_API_URL` | web | Go API 地址(默认 `http://127.0.0.1:3001`,Compose 中为 `http://api:3001`) |
|
||||
| `PORT` | web | Node 渲染服务器监听端口(默认 `3000`) |
|
||||
| `JIANG13_HTTP_PORT` | api | HTTP 端口(默认 `3000`,Compose 中为 `3001`) |
|
||||
| `JIANG13_DATA` | api | 数据目录(默认 `/data`) |
|
||||
| `JIANG13_JWT_SECRET` | api | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||
| `JIANG13_CONFIG` | api | 配置文件路径 |
|
||||
| `JIANG13_WORK_PATH` | api | 工作目录 |
|
||||
| `JIANG13_COMMUNITY_HUB` | api | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
||||
|
||||
**健康检查:** Go API `GET /health`、Node 渲染服务器 `GET /healthz`,均返回 200,供 Docker / 负载均衡探活。
|
||||
|
||||
**发布镜像到 Docker Hub(手动):**
|
||||
|
||||
```bash
|
||||
docker login
|
||||
.\build.bat -Target docker # Windows
|
||||
# make docker # Linux/macOS
|
||||
docker push hangzhang714128/jiang13-forum:1.1.7
|
||||
docker push hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
或直接构建:
|
||||
|
||||
```bash
|
||||
docker build --build-arg VERSION=1.1.7 -t hangzhang714128/jiang13-forum:1.1.7 -t hangzhang714128/jiang13-forum:latest .
|
||||
docker push hangzhang714128/jiang13-forum:1.1.7
|
||||
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. 用 Compose 编排(`docker-compose.yml`)或手动创建两个容器:`api`(Go,内部 `3001`,挂载 `/data`)与 `web`(Node,映射 `3000:3000`)
|
||||
2. `web` 容器环境变量 `GO_API_URL` 指向 `api` 容器(如 `http://api:3001`)
|
||||
3. 数据卷只需挂在 `api` 容器的 `/data`(镜像会自动修正目录权限)
|
||||
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
|
||||
|
||||
### 3. 直接启动(二进制)
|
||||
|
||||
Go 二进制仅提供 API,完整站点需同时运行 Node 渲染服务器(构建前端后 `frontend/server/index.mjs`)。最简单的方式是用上面的 Docker Compose,或本地 `make serve` / `build.bat -Target serve` 一键起双进程。
|
||||
|
||||
如需手动部署二进制,从 [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) 下载与 Docker 同版本号的文件:
|
||||
|
||||
| 文件 | 平台 |
|
||||
|------|------|
|
||||
| `jiang13-*-windows-amd64.exe` | Windows x64 |
|
||||
| `jiang13-*-linux-amd64` | Linux x64 |
|
||||
|
||||
当前 **1.1.7:** [Windows](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-windows-amd64.exe) · [Linux](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-linux-amd64)
|
||||
|
||||
两个进程一起跑(Go API :3001 + Node :3000 对外):
|
||||
|
||||
```bash
|
||||
# 1) 启动 Go API
|
||||
./jiang13 --port 3001 --data ./data
|
||||
|
||||
# 2) 另开终端,构建并启动 Node 渲染服务器
|
||||
cd frontend && npm install && npm run build
|
||||
PORT=3000 GO_API_URL=http://127.0.0.1:3001 npm start
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
.\jiang13.exe --port 3001 --data .\data
|
||||
# 另开 PowerShell:
|
||||
cd frontend; npm install; npm run build
|
||||
$env:PORT = '3000'; $env:GO_API_URL = 'http://127.0.0.1:3001'; npm start
|
||||
```
|
||||
|
||||
### 4. 首次使用
|
||||
|
||||
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||
2. **第一个注册的用户自动成为管理员**
|
||||
@@ -208,7 +332,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`、`JIANG13_COMMUNITY_HUB`(非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表)
|
||||
|
||||
### 5. 注册为系统服务(可选)
|
||||
|
||||
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
|
||||
|
||||
@@ -239,16 +365,17 @@ C:\jiang13\jiang13.exe --service start
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go 1.26 · Gin · GORM · SQLite |
|
||||
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
|
||||
| **后端** | Go 1.26 · Gin · GORM · SQLite(纯 API 服务) |
|
||||
| **前端** | React 19 · Vike SSR · react-router · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||
| **构建** | Vite 8(Rolldown)双构建:`dist/client` 静态资源 + `dist/server` SSR 入口 |
|
||||
| **渲染** | Node 24 · Express + Vike `renderPage`(:3000 对外,API 反代至 Go :3001) |
|
||||
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
|
||||
|
||||
---
|
||||
|
||||
## 前端开发
|
||||
|
||||
日常改前端不需要重新完整构建,Vite 支持秒级热更新(HMR,热模块替换):
|
||||
日常改前端不需要重新完整构建,Vike dev 支持秒级热更新(HMR,热模块替换):
|
||||
|
||||
```bat
|
||||
build.bat -Target dev
|
||||
@@ -258,14 +385,17 @@ build.bat -Target dev
|
||||
make dev
|
||||
```
|
||||
|
||||
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
|
||||
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。首页 / 板块 / 帖子详情在 dev 下同样走 SSR(`vike dev`),与生产行为一致;其余页面为 SPA 壳。
|
||||
|
||||
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
|
||||
|
||||
**何时需要完整构建:**
|
||||
|
||||
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
|
||||
- 修改 Go 代码 → `build.bat` / `make build`(仅编译 Go API)
|
||||
- 验证生产 SSR / 发布部署 → `make serve`(构建前端 + 双进程起本地产)
|
||||
- 更新 README 界面截图 → 见下方「更新截图」
|
||||
|
||||
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
|
||||
> 开发时请访问 `:5173`;`:3000` 只有在生产模式(Node 渲染服务器)下才有页面。
|
||||
|
||||
### 更新截图
|
||||
|
||||
@@ -295,13 +425,20 @@ jiang13-forum/
|
||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||
├── config/ # app.ini 与命令行配置
|
||||
├── app.ini.example # 配置文件示例
|
||||
├── Dockerfile # 多阶段构建(前端 + Go API 共用镜像,双容器运行)
|
||||
├── docker-compose.yml # 双容器 Compose 部署(web :3000 + api :3001)
|
||||
├── docker-entrypoint.sh # api 容器启动脚本(修正 /data 卷权限)
|
||||
├── .dockerignore
|
||||
├── model/ # GORM 模型与数据库迁移
|
||||
├── service/ # 业务逻辑
|
||||
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||
├── handler/ # HTTP 处理器(API + /api/home-boot SSR 聚合端点)
|
||||
├── middleware/ # JWT 鉴权等
|
||||
├── router/ # 路由注册
|
||||
├── embed_static/ # go:embed 内嵌的 SPA
|
||||
├── frontend/ # React 源码(Vite 构建)
|
||||
├── router/ # 路由注册(仅 API)
|
||||
├── frontend/
|
||||
│ ├── pages/ # Vike 页面(feed=SSR 白名单 / spa=catch-all 壳 / _error)
|
||||
│ ├── renderer/ # Vike 双端渲染入口(onRenderHtml / onRenderClient)
|
||||
│ ├── server/ # Node 渲染服务器(express + renderPage + API 反代)
|
||||
│ └── src/ # React 源码(含 src/ssr 服务端取数与作用域管理)
|
||||
├── docs/screenshots/ # README 界面截图
|
||||
├── ROADMAP.md # 路线图与已知问题
|
||||
└── scripts/ # 开发辅助脚本(含截图)
|
||||
@@ -313,13 +450,15 @@ jiang13-forum/
|
||||
|
||||
```
|
||||
data/
|
||||
├── jiang13.db # SQLite 主数据库
|
||||
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||
├── monitor.db # 网站监控 page_views
|
||||
├── jiang13.log # 运行日志
|
||||
├── filter_words.txt # 敏感词配置
|
||||
├── .jwt_secret # JWT 密钥(自动生成)
|
||||
├── logs/access/ # 网站监控请求日志(按日 jsonl)
|
||||
├── uploads/avatars/ # 用户头像
|
||||
├── uploads/posts/ # 帖子正文图片
|
||||
└── jiang13_backup_*.db # 后台导出的备份
|
||||
└── jiang13_backup_*.db # 后台导出的主库备份
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -13,3 +13,13 @@ DATA = data
|
||||
[security]
|
||||
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
||||
JWT_SECRET =
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; 维护者选项(默认关闭;普通自托管无需开启)
|
||||
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
|
||||
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
|
||||
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
|
||||
; 等价环境变量:JIANG13_COMMUNITY_HUB=1
|
||||
; ---------------------------------------------------------------------------
|
||||
; [community]
|
||||
; HUB = false
|
||||
|
||||
84
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', 'web-build', 'frontend', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'tidy', 'run', 'serve', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
|
||||
[string]$Target = 'build'
|
||||
)
|
||||
|
||||
@@ -11,7 +11,9 @@ $ErrorActionPreference = 'Stop'
|
||||
$AppName = 'jiang13'
|
||||
$MainPkg = './cmd/jiang13'
|
||||
$BuildDir = 'dist'
|
||||
$Version = '1.0.0'
|
||||
$DevDataDir = 'dist/data'
|
||||
$Version = '1.1.8'
|
||||
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
||||
$Ldlags = "-s -w -X main.version=$Version"
|
||||
|
||||
function Ensure-Dir($path) {
|
||||
@@ -21,7 +23,7 @@ function Ensure-Dir($path) {
|
||||
}
|
||||
|
||||
function Build-Frontend {
|
||||
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan
|
||||
Write-Host '[frontend] npm run build (Vike: client + server)...' -ForegroundColor Cyan
|
||||
Push-Location frontend
|
||||
try {
|
||||
if (-not (Test-Path node_modules)) {
|
||||
@@ -67,18 +69,23 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
|
||||
|
||||
switch ($Target) {
|
||||
'help' {
|
||||
Write-Host '.\build.ps1 build current platform'
|
||||
Write-Host '.\build.ps1 -Target frontend frontend only'
|
||||
Write-Host '.\build.ps1 build Go API binary (current platform)'
|
||||
Write-Host '.\build.ps1 -Target web-build build frontend (Vike client + server)'
|
||||
Write-Host '.\build.ps1 -Target serve production run: Node :3000 + Go API :3001'
|
||||
Write-Host '.\build.ps1 -Target build-windows'
|
||||
Write-Host '.\build.ps1 -Target build-linux'
|
||||
Write-Host '.\build.ps1 -Target build-all'
|
||||
Write-Host '.\build.ps1 -Target run backend only (port 3000)'
|
||||
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
|
||||
Write-Host '.\build.ps1 -Target run Go API only (port 3000, no pages)'
|
||||
Write-Host '.\build.ps1 -Target dev Vike HMR :5173 + Go API :3000 (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.'
|
||||
}
|
||||
'web-build' { Build-Frontend }
|
||||
'frontend' { Build-Frontend }
|
||||
'tidy' { go mod tidy }
|
||||
'clean' {
|
||||
@@ -86,22 +93,49 @@ switch ($Target) {
|
||||
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
||||
}
|
||||
'run' {
|
||||
go run $MainPkg
|
||||
Ensure-Dir $DevDataDir
|
||||
go run $MainPkg --data $DevDataDir
|
||||
}
|
||||
'serve' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile $AppName
|
||||
Ensure-Dir $DevDataDir
|
||||
$root = (Get-Location).Path
|
||||
$goExe = Join-Path $root "$BuildDir\$AppName.exe"
|
||||
Write-Host '[serve] 正在新窗口启动 Go API (:3001)...' -ForegroundColor Cyan
|
||||
Start-Process powershell -ArgumentList @(
|
||||
'-NoExit', '-Command',
|
||||
"Set-Location '$root'; & '$goExe' --data '$DevDataDir' --port 3001"
|
||||
) | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Write-Host '[serve] 启动 Node 渲染服务器 (:3000)...' -ForegroundColor Cyan
|
||||
Push-Location frontend
|
||||
try {
|
||||
$env:PORT = '3000'
|
||||
$env:GO_API_URL = 'http://127.0.0.1:3001'
|
||||
npm start
|
||||
} finally {
|
||||
Remove-Item Env:PORT -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GO_API_URL -ErrorAction SilentlyContinue
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
'dev' {
|
||||
$root = (Get-Location).Path
|
||||
Ensure-Dir $DevDataDir
|
||||
Write-Host ''
|
||||
Write-Host '[dev] 前端热更新: http://localhost:5173' -ForegroundColor Green
|
||||
Write-Host '[dev] 后端 API : http://localhost:3000' -ForegroundColor Green
|
||||
Write-Host '[dev] 后台管理 : http://localhost:3000/admin/dashboard' -ForegroundColor Green
|
||||
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan
|
||||
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vike HMR)' -ForegroundColor Green
|
||||
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
|
||||
Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
|
||||
Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
|
||||
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
|
||||
Start-Process powershell -ArgumentList @(
|
||||
'-NoExit', '-Command',
|
||||
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg"
|
||||
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev --data '$DevDataDir'"
|
||||
) | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
Push-Location frontend
|
||||
90| try {
|
||||
try {
|
||||
if (-not (Test-Path node_modules)) { npm install }
|
||||
npm run dev
|
||||
} finally {
|
||||
@@ -109,27 +143,37 @@ switch ($Target) {
|
||||
}
|
||||
}
|
||||
'build' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile $AppName
|
||||
}
|
||||
'build-windows' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
}
|
||||
'build-linux' {
|
||||
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
|
||||
Build-Frontend
|
||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||
}
|
||||
'build-darwin' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||
}
|
||||
'build-all' {
|
||||
Build-Frontend
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ import (
|
||||
"github.com/kardianos/service"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
appsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
appsvc.SetAppVersion(version)
|
||||
cfg, err := config.Parse()
|
||||
if err != nil {
|
||||
log.Fatalf("配置解析失败: %v", err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/kardianos/service"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
forumsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/router"
|
||||
)
|
||||
@@ -72,11 +73,18 @@ 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 {
|
||||
return fmt.Errorf("数据库初始化失败: %w", err)
|
||||
}
|
||||
if err := forumsvc.BackfillModerationNotifyRefs(); err != nil {
|
||||
log.Printf("待审通知关联字段回填警告: %v", err)
|
||||
}
|
||||
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
|
||||
return fmt.Errorf("监控库初始化失败: %w", err)
|
||||
}
|
||||
|
||||
engine, err := router.Setup(cfg)
|
||||
if err != nil {
|
||||
@@ -90,7 +98,11 @@ func (p *program) setup() error {
|
||||
}
|
||||
|
||||
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
||||
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
||||
if cfg.DevMode {
|
||||
log.Printf("前端开发服务器: http://localhost:5173 (后台管理: http://localhost:5173/admin/dashboard)")
|
||||
} else {
|
||||
log.Printf("前端由 Node 渲染服务器提供(默认 http://localhost:3000),本服务仅提供 API")
|
||||
}
|
||||
log.Printf("工作目录: %s", cfg.WorkPath)
|
||||
log.Printf("配置文件: %s", cfg.ConfigFile)
|
||||
log.Printf("数据目录: %s", cfg.DataDir)
|
||||
|
||||
410
cmd/monitor-geo-check/main.go
Normal file
@@ -0,0 +1,410 @@
|
||||
// 只读诊断:检查 Geo 库与 page_views 地理字段,或对指定 IP 试查中文映射结果。
|
||||
//
|
||||
// go run ./cmd/monitor-geo-check -db data/monitor.db
|
||||
// go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbPath := flag.String("db", filepath.Join("data", "monitor.db"), "含 page_views 的 SQLite(默认 data/monitor.db)")
|
||||
ipLookup := flag.String("ip", "", "对指定 IP 试查 BIN+ASN(不写库)")
|
||||
flag.Parse()
|
||||
|
||||
abs, err := filepath.Abs(*dbPath)
|
||||
if err != nil {
|
||||
fatalf("解析路径失败: %v", err)
|
||||
}
|
||||
dataDir := filepath.Dir(abs)
|
||||
|
||||
if *ipLookup == "" {
|
||||
if st, err := os.Stat(abs); err != nil || st.IsDir() {
|
||||
fatalf("数据库不存在: %s", abs)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("数据库: %s\n", abs)
|
||||
fmt.Printf("数据目录: %s\n\n", dataDir)
|
||||
|
||||
suite := service.NewGeoIPService(dataDir)
|
||||
defer suite.Close()
|
||||
printGeoFiles(dataDir, suite)
|
||||
|
||||
if ip := strings.TrimSpace(*ipLookup); ip != "" {
|
||||
lookupIP(suite, ip)
|
||||
fmt.Println()
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(abs), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
fatalf("打开数据库失败: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
fatalf("获取连接失败: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if !tableExists(db, "page_views") {
|
||||
fatalf("表 page_views 不存在(请确认指向 data/monitor.db,或尚未写入过浏览量)")
|
||||
}
|
||||
|
||||
have := columnSet(db)
|
||||
printColumnPresence(have)
|
||||
|
||||
since := time.Now().AddDate(0, 0, -30)
|
||||
var total, recentNonBot, withCountry, withRegion, withCity, withASN int64
|
||||
_ = db.Table("page_views").Count(&total).Error
|
||||
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ?", since, false).Count(&recentNonBot).Error
|
||||
|
||||
fmt.Println("=== page_views 统计(近 30 日非 bot)===")
|
||||
fmt.Printf("总量: %d\n", total)
|
||||
fmt.Printf("近 30 日非 bot: %d\n", recentNonBot)
|
||||
|
||||
if have["country"] {
|
||||
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND country <> ''", since, false).Count(&withCountry).Error
|
||||
fmt.Printf("有国家: %d\n", withCountry)
|
||||
} else {
|
||||
fmt.Println("有国家: (列缺失)")
|
||||
}
|
||||
if have["region"] || have["region_iso"] {
|
||||
cond := "created_at >= ? AND is_bot = ?"
|
||||
parts := []string{}
|
||||
if have["region"] {
|
||||
parts = append(parts, "region <> ''")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
parts = append(parts, "region_iso <> ''")
|
||||
}
|
||||
_ = db.Table("page_views").
|
||||
Where(cond+" AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||
Count(&withRegion).Error
|
||||
fmt.Printf("有省字段: %d\n", withRegion)
|
||||
} else {
|
||||
fmt.Println("有省字段: (列缺失)")
|
||||
}
|
||||
if have["city"] {
|
||||
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND city <> ''", since, false).Count(&withCity).Error
|
||||
fmt.Printf("有城市: %d\n", withCity)
|
||||
} else {
|
||||
fmt.Println("有城市: (列缺失)")
|
||||
}
|
||||
if have["asn"] {
|
||||
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND asn > 0", since, false).Count(&withASN).Error
|
||||
fmt.Printf("有 ASN: %d\n", withASN)
|
||||
} else {
|
||||
fmt.Println("有 ASN: (列缺失)")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== 近 30 日省级 Top(country / region / region_iso)===")
|
||||
if !(have["region"] || have["region_iso"]) {
|
||||
fmt.Println("(省列尚未迁移,跳过)")
|
||||
} else {
|
||||
type regionRow struct {
|
||||
Country string
|
||||
Region string
|
||||
RegionISO string `gorm:"column:region_iso"`
|
||||
Count int64
|
||||
}
|
||||
parts := []string{}
|
||||
if have["region"] {
|
||||
parts = append(parts, "region <> ''")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
parts = append(parts, "region_iso <> ''")
|
||||
}
|
||||
selParts := []string{}
|
||||
groupParts := []string{}
|
||||
if have["country"] {
|
||||
selParts = append(selParts, "country")
|
||||
groupParts = append(groupParts, "country")
|
||||
} else {
|
||||
selParts = append(selParts, "'' as country")
|
||||
}
|
||||
if have["region"] {
|
||||
selParts = append(selParts, "region")
|
||||
groupParts = append(groupParts, "region")
|
||||
} else {
|
||||
selParts = append(selParts, "'' as region")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
selParts = append(selParts, "region_iso")
|
||||
groupParts = append(groupParts, "region_iso")
|
||||
} else {
|
||||
selParts = append(selParts, "'' as region_iso")
|
||||
}
|
||||
selParts = append(selParts, "COUNT(*) as count")
|
||||
var regions []regionRow
|
||||
_ = db.Table("page_views").
|
||||
Select(strings.Join(selParts, ", ")).
|
||||
Where("created_at >= ? AND is_bot = ? AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||
Group(strings.Join(groupParts, ", ")).
|
||||
Order("count DESC").
|
||||
Limit(20).
|
||||
Scan(®ions).Error
|
||||
if len(regions) == 0 {
|
||||
fmt.Println("(空)")
|
||||
} else {
|
||||
for i, r := range regions {
|
||||
fmt.Printf("%2d. %s | %s | %s | %d\n", i+1, r.Country, r.Region, r.RegionISO, r.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== 对账抽样(近 30 日非 bot)===")
|
||||
printGeoReconcileSamples(db, have, since)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
|
||||
cols := []string{"id", "created_at", "path", "is_bot"}
|
||||
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||
if have[c] {
|
||||
cols = append(cols, c)
|
||||
}
|
||||
}
|
||||
type sample struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
IP string
|
||||
Country string
|
||||
Region string
|
||||
RegionISO string `gorm:"column:region_iso"`
|
||||
City string
|
||||
ASN uint
|
||||
ASOrg string `gorm:"column:as_org"`
|
||||
Path string
|
||||
IsBot bool
|
||||
}
|
||||
var samples []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(strings.Join(cols, ", ")).
|
||||
Where("created_at >= ?", since).
|
||||
Order("id DESC").
|
||||
Limit(10).
|
||||
Scan(&samples).Error
|
||||
if len(samples) == 0 {
|
||||
fmt.Println("(无近 30 日 pageview)")
|
||||
} else {
|
||||
for _, s := range samples {
|
||||
fmt.Printf("#%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q bot=%v path=%s\n",
|
||||
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.IsBot, s.Path)
|
||||
}
|
||||
}
|
||||
|
||||
regionReady := have["region"] || have["region_iso"]
|
||||
if !regionReady || withRegion == 0 {
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "提示: 近 30 日无省级字段。中国地图「暂无省级访问数据」属预期。")
|
||||
if !regionReady {
|
||||
fmt.Fprintln(os.Stderr, "当前库缺 region/region_iso 列:请先启动一次主程序以完成 AutoMigrate。")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。")
|
||||
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
|
||||
fmt.Fprintln(os.Stderr, "注意: 请求日志有地理 ≠ 概览地图/排行有数据(后者只读 page_views)。")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func printGeoReconcileSamples(db *gorm.DB, have map[string]bool, since time.Time) {
|
||||
type sample struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
IP string
|
||||
Country string
|
||||
Region string
|
||||
RegionISO string `gorm:"column:region_iso"`
|
||||
City string
|
||||
ASN uint
|
||||
ASOrg string `gorm:"column:as_org"`
|
||||
Path string
|
||||
}
|
||||
selCols := []string{"id", "created_at", "path"}
|
||||
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||
if have[c] {
|
||||
selCols = append(selCols, c)
|
||||
}
|
||||
}
|
||||
sel := strings.Join(selCols, ", ")
|
||||
|
||||
printBlock := func(title string, n int64, rows []sample) {
|
||||
fmt.Printf("%s: %d\n", title, n)
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range rows {
|
||||
fmt.Printf(" #%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q path=%s\n",
|
||||
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if have["city"] {
|
||||
var n int64
|
||||
_ = db.Table("page_views").
|
||||
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||
Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("英文城市名(可能未中文化)", n, rows)
|
||||
} else {
|
||||
fmt.Println("英文城市名: (city 列缺失)")
|
||||
}
|
||||
|
||||
if have["region"] || have["region_iso"] {
|
||||
cond := "created_at >= ? AND is_bot = ? AND country <> ''"
|
||||
emptyParts := []string{}
|
||||
if have["region"] {
|
||||
emptyParts = append(emptyParts, "(region = '' OR region IS NULL OR region = '-')")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
emptyParts = append(emptyParts, "(region_iso = '' OR region_iso IS NULL)")
|
||||
}
|
||||
where := cond + " AND (" + strings.Join(emptyParts, " AND ") + ")"
|
||||
var n int64
|
||||
_ = db.Table("page_views").Where(where, since, false).Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where(where, since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("有国家但省为空", n, rows)
|
||||
} else {
|
||||
fmt.Println("有国家但省为空: (省列缺失)")
|
||||
}
|
||||
|
||||
if have["asn"] {
|
||||
var n int64
|
||||
_ = db.Table("page_views").
|
||||
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||
Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("asn=0(无运营商)", n, rows)
|
||||
} else {
|
||||
fmt.Println("asn=0: (asn 列缺失)")
|
||||
}
|
||||
}
|
||||
|
||||
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
|
||||
fmt.Println("=== Geo 数据文件 ===")
|
||||
v4, v6, asn, country := suite.Paths()
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
path string
|
||||
ok bool
|
||||
}{
|
||||
{"IP2LOCATION-LITE-DB3.BIN", v4, suite.BINV4Available()},
|
||||
{"IP2LOCATION-LITE-DB3.IPV6.BIN", v6, suite.BINV6Available()},
|
||||
{"GeoLite2-ASN.mmdb", asn, suite.ASNAvailable()},
|
||||
{"GeoLite2-Country.mmdb", country, suite.CountryAvailable()},
|
||||
} {
|
||||
st, err := os.Stat(item.path)
|
||||
if err != nil {
|
||||
fmt.Printf("%s: 不存在\n", item.name)
|
||||
continue
|
||||
}
|
||||
loaded := "未加载"
|
||||
if item.ok {
|
||||
loaded = "已加载"
|
||||
}
|
||||
fmt.Printf("%s: 存在 (%d bytes, mtime %s) · %s\n", item.name, st.Size(), st.ModTime().Format(time.RFC3339), loaded)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func lookupIP(suite *service.GeoIPService, ipStr string) {
|
||||
fmt.Printf("=== IP 试查 %s(中文映射后)===\n", ipStr)
|
||||
info := suite.Lookup(ipStr)
|
||||
if info.Country == "" && info.Region == "" && info.City == "" && info.ASN == 0 {
|
||||
fmt.Println("未解析到地理信息(请确认 BIN/ASN 文件存在且 IP 为公网地址)")
|
||||
return
|
||||
}
|
||||
fmt.Printf("国家 ISO: %s (%s)\n", info.Country, service.CountryLabelZh(info.Country))
|
||||
if info.Region != "" || info.RegionISO != "" {
|
||||
fmt.Printf("省/州: %s / %s\n", info.Region, info.RegionISO)
|
||||
}
|
||||
if info.City != "" {
|
||||
fmt.Printf("城市: %s\n", info.City)
|
||||
}
|
||||
if info.ASN > 0 || info.ASOrg != "" {
|
||||
fmt.Printf("ASN: AS%d %s\n", info.ASN, info.ASOrg)
|
||||
}
|
||||
}
|
||||
|
||||
func tableExists(db *gorm.DB, name string) bool {
|
||||
var n int
|
||||
err := db.Raw(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
|
||||
).Scan(&n).Error
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
func columnSet(db *gorm.DB) map[string]bool {
|
||||
type colRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
var cols []colRow
|
||||
_ = db.Raw(`PRAGMA table_info(page_views)`).Scan(&cols).Error
|
||||
have := map[string]bool{}
|
||||
for _, c := range cols {
|
||||
have[c.Name] = true
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
func printColumnPresence(have map[string]bool) {
|
||||
need := []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"}
|
||||
fmt.Println("=== page_views 列 ===")
|
||||
missing := false
|
||||
for _, n := range need {
|
||||
if have[n] {
|
||||
fmt.Printf("%s: 有\n", n)
|
||||
} else {
|
||||
fmt.Printf("%s: 缺失\n", n)
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
if missing {
|
||||
fmt.Fprintln(os.Stderr, "提示: 缺列时请先启动一次主程序以完成监控库 AutoMigrate。")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -30,11 +30,16 @@ type Config struct {
|
||||
LogFile string
|
||||
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||
ServiceAction string
|
||||
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
||||
DevMode bool
|
||||
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
|
||||
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
|
||||
CommunityHub 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", "", "工作目录(默认:可执行文件所在目录)")
|
||||
@@ -42,6 +47,7 @@ func Parse() (*Config, error) {
|
||||
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
|
||||
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
|
||||
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
||||
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
|
||||
flag.Parse()
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
||||
@@ -49,12 +55,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
|
||||
}
|
||||
@@ -70,11 +84,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
|
||||
}
|
||||
@@ -83,9 +103,17 @@ 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)
|
||||
}
|
||||
|
||||
communityHub := fileCfg.CommunityHub
|
||||
if v := envBoolOrNil(envCommunityHub); v != nil {
|
||||
communityHub = *v
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
@@ -96,6 +124,8 @@ func Parse() (*Config, error) {
|
||||
JWTSecret: jwtSecret,
|
||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||
ServiceAction: action,
|
||||
DevMode: *devFlag,
|
||||
CommunityHub: communityHub,
|
||||
}
|
||||
|
||||
needDirs := action == "" || action == "install"
|
||||
@@ -163,6 +193,7 @@ func ensureDataDirs(dataDir string) error {
|
||||
filepath.Join(dataDir, "uploads", "avatars"),
|
||||
filepath.Join(dataDir, "uploads", "posts"),
|
||||
filepath.Join(dataDir, "uploads", "site"),
|
||||
filepath.Join(dataDir, "logs", "access"),
|
||||
} {
|
||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||
@@ -197,11 +228,16 @@ func validServiceAction(action string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// DBPath 返回 SQLite 数据库文件路径
|
||||
// DBPath 返回主库 SQLite 路径
|
||||
func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.DataDir, "jiang13.db")
|
||||
}
|
||||
|
||||
// MonitorDBPath 返回监控独立库路径(page_views)
|
||||
func (c *Config) MonitorDBPath() string {
|
||||
return filepath.Join(c.DataDir, "monitor.db")
|
||||
}
|
||||
|
||||
// AvatarUploadDir 返回头像上传目录
|
||||
func (c *Config) AvatarUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||
|
||||
51
config/env.go
Normal file
@@ -0,0 +1,51 @@
|
||||
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"
|
||||
envCommunityHub = "JIANG13_COMMUNITY_HUB"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// envBoolOrNil 解析布尔环境变量;未设置返回 nil
|
||||
func envBoolOrNil(key string) *bool {
|
||||
v := strings.ToLower(envOrDefault(key))
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
switch v {
|
||||
case "1", "true", "yes", "on":
|
||||
t := true
|
||||
return &t
|
||||
case "0", "false", "no", "off":
|
||||
f := false
|
||||
return &f
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type fileSettings struct {
|
||||
Port int
|
||||
DataRel string
|
||||
JWTSecret string
|
||||
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
|
||||
}
|
||||
|
||||
func defaultFileSettings() fileSettings {
|
||||
@@ -62,6 +63,12 @@ func loadAppINI(path string) (fileSettings, error) {
|
||||
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
||||
}
|
||||
|
||||
if sec, err := cfg.GetSection("community"); err == nil {
|
||||
if k := sec.Key("HUB"); k.String() != "" {
|
||||
out.CommunityHub = k.MustBool(false)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
# 姜十三论坛 — Docker Compose 双服务部署
|
||||
# 架构:web(Node :3000 对外:Vike SSR + 静态资源 + API 反代) → api(Go :3001 内部:纯 API + /data 数据卷)
|
||||
# 启动:docker compose up -d --build
|
||||
# 或:make compose-up / build.bat -Target compose-up
|
||||
|
||||
services:
|
||||
api:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: dev
|
||||
command: ["--work-path", "/app", "--data", "/data", "--port", "3001"]
|
||||
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
|
||||
expose:
|
||||
- "3001"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 15s
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
web:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
entrypoint: ["node", "server/index.mjs"]
|
||||
working_dir: /app/frontend
|
||||
user: "1000:1000"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
PORT: "3000"
|
||||
GO_API_URL: http://api:3001
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
jiang13-data:
|
||||
16
docker-entrypoint.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# api 容器入口:默认运行 Go API(web 容器在 compose 中以 node 用户直接覆盖 entrypoint)。
|
||||
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
|
||||
# 而应用以 uid 1000(node 镜像内置用户)运行,需在启动时修正 /data 权限后降权。
|
||||
|
||||
DATA_DIR="/data"
|
||||
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R 1000:1000 "$DATA_DIR"
|
||||
exec gosu 1000:1000 /app/jiang13 "$@"
|
||||
fi
|
||||
|
||||
exec /app/jiang13 "$@"
|
||||
105
docs/docker-data.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Docker 数据持久化说明
|
||||
|
||||
Docker 部署时**默认只挂载一个目录**,不是分别映射「数据库文件夹」和「附件文件夹」两个卷。
|
||||
|
||||
## 挂载对应关系
|
||||
|
||||
| 宿主机 | 容器内 |
|
||||
|--------|--------|
|
||||
| 任意目录或 named volume(如 `jiang13-data`) | **`/data`** |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
docker run -d --name jiang13 \
|
||||
-p 3000:3000 \
|
||||
-v /你的路径/jiang13-data:/data \
|
||||
--restart unless-stopped \
|
||||
hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
或在 `docker-compose.yml` 中:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
# 或绑定宿主机目录:
|
||||
# - ./data:/data
|
||||
```
|
||||
|
||||
## `/data` 目录结构
|
||||
|
||||
数据库与附件都在同一个 `/data` 挂载内:
|
||||
|
||||
```
|
||||
/data/
|
||||
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||
├── monitor.db # 网站监控 page_views
|
||||
├── uploads/ # 附件根目录
|
||||
│ ├── avatars/ # 用户头像
|
||||
│ └── posts/ # 帖子图片等
|
||||
├── logs/
|
||||
│ └── access/ # 网站监控请求日志(按日 jsonl)
|
||||
│ └── YYYY-MM-DD.jsonl
|
||||
├── IP2LOCATION-LITE-DB3.BIN # 可选:IPv4 城市库(监控)
|
||||
├── IP2LOCATION-LITE-DB3.IPV6.BIN # 可选:IPv6 城市库
|
||||
├── GeoLite2-ASN.mmdb # 可选:ASN/运营商
|
||||
├── GeoLite2-Country.mmdb # 可选:国家兜底
|
||||
├── .jwt_secret # JWT 密钥(自动生成)
|
||||
├── filter_words.txt # 敏感词配置
|
||||
└── jiang13.log # 运行日志(若启用)
|
||||
```
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| 主数据库 | `/data/jiang13.db` |
|
||||
| 监控浏览量 | `/data/monitor.db` |
|
||||
| 附件 | `/data/uploads/` |
|
||||
| 请求日志(监控) | `/data/logs/access/` |
|
||||
| 地理库(可选) | `/data/IP2LOCATION-LITE-DB3*.BIN`、`GeoLite2-ASN.mmdb` 等 |
|
||||
|
||||
官方镜像不要求拆成两个 volume;挂好 `/data` 即可同时持久化 SQLite、上传文件与监控日志。
|
||||
|
||||
地理库与 BIN **不内置**于 Docker 镜像。详见 [网站监控设计](monitor.md)。管理端展示完整客户端 IP(不做脱敏)。
|
||||
|
||||
## 设计说明:为何只挂一个 `/data`
|
||||
|
||||
对本项目(单容器、SQLite、默认可本地存附件)而言,**一个数据卷是合理默认**:
|
||||
|
||||
- 部署成本最低,不易漏挂导致「重启丢图 / 丢库」
|
||||
- 备份、迁移时拷贝或快照整个 `/data` 即可恢复站点状态
|
||||
- 密钥(`.jwt_secret`)、敏感词与库同目录,避免「库还在、登录全失效」这类半残状态
|
||||
|
||||
若你习惯「数据库文件夹 / 附件文件夹」分开填写:在本镜像中它们分别是 **`/data` 下的文件与子目录**,不是两个独立容器路径。
|
||||
|
||||
附件特别大、希望对象存储时:优先使用管理后台的 **S3 兼容存储**,而不是拆 Docker 卷。
|
||||
|
||||
## 后续计划(可选增强,非必须)
|
||||
|
||||
按优先级,仅在有明确需求时做:
|
||||
|
||||
1. **文档与面板提示(优先)**
|
||||
- README / 1Panel 说明中固定话术:「只需挂载 `/data`;数据库=`jiang13.db`,监控=`monitor.db`,附件=`uploads/`」。
|
||||
- 本页保持为权威说明,避免用户误以为漏了一个卷。
|
||||
|
||||
2. **进阶:同一 DataDir 下拆挂子路径(文档级,无需改代码)**
|
||||
若必须把库与附件分到不同宿主机磁盘,可在仍使用容器 `/data` 的前提下分别绑定,例如:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
# 推荐:父目录一次挂载
|
||||
- /ssd/jiang13:/data
|
||||
# 或仅把附件分到大容量盘(需保证 /data 下其余文件仍可写)
|
||||
- /ssd/jiang13:/data
|
||||
- /hdd/jiang13-uploads:/data/uploads
|
||||
```
|
||||
|
||||
拆挂时仍要保证 `/data` 下 `.jwt_secret` 等文件可写;不熟悉 Docker 时请继续只用单个 `/data` 挂载。
|
||||
|
||||
3. **产品级:可配置上传根目录(中期)**
|
||||
- 环境变量或 `app.ini` 支持 `upload_dir` 与 `data_dir` 分离。
|
||||
- Docker 示例改为可选双卷;**默认仍单卷**,避免破坏现有部署。
|
||||
|
||||
4. **不做的事**
|
||||
- 不为「看起来像 WordPress」而强制双卷。
|
||||
- 不在未支持分离配置前,在 UI 里假装有两个独立数据根。
|
||||
79
docs/monitor.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 网站监控设计说明
|
||||
|
||||
管理端「网站监控」:轻量自建访问统计,对标 1Panel 思路——**请求流水写文件、访客地理写独立库**,开启后不拖慢前台读帖。
|
||||
|
||||
## 双通道数据
|
||||
|
||||
| 数据 | 存储 | 用途 |
|
||||
|------|------|------|
|
||||
| 请求日志 | `{DataDir}/logs/access/YYYY-MM-DD.jsonl` | 请求日志页、请求数/流量/4xx·5xx、实时曲线 |
|
||||
| page_views | `{DataDir}/monitor.db`(SQLite) | 浏览量/访客、国家/省/城市/运营商排行与地图 |
|
||||
|
||||
请求日志**只写 jsonl**,不进 SQLite。删除过期 `.jsonl` 即释放磁盘。
|
||||
|
||||
`page_views` **不进主库** `jiang13.db`。后台「导出备份」只含主库,不含浏览量。
|
||||
|
||||
### 文件行格式(JSONL)
|
||||
|
||||
每行一条 JSON,字段包括:`t`(RFC3339)、`method`、`path`、`status`、`bytes`、`duration_ms`、`ip`、`ua`、`referer`、`country`、`region`、`region_iso`、`city`、`asn`、`as_org`、`is_bot`。
|
||||
|
||||
### 保留天数
|
||||
|
||||
- `monitor_retention_days`:仅 **page_views**(默认 30)
|
||||
- `monitor_access_log_retention_days`:请求日志文件(默认 7)
|
||||
|
||||
## 地理数据文件
|
||||
|
||||
放在数据目录(与 `jiang13.db` 同级),有则加载、无则静默降级:
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `IP2LOCATION-LITE-DB3.BIN` | IPv4 国家/省/城市 |
|
||||
| `IP2LOCATION-LITE-DB3.IPV6.BIN` | IPv6 国家/省/城市 |
|
||||
| `GeoLite2-ASN.mmdb` | ASN / 运营商 |
|
||||
| `GeoLite2-Country.mmdb` | 可选:BIN 未命中时国家兜底 |
|
||||
|
||||
CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat/Lon/Zip/TimeZone。管理端展示**完整客户端 IP**。
|
||||
|
||||
写入时做本地中文映射(省 / 中国城市按 IP2Location 英文名 / 运营商);同音城市(如苏州/宿州)按省份歧义。`country` 存 ISO2,展示用中文名。历史 `page_views` / 请求日志不会回写,仅影响新写入。
|
||||
|
||||
**口径提示:** 访客地图与城市/运营商排行只聚合 `page_views`(前台浏览);请求日志里的地理来自 access JSONL。仅有请求日志、没有前台浏览时,地图与排行可为空——属预期。
|
||||
|
||||
## 写入路径(性能)
|
||||
|
||||
1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘)。
|
||||
2. 后台 flush:出队 → Geo + 中文映射 → append 当日 jsonl + 更新内存实时环。
|
||||
3. 队列上限 8192,满则丢弃新日志(保护内存与磁盘)。
|
||||
4. pageview 信标 QPS 低,可在写入时同步 Geo 后写独立 `monitor.db`(不占用主库连接)。
|
||||
|
||||
**热路径零 Geo、零写库、零写文件。**
|
||||
|
||||
## 请求日志口径(排除规则)
|
||||
|
||||
默认只关心**前台访客**,排除管理员刷后台产生的壳请求:
|
||||
|
||||
- `/admin`、`/api/admin/`
|
||||
- `/api/me`、`/api/site-branding`
|
||||
- `/health`、`/uploads/`、`/media/`、静态后缀、`/api/monitor/pageview` 等
|
||||
|
||||
设置页可「恢复推荐排除规则」。不强制覆盖用户已保存的自定义列表。
|
||||
|
||||
浏览量/访客仍来自前台路由 pageview(后台路径本身不上报)。
|
||||
|
||||
## 实时与聚合
|
||||
|
||||
- 近 1 分钟 / 近 1 小时:内存环(flush 时更新),避免扫全文件。
|
||||
- 今日请求数等:内存日累加器(进程内;重启后自新流量累积,论坛量级可接受)。
|
||||
- 地理排行:仅 `page_views`。
|
||||
|
||||
## 诊断
|
||||
|
||||
```bash
|
||||
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||
```
|
||||
|
||||
会输出 Geo 文件状态、近 30 日省级 Top,以及对账抽样(英文城市名、有国家但省为空、`asn=0`)。
|
||||
## 明确不做
|
||||
|
||||
- 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库
|
||||
- 不做街道级地图;Docker 镜像不内置 BIN/MMDB
|
||||
@@ -18,6 +18,24 @@
|
||||
|
||||
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
||||
|
||||
## 自定义单页(推荐)
|
||||
|
||||
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
|
||||
|
||||
| 文件 | 标题 | slug | 操作 |
|
||||
| --- | --- | --- | --- |
|
||||
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
|
||||
|
||||
访问路径:`/page/changelog`。
|
||||
|
||||
## 部署指南(置顶帖)
|
||||
|
||||
| 文件 | 标题 | 标签 | 操作 |
|
||||
| --- | --- | --- | --- |
|
||||
| [deployment-guide.md](./deployment-guide.md) | 从零开始部署姜十三论坛 | `部署` `教程` | **置顶**(可选精华) |
|
||||
|
||||
覆盖:服务器选型、宝塔 / 1Panel、Docker / 二进制、域名 HTTPS、备份升级、常见问题。
|
||||
|
||||
## 可选短文
|
||||
|
||||
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
||||
|
||||
193
docs/site-posts/changelog.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# 更新日志
|
||||
|
||||
> 建议标题:更新日志
|
||||
> 建议 slug:`changelog`
|
||||
> 建议路径:`/page/changelog`
|
||||
> 建议操作:后台「单页管理」新建并发布;勾选「页脚展示」(可选「侧栏导航」)
|
||||
> 复制正文时:从下方第一个 `---` **之后**开始粘贴(Markdown 模式)
|
||||
|
||||
---
|
||||
|
||||
Docker 镜像、Windows exe、Linux 单文件 **共用同一版本号**。有新版本时,以本页为准。
|
||||
|
||||
**当前版本:1.1.8**(2026-09-02)
|
||||
|
||||
## 去哪看新版本
|
||||
|
||||
| 渠道 | 看什么 | 地址 |
|
||||
| --- | --- | --- |
|
||||
| 本页 | 版本号、变更说明、升级注意 | 本站 `/page/changelog` |
|
||||
| Docker | 镜像标签(`latest` 与 `1.1.x`) | [Docker Hub · Tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags) |
|
||||
| Windows / Linux / macOS | 预编译单文件(与 Docker 同版本) | [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) |
|
||||
| 源码 | 提交记录与自行编译 | [Gitea 仓库](https://git.iioio.com/freefire/jiang13-forum) |
|
||||
|
||||
**1.1.8 直接下载:**
|
||||
|
||||
- Windows x64:[jiang13_v1.1.8_windows-amd64.exe](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_windows-amd64.exe)
|
||||
- Linux x64:[jiang13_v1.1.8_linux-amd64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64)
|
||||
- macOS arm64:[jiang13_v1.1.8_darwin-arm64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_darwin-arm64)
|
||||
|
||||
拉取指定版本镜像:
|
||||
|
||||
```bash
|
||||
docker pull hangzhang714128/jiang13-forum:1.1.8
|
||||
# 或始终跟随最新
|
||||
docker pull hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
Windows:停掉正在跑的进程或服务,用新下载的 exe 覆盖原文件(可改名为 `jiang13.exe`),**不要动**旁边的 `data/` 与 `app.ini`,再启动即可。
|
||||
|
||||
Linux:赋予执行权限后放到原目录覆盖,同样保留 `data/` 与 `app.ini`。
|
||||
|
||||
## 升级注意
|
||||
|
||||
- 一般只需换镜像或换 exe,数据目录向后兼容。
|
||||
- Docker 请继续挂载原来的 `/data` 卷,切勿新建空目录当「升级」。
|
||||
- 升级后若页面样式异常,强制刷新浏览器(Ctrl+F5)。程序也会在前端资源失效时自动硬刷新。
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.8 · 2026-09-02
|
||||
|
||||
表情选择与评论富文本展示完善,私信表情不再发成图片附件。
|
||||
|
||||
**新增 / 调整**
|
||||
|
||||
- 表情选择器在评论富文本中可直接插入,与文章编辑器一致
|
||||
- 私信页支持表情选择器,点击表情直接插入文本框
|
||||
|
||||
**修复**
|
||||
|
||||
- 私信中插入的表情改为文本内容发送,不再作为图片附件
|
||||
- 消息未读数在多端间同步更及时
|
||||
- 帖子详情快照逻辑修复,避免打开旧缓存页
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.7 · 2026-09-02
|
||||
|
||||
私信与开源展柜体验升级,待审通知的审核态更及时。
|
||||
|
||||
**新增 / 调整**
|
||||
|
||||
- 私信改为飞书风布局:缩略图表情/图片、按会话草稿、用户搜索发起会话
|
||||
- 主页「发私信」直达消息页对应会话
|
||||
- 开源展柜改为目录式 UI,空列表不再粘滞
|
||||
- 消息页体验重构;待审通知打开后实时回填审核态
|
||||
|
||||
**修复**
|
||||
|
||||
- 帖子详情右栏默认头像改为深绿底白字,与列表默认头像配色一致
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.6 · 2026-09-01
|
||||
|
||||
编辑器插入体验与首页默认 Feed 排序更合理。
|
||||
|
||||
**新增 / 调整**
|
||||
|
||||
- 链接对话框支持网址、链接文字与站内帖子/单页搜索;站内搜索收进「高级选项」,点选同步链接文字
|
||||
- 文章与评论共用统一图片插入选择器(上传 / 链接 / 已上传)
|
||||
- 链接是否新标签打开交由全站设置,对话框不再单独开关
|
||||
- 首页默认「最新」按发帖与最后评论的较晚时间混排,零回复新帖不再沉底
|
||||
- 单页详情右侧栏与帖子共用目录布局;三栏主滚动区统一细绿色滚动条
|
||||
|
||||
**修复**
|
||||
|
||||
- 帖子目录从正文同步派生,避免预取缓存竞态导致空树
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.5 · 2026-09-01
|
||||
|
||||
首页首屏与交互更稳,Feed 排序语义更清楚。
|
||||
本版起同时提供 **Docker 镜像** 与 **Windows / Linux 预编译单文件**(Gitea Release `v1.1.5`)。
|
||||
|
||||
**新增 / 调整**
|
||||
|
||||
- 首页改为 Go SSR 与 React hydrate(注水)同构,打开首页不再先闪一层空壳再跳内容
|
||||
- Feed 排序改为:**新评论 / 新帖子 / 推荐帖**;推荐帖只出精华(featured)
|
||||
- 点击排序会强制刷新列表;软刷新时同步站点限额与品牌文案
|
||||
- 返回列表用缓存恢复滚动位置,不再重复请求
|
||||
|
||||
**修复**
|
||||
|
||||
- 发版后软刷新会检测入口壳;chunk(代码分片)404 时自动硬刷新,避免卡在旧页面
|
||||
- 软刷新齐套前保留旧画面,避免一点击就把内容卸光
|
||||
- 补齐 `/favicon.ico` 与页面 head 图标;拆除爬虫专用 HTML
|
||||
- 桌面端隐藏多余的导航汉堡按钮
|
||||
- 帖行评论数、列表「共 N 条」右对齐;排序栏数字紧跟标签
|
||||
- 手机端 SSR 顶栏 / 页脚与 React 一致,减少闪动
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.4 · 2026-08-31
|
||||
|
||||
网站监控的地理信息与口径更准确。
|
||||
|
||||
- 补全 IP2Location(IP 地理位置库)中国城市中文名,同名城市按省消歧
|
||||
- 对齐监控概览「双通道」口径文案与诊断抽样说明
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.3 · 2026-08-31
|
||||
|
||||
管理后台可看站点访问情况。
|
||||
|
||||
- 新增管理端 **网站监控**:浏览量写入独立 `monitor.db`,与主库分开
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.2 · 2026-08-31
|
||||
|
||||
可选接入官方社区展柜。
|
||||
|
||||
- 可选社区上报,以及官方精选展柜(自建站可出现在官方社区列表中)
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.1 · 2026-08-31
|
||||
|
||||
- 评论管理操作收进「更多」菜单,管理员界面更干净
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.1.0 · 2026-08-30
|
||||
|
||||
一批社区功能与界面整理,版本号从 1.0 跨到 1.1。
|
||||
|
||||
**新增**
|
||||
|
||||
- 友链申请、独立友链页;页脚左右区域可分别开关
|
||||
- 自定义单页(后台「单页管理」,如本页)
|
||||
- 投票帖、悬赏帖、抽奖帖
|
||||
- 侧栏签到;右侧栏「最新注册」
|
||||
- 搜索重设计:筛选面板、结果页 chips(筛选标签)
|
||||
- 帖子管理收进右上角菜单;手机端评论输入默认折叠
|
||||
|
||||
**优化**
|
||||
|
||||
- 首页帖子列表密度与标题样式
|
||||
- 开源码桶展示加强,去掉 Feed 顶栏统计
|
||||
- 单页编辑、列表留白与友链申请体验
|
||||
- 开发与发行版共用 `dist/data` 数据目录,避免两套数据打架
|
||||
|
||||
**修复**
|
||||
|
||||
- 手机端搜索入口与底部 Sheet(抽屉面板)
|
||||
- 占位头像对比度;无头像用户不再误用游客灰底
|
||||
- 页面双指缩放锁死,正文原图与灯箱仍可 pinch(捏合)放大
|
||||
- 单页正文排版与帖子详情对齐
|
||||
|
||||
---
|
||||
|
||||
## 📦 v1.0.0 · 2026-08-23
|
||||
|
||||
首次发布 Docker 镜像 `hangzhang714128/jiang13-forum:1.0.0`。
|
||||
|
||||
当时已包含论坛核心能力:板块与发帖、楼层评论、点赞收藏、私信、TipTap 富文本、贴纸、修订历史、管理后台、OIDC(开放身份连接)/ SSO(单点登录)、邮件验证码、Gitea 仓库同步、SQLite 单二进制部署,以及 Docker 一键运行。
|
||||
|
||||
---
|
||||
|
||||
之后发版时:Docker 推 `x.y.z` + `latest`,Gitea 打 tag `vx.y.z` 并挂上对应 exe / linux 文件,再把新版本写到本页最上方。
|
||||
244
docs/site-posts/deployment-guide.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# 从零开始部署姜十三论坛
|
||||
|
||||
> 建议标题:从零开始部署姜十三论坛
|
||||
> 建议 slug:`deploy`
|
||||
> 建议操作:发布后设为「置顶」
|
||||
> 复制正文时:从下方第一个 `---` **之后**开始粘贴
|
||||
|
||||
---
|
||||
|
||||
## 为什么选姜十三
|
||||
|
||||
单二进制 + 内置 SQLite,**不需要 MySQL、PHP、Redis**。面板(宝塔 / 1Panel)只是帮你更方便地管理,不是必须的。
|
||||
|
||||
## 服务器选型
|
||||
|
||||
| 部署方式 | CPU | 内存 | 磁盘 |
|
||||
| --- | --- | --- | --- |
|
||||
| 纯二进制(无面板) | 1 核 | 512MB | 10GB SSD |
|
||||
| 二进制 + 宝塔 | 1 核 | 1GB | 20GB SSD |
|
||||
| Docker + 1Panel | 2 核 | 2GB | 40GB SSD |
|
||||
|
||||
> 推荐 Debian 12 / Ubuntu 22.04。建议直接上 2 核 2G。
|
||||
|
||||
## 部署方式对比
|
||||
|
||||
| | 1Panel + Docker | 宝塔 + 二进制 | 纯命令行 Docker |
|
||||
| --- | --- | --- | --- |
|
||||
| 适合 | 新手 / 还要跑其他容器 | 习惯宝塔 / 只跑这一个 | 熟悉 Linux / 资源紧张 |
|
||||
| 资源占用 | 较高(自带 Docker) | 低 | 最低 |
|
||||
| HTTPS | 面板一键 | 面板一键 | Caddy / certbot |
|
||||
| 备份 | 面板计划任务 | 面板计划任务 | `tar` 打包 |
|
||||
| 升级 | 面板点一下 | 替换二进制 | `docker pull` + 重启 |
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
apt update && apt upgrade -y
|
||||
apt install -y curl wget ca-certificates
|
||||
timedatectl set-timezone Asia/Shanghai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式一:1Panel + Docker
|
||||
|
||||
### 1. 安装 1Panel
|
||||
|
||||
```bash
|
||||
curl -sSL https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh \
|
||||
-o quick_start.sh && bash quick_start.sh
|
||||
```
|
||||
|
||||
- 安装目录:默认 `/opt`
|
||||
- 是否装 Docker:**y**(必须)
|
||||
- 面板端口 / 安全入口 / 账号密码自定义,记好
|
||||
|
||||
安全组放行设置的端口,然后浏览器打开面板。
|
||||
|
||||
### 2. 启动姜十三
|
||||
|
||||
1Panel 面板 → 容器 → 创建:
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| 镜像 | `hangzhang714128/jiang13-forum:latest` |
|
||||
| 端口 | `3000:3000` |
|
||||
| 数据卷 | 主机目录 → 容器内 **`/data`** |
|
||||
| 重启策略 | `unless-stopped` |
|
||||
|
||||
浏览器访问 `http://IP:3000` → **首个注册用户自动成为管理员**。
|
||||
|
||||
> 若容器报 `permission denied`:`chown -R 1000:1000 /数据目录`
|
||||
|
||||
---
|
||||
|
||||
## 方式二:宝塔 + 二进制
|
||||
|
||||
### 1. 安装宝塔
|
||||
|
||||
```bash
|
||||
# 正式版 13.0.0(含 AI,需绑定账号)
|
||||
if [ -f /usr/bin/curl ];then curl -sSO https://download.bt.cn/install/install_panel.sh;else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh;fi;bash install_panel.sh KZOqff1mn
|
||||
|
||||
# 稳定版 12.0.0(纯净、免绑定)
|
||||
url=https://download.bt.cn/install/installStable_12.sh;if [ -f /usr/bin/curl ];then curl -sSO $url;else wget -O installStable_12.sh $url;fi;bash installStable_12.sh 5x59vxy56
|
||||
```
|
||||
|
||||
安全组放行随机端口,浏览器打开(自签名证书提示 → 高级 → 继续前往)。
|
||||
|
||||
### 2. 下载并注册服务
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/jiang13 && cd /opt/jiang13
|
||||
# 版本号以更新日志为准:https://bbs.iioio.com/page/update-log.html
|
||||
wget -O jiang13 https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64
|
||||
chmod +x jiang13
|
||||
|
||||
# 注册为 systemd 服务(install 创建 unit 文件,start 启动,enable 开机自启)
|
||||
./jiang13 -service install
|
||||
./jiang13 -service start
|
||||
systemctl enable jiang13
|
||||
|
||||
# 日常管理
|
||||
./jiang13 -service status # 查看状态
|
||||
./jiang13 -service restart # 重启
|
||||
|
||||
# 验证
|
||||
curl http://127.0.0.1:3000/health
|
||||
```
|
||||
|
||||
> 只是临时跑一下?直接 `./jiang13` 前台启动即可,Ctrl+C 停止,不用 `install`。
|
||||
|
||||
### 2.1 升级二进制
|
||||
|
||||
```bash
|
||||
cd /opt/jiang13
|
||||
./jiang13 -service stop
|
||||
wget -O jiang13.new https://git.iioio.com/freefire/jiang13-forum/releases/download/v新版本/jiang13_v新版本_linux-amd64
|
||||
mv jiang13.new jiang13 && chmod +x jiang13
|
||||
./jiang13 -service start
|
||||
```
|
||||
|
||||
### 3. 宝塔反代 + SSL
|
||||
|
||||
网站 → 添加站点(域名,PHP 选**纯静态**)→ 反向代理(目标 `http://127.0.0.1:3000`)→ SSL → Let's Encrypt 一键签发 → 强制 HTTPS。
|
||||
|
||||
---
|
||||
|
||||
## 方式三:纯 Docker
|
||||
|
||||
```bash
|
||||
# 安装 Docker
|
||||
bash <(curl -sSL https://linuxmirrors.cn/docker.sh)
|
||||
|
||||
# 启动姜十三
|
||||
docker run -d \
|
||||
--name jiang13 \
|
||||
-p 3000:3000 \
|
||||
-v /opt/jiang13-data:/data \
|
||||
--restart unless-stopped \
|
||||
hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
或用 docker compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jiang13:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
ports: ["3000:3000"]
|
||||
volumes: ["./data:/data"]
|
||||
environment: {TZ: Asia/Shanghai}
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
HTTPS 用 Caddy 最省心:
|
||||
|
||||
```
|
||||
bbs.yourdomain.com {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
Caddy 自动申请 Let's Encrypt。
|
||||
|
||||
---
|
||||
|
||||
## 域名 + HTTPS
|
||||
|
||||
域名服务商添加 **A 记录**:`bbs` → 服务器公网 IP。
|
||||
|
||||
| 方式 | HTTPS 操作 |
|
||||
| --- | --- |
|
||||
| 1Panel | 网站 → 创建 → 反向代理 → SSL → Let's Encrypt |
|
||||
| 宝塔 | 同上 |
|
||||
| 纯 Docker | Caddy 自动 / Nginx + certbot |
|
||||
|
||||
---
|
||||
|
||||
## 数据备份
|
||||
|
||||
所有数据(SQLite + 上传 + JWT 密钥)都在**一个目录**里,备份 = 复制目录。
|
||||
|
||||
| 方式 | 数据目录 |
|
||||
| --- | --- |
|
||||
| 1Panel Docker | 你映射的宿主机目录 |
|
||||
| 宝塔二进制 | 二进制同目录下 `data/` |
|
||||
| 纯 Docker | `-v` 映射的目录 |
|
||||
|
||||
```bash
|
||||
tar -czf jiang13-backup-$(date +%Y%m%d).tar.gz /数据目录
|
||||
```
|
||||
|
||||
> ⚠️ `data/.jwt_secret` 务必一起备份,丢了所有用户已登录的 cookie 会失效,需要重新输入密码登录(密码本身存 SQLite 里不受影响)。
|
||||
|
||||
---
|
||||
|
||||
## 版本升级
|
||||
|
||||
版本号以 [更新日志](https://bbs.iioio.com/page/update-log.html) 为准。
|
||||
|
||||
| 方式 | 升级 |
|
||||
| --- | --- |
|
||||
| 1Panel | 容器列表 → 选中 → 镜像更新 |
|
||||
| docker compose | `docker compose pull && docker compose up -d` |
|
||||
| 二进制 | 见上方「2.1 升级二进制」 |
|
||||
|
||||
升级前先备份:`tar -czf backup.tar.gz /数据目录`
|
||||
|
||||
---
|
||||
|
||||
## 面板常用命令
|
||||
|
||||
| 操作 | 宝塔 | 1Panel |
|
||||
| --- | --- | --- |
|
||||
| 菜单 | `bt` | `1pctl` |
|
||||
| 查入口 / 密码 | `bt default` | `1pctl user-info` |
|
||||
| 改密码 | `bt 14` | `1pctl update password` |
|
||||
| 重启面板 | `bt restart` | — |
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q:面板 / 网站打不开?** 安全组有没有放行对应端口?`netstat -tlnp | grep 端口` 看服务在不在监听。
|
||||
|
||||
**Q:宝塔 vs 1Panel 怎么选?** 只跑姜十三 → 宝塔 + 二进制(省资源);还要跑别的 → 1Panel。
|
||||
|
||||
**Q:支持邮件 / SSO 吗?** 邮件 SMTP 和 OIDC SSO 都在**管理后台 → 系统设置**里配置。
|
||||
|
||||
**Q:源码编译?** 见项目 README。推荐用预编译包。
|
||||
|
||||
## 链接
|
||||
|
||||
| 项目 | 地址 |
|
||||
| --- | --- |
|
||||
| 在线演示 | https://bbs.iioio.com/ |
|
||||
| 更新日志 | https://bbs.iioio.com/page/update-log.html |
|
||||
| Docker Hub | hangzhang714128/jiang13-forum |
|
||||
| 预编译下载 | https://git.iioio.com/freefire/jiang13-forum/releases |
|
||||
|
||||
有问题欢迎本帖留言。
|
||||
@@ -1,77 +0,0 @@
|
||||
package embed_static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
||||
spaBrandTitleFn func() string
|
||||
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
||||
)
|
||||
|
||||
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
||||
func SetSPADocumentTitle(fn func() string) {
|
||||
spaBrandTitleFn = fn
|
||||
}
|
||||
|
||||
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
|
||||
func SetSPABrandingJSON(fn func() []byte) {
|
||||
spaBrandJSONFn = fn
|
||||
}
|
||||
|
||||
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
||||
func SetupEmbed(r *gin.Engine) error {
|
||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
||||
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
|
||||
r.GET("/assets/*filepath", func(c *gin.Context) {
|
||||
// hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
|
||||
func ServeSPA(c *gin.Context) {
|
||||
ServeSPAWithMeta(c, nil)
|
||||
}
|
||||
|
||||
// ServeSPANoIndex 返回带 noindex 的 SPA(登录/后台等私密页)
|
||||
func ServeSPANoIndex(c *gin.Context) {
|
||||
title := ""
|
||||
if spaBrandTitleFn != nil {
|
||||
title = strings.TrimSpace(spaBrandTitleFn())
|
||||
}
|
||||
ServeSPAWithMeta(c, &SPAPageMeta{
|
||||
Title: title,
|
||||
Robots: "noindex,nofollow",
|
||||
})
|
||||
}
|
||||
|
||||
// IsSPARoute 判断是否应由 SPA 处理
|
||||
func IsSPARoute(path string) bool {
|
||||
if path == "/robots.txt" || path == "/sitemap.xml" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(path, "/api") ||
|
||||
strings.HasPrefix(path, "/admin") ||
|
||||
strings.HasPrefix(path, "/uploads") ||
|
||||
strings.HasPrefix(path, "/media") ||
|
||||
strings.HasPrefix(path, "/assets") ||
|
||||
strings.HasPrefix(path, "/oauth") ||
|
||||
strings.HasPrefix(path, "/.well-known") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package embed_static
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据(仅 <head>,不写 #root,避免刷新闪屏)
|
||||
type SPAPageMeta struct {
|
||||
Title string // 完整 <title>
|
||||
Description string
|
||||
Keywords string // meta keywords
|
||||
Canonical string
|
||||
OGType string // 默认 website
|
||||
OGImage string
|
||||
SiteName string // og:site_name
|
||||
Locale string // og:locale,默认 zh_CN
|
||||
Robots string // 如 noindex,nofollow
|
||||
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
|
||||
Status int // HTTP 状态码,0 视为 200
|
||||
}
|
||||
|
||||
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
|
||||
func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
||||
status := http.StatusOK
|
||||
if meta != nil && meta.Status != 0 {
|
||||
status = meta.Status
|
||||
}
|
||||
data, err := staticFS.ReadFile("static/spa/index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
||||
return
|
||||
}
|
||||
data = applySPAPageMeta(data, meta)
|
||||
// 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(status, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
|
||||
if meta == nil {
|
||||
meta = &SPAPageMeta{}
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(meta.Title)
|
||||
if title == "" && spaBrandTitleFn != nil {
|
||||
title = strings.TrimSpace(spaBrandTitleFn())
|
||||
}
|
||||
if title != "" {
|
||||
escaped := html.EscapeString(title)
|
||||
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
|
||||
}
|
||||
|
||||
var head strings.Builder
|
||||
writeMeta(&head, "description", meta.Description)
|
||||
writeMeta(&head, "keywords", meta.Keywords)
|
||||
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
||||
head.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
||||
}
|
||||
robots := strings.TrimSpace(meta.Robots)
|
||||
if robots != "" {
|
||||
writeMeta(&head, "robots", robots)
|
||||
}
|
||||
|
||||
ogType := strings.TrimSpace(meta.OGType)
|
||||
if ogType == "" {
|
||||
ogType = "website"
|
||||
}
|
||||
locale := strings.TrimSpace(meta.Locale)
|
||||
if locale == "" {
|
||||
locale = "zh_CN"
|
||||
}
|
||||
writeProp(&head, "og:type", ogType)
|
||||
writeProp(&head, "og:site_name", meta.SiteName)
|
||||
writeProp(&head, "og:locale", locale)
|
||||
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title))
|
||||
writeProp(&head, "og:description", meta.Description)
|
||||
writeProp(&head, "og:url", meta.Canonical)
|
||||
writeProp(&head, "og:image", meta.OGImage)
|
||||
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage))
|
||||
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title))
|
||||
writeMetaName(&head, "twitter:description", meta.Description)
|
||||
writeMetaName(&head, "twitter:image", meta.OGImage)
|
||||
|
||||
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
||||
head.WriteString(`<script type="application/ld+json">`)
|
||||
head.WriteString(jsonld)
|
||||
head.WriteString(`</script>`)
|
||||
}
|
||||
|
||||
// 同步注入品牌配置,避免 React 首屏用默认名闪一下
|
||||
if boot := spaBrandingBootScript(); boot != "" {
|
||||
head.WriteString(boot)
|
||||
}
|
||||
|
||||
if head.Len() > 0 {
|
||||
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
||||
func spaBrandingBootScript() string {
|
||||
if spaBrandJSONFn == nil {
|
||||
return ""
|
||||
}
|
||||
raw := bytes.TrimSpace(spaBrandJSONFn())
|
||||
if len(raw) == 0 || !json.Valid(raw) {
|
||||
return ""
|
||||
}
|
||||
// 防止 JSON 字符串中的 </script> 提前闭合标签
|
||||
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
|
||||
}
|
||||
|
||||
func writeMeta(b *strings.Builder, name, content string) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString(`<meta name="` + html.EscapeString(name) + `" content="` + html.EscapeString(content) + `"/>`)
|
||||
}
|
||||
|
||||
func writeMetaName(b *strings.Builder, name, content string) {
|
||||
writeMeta(b, name, content)
|
||||
}
|
||||
|
||||
func writeProp(b *strings.Builder, prop, content string) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString(`<meta property="` + html.EscapeString(prop) + `" content="` + html.EscapeString(content) + `"/>`)
|
||||
}
|
||||
|
||||
func twitterCard(ogImage string) string {
|
||||
if strings.TrimSpace(ogImage) != "" {
|
||||
return "summary_large_image"
|
||||
}
|
||||
return "summary"
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"config": "",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="拾三一隅,自在交流" />
|
||||
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||
<style>
|
||||
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
|
||||
html { scrollbar-gutter: stable; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
|
||||
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-header { height: 56px; flex-shrink: 0; }
|
||||
.site-footer { flex-shrink: 0; }
|
||||
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
|
||||
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.sidebar { width: 210px; flex-shrink: 0; }
|
||||
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.aside-panel { width: 280px; flex-shrink: 0; }
|
||||
@media (max-width: 1100px) { .aside-panel { display: none; } }
|
||||
@media (max-width: 768px) { .sidebar { display: none; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var theme = localStorage.getItem('j13-theme') || 'light';
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5587
frontend/package-lock.json
generated
@@ -4,11 +4,15 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "vike dev",
|
||||
"build": "vike build",
|
||||
"preview": "vike preview",
|
||||
"start": "node server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
@@ -16,7 +20,9 @@
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@svg-maps/china": "^2.0.0",
|
||||
"@svg-maps/world": "^2.0.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-code-block": "^3.26.1",
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
@@ -27,35 +33,44 @@
|
||||
"@tiptap/pm": "^3.26.1",
|
||||
"@tiptap/react": "^3.26.1",
|
||||
"@tiptap/starter-kit": "^3.26.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compression": "^1.8.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"express": "^5.2.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"http-proxy-middleware": "^4.2.0",
|
||||
"linkedom": "^0.18.13",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-router": "^8.3.1",
|
||||
"react-streaming": "^0.4.20",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vike": "^0.4.266",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true
|
||||
}
|
||||
}
|
||||
|
||||
12
frontend/pages/+config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Config } from 'vike/types';
|
||||
|
||||
export default {
|
||||
// pageProps / data(+data 返回的 boot / snapshot / head)透传给客户端供 hydrate seed
|
||||
passToClient: ['pageProps', 'data'],
|
||||
// 自定义集成(无 vike-react):声明 `ssr` 配置供页面 +ssr.ts 使用(SPA 壳页 ssr: false)
|
||||
meta: {
|
||||
ssr: {
|
||||
env: { server: true },
|
||||
},
|
||||
},
|
||||
} satisfies Config;
|
||||
10
frontend/pages/_error/+Page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import App from '../../src/App';
|
||||
import type { PageContext } from 'vike/types';
|
||||
|
||||
export { Page };
|
||||
|
||||
/** 错误页:复用 App 壳层与路由树 —— /post/:id 未找到渲染帖子自己的未找到态,未知路径渲染 catch-all NotFoundPage */
|
||||
function Page(pageContext: PageContext & { url?: string }) {
|
||||
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
|
||||
return <App url={url} />;
|
||||
}
|
||||
24
frontend/pages/_error/+data.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { PageContextServer } from 'vike/types';
|
||||
import type { FeedData } from '../../src/ssr/feedSeed';
|
||||
|
||||
export { data };
|
||||
|
||||
type NotFoundAbortReason = {
|
||||
head?: FeedData['head'];
|
||||
boot?: FeedData['boot'];
|
||||
};
|
||||
|
||||
/**
|
||||
* 错误页取数:feed +data 未找到(帖子/板块)时 throw render(404, { head, boot }),
|
||||
* 壳层数据经 abortReason 带过来,保证 404 页也渲染完整站点壳与 noindex head。
|
||||
*/
|
||||
async function data(pageContext: PageContextServer): Promise<FeedData> {
|
||||
if (!import.meta.env.SSR) return { kind: 'notfound' };
|
||||
|
||||
const reason = pageContext.abortReason as NotFoundAbortReason | undefined;
|
||||
return {
|
||||
kind: 'notfound',
|
||||
head: reason?.head,
|
||||
boot: reason?.boot,
|
||||
};
|
||||
}
|
||||
11
frontend/pages/feed/+Page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import App from '../../src/App';
|
||||
import type { PageContext } from 'vike/types';
|
||||
|
||||
export { Page };
|
||||
|
||||
/** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */
|
||||
function Page(pageContext: PageContext & { url?: string }) {
|
||||
// onRenderHtml 透传 url(pathname + search);urlOriginal 兜底(含 query)
|
||||
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
|
||||
return <App url={url} />;
|
||||
}
|
||||
9
frontend/pages/feed/+data.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { PageContextServer } from 'vike/types';
|
||||
import { loadFeedData } from '../../src/ssr/feedData';
|
||||
|
||||
export { data };
|
||||
|
||||
// SSR 白名单页(/ 、/board/:id 、/post/:id)服务端取数 + head 构建
|
||||
async function data(pageContext: PageContextServer) {
|
||||
return await loadFeedData(pageContext);
|
||||
}
|
||||
11
frontend/pages/feed/+route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PageContext } from 'vike/types';
|
||||
import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
|
||||
|
||||
export { route };
|
||||
|
||||
/** SSR 白名单:`/`、`/board/:id`、`/post/:id`(含 `.html` 伪静态) */
|
||||
function route(pageContext: PageContext) {
|
||||
const match = matchFeedSsrPath(pageContext.urlPathname);
|
||||
if (!match) return false;
|
||||
return { routeParams: { kind: match.kind, id: match.id } };
|
||||
}
|
||||
8
frontend/pages/spa/+Page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import App from '../../src/App';
|
||||
|
||||
export { Page };
|
||||
|
||||
/** SPA 壳页:客户端整树挂载 react-router(服务端不执行本组件) */
|
||||
function Page() {
|
||||
return <App />;
|
||||
}
|
||||
10
frontend/pages/spa/+route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { PageContext } from 'vike/types';
|
||||
import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
|
||||
|
||||
export { route };
|
||||
|
||||
/** catch-all:SSR feed 白名单之外的一切 URL */
|
||||
function route(pageContext: PageContext) {
|
||||
if (matchFeedSsrPath(pageContext.urlPathname)) return false;
|
||||
return { routeParams: {} };
|
||||
}
|
||||
4
frontend/pages/spa/+ssr.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ssr };
|
||||
|
||||
/** SPA 壳:服务端只出空壳 HTML,客户端整树挂载 react-router */
|
||||
const ssr = false;
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend/public/stickers/bilibili/bl_01.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_02.avif
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/stickers/bilibili/bl_03.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_04.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_05.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_06.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_07.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_08.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_09.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_10.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_11.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_12.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_13.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_14.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_15.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_16.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_17.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_18.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_19.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_20.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_21.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_22.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_23.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/bilibili/bl_24.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_25.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_26.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_27.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_28.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_29.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_30.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_31.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_32.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_33.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/bilibili/bl_34.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_35.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_01.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_02.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_03.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_04.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_05.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_06.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_07.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/douyin/dy_08.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_09.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_10.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/douyin/dy_11.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_12.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/douyin/dy_13.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_14.avif
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/stickers/douyin/dy_15.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_16.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_17.avif
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/stickers/douyin/dy_18.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_19.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_20.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_21.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_22.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_23.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_24.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_25.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/douyin/dy_26.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_27.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_28.avif
Normal file
|
After Width: | Height: | Size: 2.2 KiB |