fix: 完成 Gitea 目录改组收尾(import、构建与 LICENSE)

同步包路径与路由,去掉 SPA 构建步骤,对齐 Gitea 式 LICENSE,并更新规格/规则与占位 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 04:01:20 +08:00
parent 9fe299a45f
commit 3f50316ad0
93 changed files with 1472 additions and 1522 deletions

View File

@@ -5,7 +5,8 @@ alwaysApply: true
# 编译与构建脚本 # 编译与构建脚本
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。 本分支(`rebuild/gitea-ssr`Go 单二进制 + `go:embed` 的 **templates** 与 **public/assets**(由 `web_src` 构建);产物在 `dist/`。
对照 SPA 仅在 `main` 分支。
## 运行编译(优先用封装命令,不要猜命令) ## 运行编译(优先用封装命令,不要猜命令)
@@ -18,15 +19,16 @@ Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。 Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
常用 target`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。 常用 target`build`(默认)、`dev`、`run`、`web-src`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
## 修改构建脚本时的约定 ## 修改构建脚本时的约定
1. **双轨同步**`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。 1. **双轨同步**`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
2. **`build.bat` 仅用 ASCII 注释**`.bat` 会被 cmd 按 GBK 解析UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。 2. **`build.bat` 仅用 ASCII 注释**`.bat` 会被 cmd 按 GBK 解析UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
3. **`build.ps1` 可用 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`。 4. **构建顺序**:先 `web_src` 内 `npm run build`(产出 `public/assets`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
5. **入口包**`./cmd/jiang13`Windows 产物带 `.exe`。 5. **入口包**`./cmd/jiang13`Windows 产物带 `.exe`。
6. **本分支无 `frontend/`**:勿再添加 SPA 构建步骤;需要对照 UI 时 `git checkout main`。
## 新增 target 检查清单 ## 新增 target 检查清单

View File

@@ -21,9 +21,14 @@ alwaysApply: true
对齐 [Gitea](https://github.com/go-gitea/gitea) 职责划分: 对齐 [Gitea](https://github.com/go-gitea/gitea) 职责划分:
- `routers/web` — HTML 页面路由 - `routers/web` — HTML 页面路由
- `routers/api` — JSON API原 `handler/`
- `routers/setup.go` — 路由总装
- `templates/` — 模板 - `templates/` — 模板
- `web_src/` → `public/assets/` — 渐进增强 CSS/JS - `web_src/` → `public/assets/` — 渐进增强 CSS/JS
- 现有 `service/`、`model/` 可复用业务逻辑 - `models/`、`services/` — 数据与业务
- `modules/auth`、`modules/webrender`、`modules/seo` — 横切
本分支**已删除** `frontend/` 与 `embed_static/`;勿再恢复 SPA 生产回落。
细节见 [`docs/rebuild-spec/08-gitea-ssr-architecture.md`](docs/rebuild-spec/08-gitea-ssr-architecture.md)。 细节见 [`docs/rebuild-spec/08-gitea-ssr-architecture.md`](docs/rebuild-spec/08-gitea-ssr-architecture.md)。

View File

@@ -4,17 +4,17 @@
.idea .idea
.vscode .vscode
.cursor .cursor
.trae
# 运行时数据与本地配置 # 运行时数据与本地配置
data/ data/
app.ini app.ini
tmp-cookie.txt tmp-cookie.txt
# 编译产物与前端缓存 # 编译产物与依赖缓存
dist/ dist/
frontend/node_modules/ web_src/node_modules/
frontend/dist/ public/assets/
embed_static/static/spa/
node_modules/ node_modules/
.vite/ .vite/

18
.gitignore vendored
View File

@@ -4,22 +4,28 @@
# 本地配置(保留 app.ini.example # 本地配置(保留 app.ini.example
/app.ini /app.ini
# 前端依赖与构建缓存 # Node / 构建缓存
/node_modules/ /node_modules/
/frontend/node_modules/ /web_src/node_modules/
/frontend/dist/ .vite/
/embed_static/static/spa/ *.tsbuildinfo
# Go 编译产物 # Go 编译产物(统一进 dist/;勿把二进制扔在仓库根目录)
/dist/ /dist/
*.exe *.exe
/jiang13
/jiang13-*
!/cmd/jiang13/
# 临时文件 # 临时文件
tmp-cookie.txt tmp-cookie.txt
*-err.txt
*-out.txt
# 编辑器 / OS # 编辑器 / AI 草稿 / OS
.idea/ .idea/
.vscode/ .vscode/
.trae/
*.swp *.swp
Thumbs.db Thumbs.db
.DS_Store .DS_Store

View File

@@ -1,4 +1,4 @@
# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像 # 姜十三论坛 — 多阶段构建:web_src → Go 单二进制 → Alpine 运行镜像
# 不使用 # syntax=docker/dockerfile:1避免构建前额外拉取 docker.io/docker/dockerfile # 不使用 # syntax=docker/dockerfile:1避免构建前额外拉取 docker.io/docker/dockerfile
# #
# 国内网络:默认经 DaoCloud 拉取基础镜像npm/go 走国内代理 # 国内网络:默认经 DaoCloud 拉取基础镜像npm/go 走国内代理
@@ -8,14 +8,12 @@
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/ ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
ARG VERSION=dev ARG VERSION=dev
# ── Stage 1: 前端构建Vite → embed_static/static/spa──────────────────── # ── Stage 1: SSR 渐进资源web_src → public/assets────────────────────────
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS frontend FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS websrc
WORKDIR /src/frontend WORKDIR /src/web_src
COPY frontend/package.json frontend/package-lock.json ./ COPY web_src/package.json ./
RUN npm config set registry https://registry.npmmirror.com \ COPY web_src/ ./
&& npm ci RUN node build.mjs
COPY frontend/ ./
RUN npm run build
# ── Stage 2: Go 编译(纯 Go SQLiteCGO_ENABLED=0──────────────────────── # ── Stage 2: Go 编译(纯 Go SQLiteCGO_ENABLED=0────────────────────────
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
@@ -25,7 +23,7 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=frontend /src/embed_static/static/spa ./embed_static/static/spa COPY --from=websrc /src/public/assets ./public/assets
RUN CGO_ENABLED=0 go build -trimpath \ RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \ -ldflags "-s -w -X main.version=${VERSION}" \
-o /out/jiang13 ./cmd/jiang13 -o /out/jiang13 ./cmd/jiang13

12
LICENSE
View File

@@ -1,6 +1,4 @@
MIT License Copyright (c) 2026 The Jiang13 Authors
Copyright (c) 2026 freefire
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +7,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in
copies or substantial portions of the Software. all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
SOFTWARE. THE SOFTWARE.

View File

@@ -1,5 +1,5 @@
# 姜十三论坛 Jiang13 Forum - Makefile # 姜十三论坛 Jiang13 Forum - Makefile
# Go 1.26 单二进制编译,与 Gitea 打包方式一致 # Go 1.26 单二进制templates SSR + web_src 渐进资源(本分支无 React SPA
APP_NAME := jiang13 APP_NAME := jiang13
MAIN_PKG := ./cmd/jiang13 MAIN_PKG := ./cmd/jiang13
@@ -12,42 +12,39 @@ REGISTRY_IMAGE := hangzhang714128/jiang13-forum
GO := go GO := go
GOFLAGS := -trimpath GOFLAGS := -trimpath
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build web-src-build docker compose-up compose-down .PHONY: all build build-windows build-linux build-darwin build-all clean run dev tidy help web-src-build docker compose-up compose-down
all: build all: build
web-src-build: web-src-build:
cd web_src && npm run build cd web_src && npm run build
frontend-build:
cd frontend && npm install && npm run build
## 编译当前平台二进制(纯 Go SQLite无需 CGO ## 编译当前平台二进制(纯 Go SQLite无需 CGO
build: web-src-build frontend-build build: web-src-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG) CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)" @echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
## Windows amd64(先打包前端再 embed ## Windows amd64
build-windows: web-src-build frontend-build build-windows: web-src-build
@mkdir -p $(BUILD_DIR) @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=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe" @echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
## Linux amd64(先打包前端再 embed ## Linux amd64
build-linux: web-src-build frontend-build build-linux: web-src-build
@mkdir -p $(BUILD_DIR) @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) 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" @echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
## macOS arm64 (Apple Silicon)(先打包前端再 embed ## macOS arm64 (Apple Silicon)
build-darwin: web-src-build frontend-build build-darwin: web-src-build
@mkdir -p $(BUILD_DIR) @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) 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" @echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
## 跨平台全量编译web-src + frontend 只跑一次) ## 跨平台全量编译web_src 只跑一次)
build-all: web-src-build frontend-build build-all: web-src-build
@mkdir -p $(BUILD_DIR) @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=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) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@@ -59,20 +56,13 @@ build-all: web-src-build frontend-build
tidy: tidy:
$(GO) mod tidy $(GO) mod tidy
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致 ## 本地运行 SSR先构建 web_src
run: run: web-src-build
@mkdir -p $(DEV_DATA_DIR) @mkdir -p $(DEV_DATA_DIR)
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR) $(GO) run $(MAIN_PKG) --work-path . --data $(DEV_DATA_DIR)
## 前端热更新开发(后端 :3000 + Vite :5173Ctrl+C 同时退出;数据目录与 dist 二进制一致 ## 同 runSPA 对照请 git checkout main
dev: dev: run
@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) --dev --data $(DEV_DATA_DIR) & \
cd frontend && (test -d node_modules || npm install) && npm run dev
## 清理编译产物 ## 清理编译产物
clean: clean:
@@ -91,15 +81,15 @@ compose-down:
docker compose down docker compose down
help: help:
@echo "姜十三论坛编译命令:" @echo "姜十三论坛编译命令 (rebuild/gitea-ssr):"
@echo " make web-src-build - 构建 SSR 渐进资源 (web_src)" @echo " make web-src-build - 构建 SSR 渐进资源 (web_src)"
@echo " make build - 编译当前平台" @echo " make build - web_src + 编译当前平台"
@echo " make build-windows - 编译 Windows" @echo " make build-windows - 编译 Windows"
@echo " make build-linux - 编译 Linux" @echo " make build-linux - 编译 Linux"
@echo " make build-darwin - 编译 macOS" @echo " make build-darwin - 编译 macOS"
@echo " make build-all - 编译全部平台" @echo " make build-all - 编译全部平台"
@echo " make run - 启动后端 SSR:3000" @echo " make run / make dev - 启动 SSR:3000"
@echo " make dev - 后端 + 旧 SPA Vite 对照(:5173 + :3000"
@echo " make docker - 构建 Docker 镜像" @echo " make docker - 构建 Docker 镜像"
@echo " make compose-up - Docker Compose 启动" @echo " make compose-up - Docker Compose 启动"
@echo " make compose-down - Docker Compose 停止" @echo " make compose-down - Docker Compose 停止"
@echo " SPA 对照: git checkout main"

102
README.md
View File

@@ -5,7 +5,8 @@
**能聊 · 好看 · 好装** **能聊 · 好看 · 好装**
面向小圈子、团队与同好社群的轻量现代化论坛。 面向小圈子、团队与同好社群的轻量现代化论坛。
编译为单个 Go 二进制,前端 SPA单页应用内嵌内置 SQLite拷到服务器即可运行。 本分支(`rebuild/gitea-ssr`Go 模板真 SSR + `web_src` 渐进增强,单二进制 + SQLite。
对照 React SPA 请见 `main` 分支。
<br> <br>
@@ -13,7 +14,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/Docker-hangzhang714128%2Fjiang13--forum-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/hangzhang714128/jiang13-forum) [![Docker](https://img.shields.io/badge/Docker-hangzhang714128%2Fjiang13--forum-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod)
[![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json) [![SSR](https://img.shields.io/badge/SSR-Go_html%2Ftemplate-00ADD8?style=flat-square&logo=go&logoColor=white)](docs/rebuild-spec/08-gitea-ssr-architecture.md)
[![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#) [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#)
[在线演示](https://bbs.iioio.com/) · [在线演示](https://bbs.iioio.com/) ·
@@ -31,8 +32,8 @@
<br> <br>
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/) > **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)(现网多为 `main` SPA
> 项目积极开发中。管理后台已统一为 React SPA`/admin`欢迎提 Issue / PR 共建。 > 本分支按 [Gitea 式 SSR 规格](docs/rebuild-spec/08-gitea-ssr-architecture.md) 重构;欢迎提 Issue / PR 共建。
</div> </div>
@@ -142,7 +143,7 @@ make build
**手动分步(全平台):** **手动分步(全平台):**
```bash ```bash
cd frontend && npm install && npm run build cd web_src && npm run build
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13 cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
``` ```
@@ -325,80 +326,61 @@ C:\jiang13\jiang13.exe --service start
| 层级 | 技术 | | 层级 | 技术 |
|------|------| |------|------|
| **后端** | Go 1.26 · Gin · GORM · SQLite | | **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite · `html/template` |
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual | | **渐进资源** | `web_src/`(构建到 `public/assets/`URL `/ssr-assets/` |
| **构建** | Vite`go:embed` 内嵌 SPA,单二进制发布 | | **构建** | `web_src``go:embed` templates + assets,单二进制发布 |
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider | | **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
| **对照 SPA** | 仅 `main` 分支React 18 · TipTap · Vite |
--- ---
## 前端开发 ## 本地开发SSR
日常改前端不需要重新完整构建Vite 支持秒级热更新HMR热模块替换
```bat ```bat
build.bat -Target dev build.bat -Target run
``` ```
```bash ```bash
make dev make run
``` ```
浏览器访问 `http://localhost:5173`API 自动代理到 `http://localhost:3000` 浏览器访问 `http://localhost:3000`。数据目录默认 `dist/data`
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致 改模板 / Go 后重启进程;改 `web_src` 后需再跑 `build.bat -Target web-src`(或完整 `build`
**何时需要完整构建:** 需要对照旧 SPA UI`git checkout main``git worktree add ../jiang13-spa main`
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
- 更新 README 界面截图 → 见下方「更新截图」
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
### 更新截图
默认从演示站抓取到 `docs/screenshots/`(需本机已安装 Playwright
```bash
npm install -D playwright
npx playwright install chromium
node scripts/capture-screenshots.mjs
```
| 环境变量 | 说明 | 默认 |
|----------|------|------|
| `J13_URL` | 抓取目标 | `https://bbs.iioio.com` |
| `J13_POST_ID` | 详情页帖子 ID | `1` |
| `J13_RICH_POST_ID` | 富文本展示帖 ID | `8` |
| `J13_USER` / `J13_PASS` | 发帖页登录(可选) | `admin` / `admin123` |
本地站点示例:`J13_URL=http://localhost:3000 node scripts/capture-screenshots.mjs`
--- ---
## 项目结构 ## 项目结构
``` ```
jiang13-forum/ jiang13-forum/ # 分支 rebuild/gitea-ssr
├── cmd/jiang13/ # 程序入口(含系统服务注册) ├── cmd/jiang13/ # 程序入口(含系统服务注册)
├── config/ # app.ini 与命令行配置 ├── config/ # app.ini 与命令行配置
├── app.ini.example # 配置文件示例 ├── app.ini.example
├── Dockerfile # 多阶段 Docker 构建 ├── Dockerfile # web_src → Go → Alpine
├── docker-compose.yml # 单容器 Compose 部署 ├── docker-compose.yml
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限) ├── models/ # GORM 模型
├── .dockerignore ├── services/ # 业务逻辑
├── model/ # GORM 模型与数据库迁移 ├── routers/
├── service/ # 业务逻辑 ├── setup.go # 路由总装
├── handler/ # HTTP 处理器(前台 + 后台) │ ├── web/ # HTML SSR
├── middleware/ # JWT 鉴权等 │ └── api/ # JSON API
├── router/ # 路由注册 ├── modules/
├── embed_static/ # go:embed 内嵌的 SPA │ ├── auth/ # JWT / 限流
├── frontend/ # React 源码Vite 构建) │ ├── webrender/ # 模板渲染
├── docs/screenshots/ # README 界面截图 │ └── seo/
├── ROADMAP.md # 路线图与已知问题 ├── templates/ # Go html/templateembed
── scripts/ # 开发辅助脚本(含截图) ── web_src/ # 渐进 CSS/JS 源码
├── public/assets/ # web_src 构建产物embed
├── docs/rebuild-spec/ # 产品规格与 SSR 架构
├── docs/screenshots/
└── ROADMAP.md
``` ```
> SPA 源码树仅存在于 `main``frontend/`、`embed_static/`)。
--- ---
## 数据目录 ## 数据目录
@@ -424,7 +406,7 @@ data/
|------|------| |------|------|
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 | | ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
| ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 | | ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 |
| ✅ 管理后台 | React SPA仪表盘、置顶 / 精华、禁言、系统设置 | | ✅ 管理后台 | JSON API 已就绪;本分支管理 UI 为 SSR 占位(完整后台见 `main` SPA |
| 📋 计划中 | 通知动态优化、邮件提醒 | | 📋 计划中 | 通知动态优化、邮件提醒 |
--- ---
@@ -439,4 +421,4 @@ data/
## 许可证 ## 许可证
[MIT](LICENSE) — 自由使用、修改与分发。 [MIT](LICENSE)(与 [Gitea](https://github.com/go-gitea/gitea) 相同的 Expat 文本格式)— 自由使用、修改与分发。

View File

@@ -1,9 +1,10 @@
# Jiang13 Forum - Windows build script (replaces GNU Make) # Jiang13 Forum - Windows build script (replaces GNU Make)
# Usage: .\build.ps1 # Usage: .\build.ps1
# .\build.ps1 -Target build-windows # .\build.ps1 -Target build-windows
# Branch rebuild/gitea-ssr: Go templates SSR + web_src (no React SPA)
param( param(
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'web-src', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')] [ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'web-src', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
[string]$Target = 'build' [string]$Target = 'build'
) )
@@ -33,20 +34,6 @@ function Build-WebSrc {
} }
} }
function Build-Frontend {
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan
Push-Location frontend
try {
if (-not (Test-Path node_modules)) {
npm install
}
npm run build
if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' }
} finally {
Pop-Location
}
}
function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') { function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
Ensure-Dir $BuildDir Ensure-Dir $BuildDir
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue } if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
@@ -80,23 +67,22 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
switch ($Target) { switch ($Target) {
'help' { 'help' {
Write-Host '.\build.ps1 build current platform' Write-Host '.\build.ps1 build current platform (web_src + go)'
Write-Host '.\build.ps1 -Target frontend SPA frontend only (legacy)' Write-Host '.\build.ps1 -Target web-src SSR progressive assets only'
Write-Host '.\build.ps1 -Target web-src SSR progressive assets'
Write-Host '.\build.ps1 -Target build-windows' Write-Host '.\build.ps1 -Target build-windows'
Write-Host '.\build.ps1 -Target build-linux' Write-Host '.\build.ps1 -Target build-linux'
Write-Host '.\build.ps1 -Target build-all' Write-Host '.\build.ps1 -Target build-all'
Write-Host '.\build.ps1 -Target run backend (SSR on :3000)' Write-Host '.\build.ps1 -Target run SSR on :3000'
Write-Host '.\build.ps1 -Target dev backend + Vite SPA对照' Write-Host '.\build.ps1 -Target dev same as run (SSR; SPA is on main)'
Write-Host '.\build.ps1 -Target tidy' Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean' Write-Host '.\build.ps1 -Target clean'
Write-Host '.\build.ps1 -Target docker build Docker image' Write-Host '.\build.ps1 -Target docker'
Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build' Write-Host '.\build.ps1 -Target compose-up'
Write-Host '.\build.ps1 -Target compose-down docker compose down' Write-Host '.\build.ps1 -Target compose-down'
Write-Host '' Write-Host ''
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.' Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
Write-Host 'SPA reference: git checkout main (or origin/main).'
} }
'frontend' { Build-Frontend }
'web-src' { Build-WebSrc } 'web-src' { Build-WebSrc }
'tidy' { go mod tidy } 'tidy' { go mod tidy }
'clean' { 'clean' {
@@ -105,54 +91,33 @@ switch ($Target) {
} }
'run' { 'run' {
Ensure-Dir $DevDataDir Ensure-Dir $DevDataDir
go run $MainPkg --data $DevDataDir Build-WebSrc
go run $MainPkg --work-path . --data $DevDataDir
} }
'dev' { 'dev' {
$root = (Get-Location).Path
Ensure-Dir $DevDataDir Ensure-Dir $DevDataDir
Write-Host '' Build-WebSrc
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green Write-Host '[dev] SSR: http://localhost:3000 (SPA 对照请 checkout main)' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green go run $MainPkg --work-path . --data $DevDataDir
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 --dev --data '$DevDataDir'"
) | Out-Null
Start-Sleep -Seconds 2
Push-Location frontend
try {
if (-not (Test-Path node_modules)) { npm install }
npm run dev
} finally {
Pop-Location
}
} }
'build' { 'build' {
Build-WebSrc Build-WebSrc
Build-Frontend
Build-Go -OutFile $AppName Build-Go -OutFile $AppName
} }
'build-windows' { 'build-windows' {
Build-WebSrc Build-WebSrc
Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
} }
'build-linux' { 'build-linux' {
Write-Host '[build-linux] will build web_src + SPA then go:embed' -ForegroundColor Yellow
Build-WebSrc Build-WebSrc
Build-Frontend
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64' Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
} }
'build-darwin' { 'build-darwin' {
Build-WebSrc Build-WebSrc
Build-Frontend
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64' Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
} }
'build-all' { 'build-all' {
Build-WebSrc Build-WebSrc
Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64' Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64' Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
@@ -166,12 +131,10 @@ switch ($Target) {
} }
'compose-up' { 'compose-up' {
docker compose up -d --build docker compose up -d --build
if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' } if ($LASTEXITCODE -ne 0) { throw 'compose up failed' }
Write-Host '[ok] compose started' -ForegroundColor Green
} }
'compose-down' { 'compose-down' {
docker compose down docker compose down
if ($LASTEXITCODE -ne 0) { throw 'docker compose down failed' } if ($LASTEXITCODE -ne 0) { throw 'compose down failed' }
Write-Host '[ok] compose stopped' -ForegroundColor Green
} }
} }

View File

@@ -1,11 +1,11 @@
package main package main
import ( import (
"fmt" "fmt"
"log" "log"
"os" "os"
"github.com/kardianos/service" kardsvc "github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
) )
@@ -25,7 +25,7 @@ func main() {
} }
prg := &program{cfg: cfg} prg := &program{cfg: cfg}
svc, err := service.New(prg, svcCfg) svc, err := kardsvc.New(prg, svcCfg)
if err != nil { if err != nil {
log.Fatalf("创建系统服务失败: %v", err) log.Fatalf("创建系统服务失败: %v", err)
} }

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"context" "context"
@@ -9,11 +9,11 @@ import (
"os" "os"
"time" "time"
"github.com/kardianos/service" kardsvc "github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/router" "git.iioio.com/freefire/jiang13-forum/routers"
) )
const ( const (
@@ -28,7 +28,7 @@ type program struct {
server *http.Server server *http.Server
} }
func (p *program) Start(s service.Service) error { func (p *program) Start(s kardsvc.Service) error {
if err := p.setup(); err != nil { if err := p.setup(); err != nil {
return err return err
} }
@@ -40,7 +40,7 @@ func (p *program) Start(s service.Service) error {
return nil return nil
} }
func (p *program) Stop(s service.Service) error { func (p *program) Stop(s kardsvc.Service) error {
log.Println("收到关机信号,正在优雅关闭...") log.Println("收到关机信号,正在优雅关闭...")
if p.server == nil { if p.server == nil {
return nil return nil
@@ -63,7 +63,7 @@ func (p *program) setup() error {
return fmt.Errorf("打开日志文件失败: %w", err) return fmt.Errorf("打开日志文件失败: %w", err)
} }
// 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写 // 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写
if service.Interactive() { if kardsvc.Interactive() {
log.SetOutput(io.MultiWriter(os.Stdout, logFile)) log.SetOutput(io.MultiWriter(os.Stdout, logFile))
} else { } else {
log.SetOutput(logFile) log.SetOutput(logFile)
@@ -75,11 +75,11 @@ func (p *program) setup() error {
log.Printf(" 版本: %s", version) log.Printf(" 版本: %s", version)
log.Println("========================================") log.Println("========================================")
if err := model.InitDB(cfg.DBPath()); err != nil { if err := models.InitDB(cfg.DBPath()); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err) return fmt.Errorf("数据库初始化失败: %w", err)
} }
engine, err := router.Setup(cfg) engine, err := routers.Setup(cfg)
if err != nil { if err != nil {
return fmt.Errorf("路由初始化失败: %w", err) return fmt.Errorf("路由初始化失败: %w", err)
} }
@@ -98,9 +98,9 @@ func (p *program) setup() error {
return nil return nil
} }
func buildServiceConfig(cfg *config.Config) (*service.Config, error) { func buildServiceConfig(cfg *config.Config) (*kardsvc.Config, error) {
// 服务只绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务 // 服务只绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务
return &service.Config{ return &kardsvc.Config{
Name: svcName, Name: svcName,
DisplayName: svcDisplayName, DisplayName: svcDisplayName,
Description: svcDescription, Description: svcDescription,
@@ -109,7 +109,7 @@ func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
"--work-path", cfg.WorkPath, "--work-path", cfg.WorkPath,
"--config", cfg.ConfigFile, "--config", cfg.ConfigFile,
}, },
Option: service.KeyValue{ Option: kardsvc.KeyValue{
// systemd异常退出后自动拉起 // systemd异常退出后自动拉起
"Restart": "always", "Restart": "always",
// Windows崩溃后重启 // Windows崩溃后重启
@@ -118,16 +118,16 @@ func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
}, nil }, nil
} }
func runServiceControl(s service.Service, action string) error { func runServiceControl(s kardsvc.Service, action string) error {
if action == "status" { if action == "status" {
st, err := s.Status() st, err := s.Status()
if err != nil { if err != nil {
return err return err
} }
switch st { switch st {
case service.StatusRunning: case kardsvc.StatusRunning:
fmt.Println("服务状态: 运行中 (running)") fmt.Println("服务状态: 运行中 (running)")
case service.StatusStopped: case kardsvc.StatusStopped:
fmt.Println("服务状态: 已停止 (stopped)") fmt.Println("服务状态: 已停止 (stopped)")
default: default:
fmt.Println("服务状态: 未知 (unknown)") fmt.Println("服务状态: 未知 (unknown)")
@@ -135,7 +135,7 @@ func runServiceControl(s service.Service, action string) error {
return nil return nil
} }
if err := service.Control(s, action); err != nil { if err := kardsvc.Control(s, action); err != nil {
return err return err
} }

View File

@@ -3,7 +3,7 @@
> **读者**:实现与验收 > **读者**:实现与验收
> **前置**[01-product.md](01-product.md) > **前置**[01-product.md](01-product.md)
> **交叉**[05-business-rules.md](05-business-rules.md)、[06-pages-ux.md](06-pages-ux.md) > **交叉**[05-business-rules.md](05-business-rules.md)、[06-pages-ux.md](06-pages-ux.md)
> **源码对照**[`frontend/src/App.tsx`](../../frontend/src/App.tsx)、[`router/router.go`](../../router/router.go)、[`README.md`](../../README.md) > **源码对照**[`frontend/src/App.tsx`]((仅 mainfrontend/src/App.tsx)、[`router/router.go`](../../routers/setup.go)、[`README.md`](../../README.md)
用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。 用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。

View File

@@ -3,7 +3,7 @@
> **读者**:实现数据库与领域层的 AI > **读者**:实现数据库与领域层的 AI
> **前置**[01-product.md](01-product.md) > **前置**[01-product.md](01-product.md)
> **后续**[04-api.md](04-api.md)、[05-business-rules.md](05-business-rules.md) > **后续**[04-api.md](04-api.md)、[05-business-rules.md](05-business-rules.md)
> **源码**[`model/models.go`](../../model/models.go)、[`model/oauth.go`](../../model/oauth.go)、[`model/gitea.go`](../../model/gitea.go)、[`model/level.go`](../../model/level.go)、[`model/db.go`](../../model/db.go)、[`model/user_view.go`](../../model/user_view.go)、[`service/settings.go`](../../service/settings.go) > **源码**[`model/models.go`](../../models/models.go)、[`model/oauth.go`](../../models/oauth.go)、[`model/gitea.go`](../../models/gitea.go)、[`model/level.go`](../../models/level.go)、[`model/db.go`](../../models/db.go)、[`model/user_view.go`](../../models/user_view.go)、[`service/settings.go`](../../services/settings.go)
当前无独立 SQL migration表由 GORM `AutoMigrate` 创建。新站可用正式 migration但**字段语义应对齐**。 当前无独立 SQL migration表由 GORM `AutoMigrate` 创建。新站可用正式 migration但**字段语义应对齐**。
@@ -72,7 +72,7 @@ erDiagram
**非落库展示字段**`level`(由 Exp 推导)、`badges`(附加)。 **非落库展示字段**`level`(由 Exp 推导)、`badges`(附加)。
视图结构:`UserPublic` / `UserSelf` / `UserAdmin`(见 [`model/user_view.go`](../../model/user_view.go))。 视图结构:`UserPublic` / `UserSelf` / `UserAdmin`(见 [`model/user_view.go`](../../models/user_view.go))。
### 2.2 boards ### 2.2 boards
@@ -278,7 +278,7 @@ Metric`tenure_days` | `likes_received` | `creator_income`
## 4. 等级Exp → Level ## 4. 等级Exp → Level
源:[`model/level.go`](../../model/level.go) 源:[`model/level.go`](../../models/level.go)
| Level | 最低 Exp | | Level | 最低 Exp |
|-------|----------| |-------|----------|
@@ -299,7 +299,7 @@ Metric`tenure_days` | `likes_received` | `creator_income`
## 5. 内置自动徽章seed ## 5. 内置自动徽章seed
源:[`model/db.go`](../../model/db.go) `seedDefaultBadges` 源:[`model/db.go`](../../models/db.go) `seedDefaultBadges`
| code | 名称 | metric | threshold | | code | 名称 | metric | threshold |
|------|------|--------|-----------| |------|------|--------|-----------|
@@ -317,7 +317,7 @@ Metric`tenure_days` | `likes_received` | `creator_income`
## 6. forum_settings 键与默认值 ## 6. forum_settings 键与默认值
源:[`service/settings.go`](../../service/settings.go)、[`service/permalink.go`](../../service/permalink.go) 源:[`service/settings.go`](../../services/settings.go)、[`service/permalink.go`](../../services/permalink.go)
### 6.1 论坛限制 ### 6.1 论坛限制
@@ -429,7 +429,7 @@ Metric`tenure_days` | `likes_received` | `creator_income`
## 7. 升级兼容补丁(现网 InitDB ## 7. 升级兼容补丁(现网 InitDB
[`model/db.go`](../../model/db.go) 在 AutoMigrate 后: [`model/db.go`](../../models/db.go) 在 AutoMigrate 后:
-`status` 的帖/评 → `published` -`status` 的帖/评 → `published`
-`post_type``normal` -`post_type``normal`
@@ -449,4 +449,4 @@ Metric`tenure_days` | `likes_received` | `creator_income`
<points-only data-cost="10">...</points-only> <points-only data-cost="10">...</points-only>
``` ```
积分解锁 `block_key` = `sha256(innerHTML)[:16]`hex见 [`service/unlock.go`](../../service/unlock.go)。 积分解锁 `block_key` = `sha256(innerHTML)[:16]`hex见 [`service/unlock.go`](../../services/unlock.go)。

View File

@@ -2,7 +2,7 @@
> **读者**:实现后端 / BFF / 前端数据层的 AI > **读者**:实现后端 / BFF / 前端数据层的 AI
> **前置**[03-data-model.md](03-data-model.md) > **前置**[03-data-model.md](03-data-model.md)
> **源码**[`router/router.go`](../../router/router.go)、[`frontend/src/api/client.ts`](../../frontend/src/api/client.ts)、[`frontend/src/api/types.ts`](../../frontend/src/api/types.ts)、[`middleware/auth.go`](../../middleware/auth.go) > **源码**[`router/router.go`](../../routers/setup.go)、[`frontend/src/api/client.ts`]((仅 mainfrontend/src/api/client.ts)、[`frontend/src/api/types.ts`]((仅 mainfrontend/src/api/types.ts)、[`middleware/auth.go`](../../modules/auth/auth.go)
不要求 OpenAPI YAML以下表格 + JSON 形状即为合约。新站可加 `/v1` 前缀,但**字段名建议保持**以便对照迁移。 不要求 OpenAPI YAML以下表格 + JSON 形状即为合约。新站可加 `/v1` 前缀,但**字段名建议保持**以便对照迁移。
@@ -54,7 +54,7 @@
| GET/POST | `/oauth/userinfo` | Bearer | 用户信息 | | GET/POST | `/oauth/userinfo` | Bearer | 用户信息 |
| GET/POST | `/oauth/logout` | 视实现 | 登出 | | GET/POST | `/oauth/logout` | 视实现 | 登出 |
细节以 [`service/oidc.go`](../../service/oidc.go) / [`handler/oidc.go`](../../handler/oidc.go) 为准。 细节以 [`service/oidc.go`](../../services/oidc.go) / [`handler/oidc.go`](../../routers/api/oidc.go) 为准。
--- ---
@@ -356,7 +356,7 @@
## 7. 核心类型速查(与前端对齐) ## 7. 核心类型速查(与前端对齐)
详见 [`frontend/src/api/types.ts`](../../frontend/src/api/types.ts)。实现时至少对齐: 详见 [`frontend/src/api/types.ts`]((仅 mainfrontend/src/api/types.ts)。实现时至少对齐:
- `User` / `UserPublic` / `UserActivityStats` - `User` / `UserPublic` / `UserActivityStats`
- `Board` / `PostItem` / `PostDetailResponse` / `Comment` - `Board` / `PostItem` / `PostDetailResponse` / `Comment`
@@ -373,4 +373,4 @@
中间件对未登录 / 过期 / 禁言返回 JSON error并可能清 cookie。前端统一 `throw new Error(data.error)`。新站应保持可区分的错误文案或错误码,避免前端无法提示。 中间件对未登录 / 过期 / 禁言返回 JSON error并可能清 cookie。前端统一 `throw new Error(data.error)`。新站应保持可区分的错误文案或错误码,避免前端无法提示。
源:[`middleware/auth.go`](../../middleware/auth.go)。 源:[`middleware/auth.go`](../../modules/auth/auth.go)。

View File

@@ -2,13 +2,13 @@
> **读者**:实现领域逻辑的 AI最易「看起来像但算错」 > **读者**:实现领域逻辑的 AI最易「看起来像但算错」
> **前置**[03-data-model.md](03-data-model.md)、[04-api.md](04-api.md) > **前置**[03-data-model.md](03-data-model.md)、[04-api.md](04-api.md)
> **源码**[`service/`](../../service/)、[`model/models.go`](../../model/models.go) > **源码**[`service/`](../../services/)、[`model/models.go`](../../models/models.go)
--- ---
## 1. 注册与引导 ## 1. 注册与引导
源:[`handler/handlers.go`](../../handler/handlers.go) `APIRegisterConfig`、[`service/auth.go`](../../service/auth.go) 源:[`handler/handlers.go`](../../routers/api/handlers.go) `APIRegisterConfig`、[`service/auth.go`](../../services/auth.go)
| 规则 | 细节 | | 规则 | 细节 |
|------|------| |------|------|
@@ -41,7 +41,7 @@ stateDiagram-v2
| 待审提醒 | 通知管理员kind=`moderation` | | 待审提醒 | 通知管理员kind=`moderation` |
| 游客评论 | 通常直接或按实现进入审核;勿假设与登录用户完全相同 | | 游客评论 | 通常直接或按实现进入审核;勿假设与登录用户完全相同 |
源:[`service/post.go`](../../service/post.go) `CanViewPost`、[`service/comment.go`](../../service/comment.go)。 源:[`service/post.go`](../../services/post.go) `CanViewPost`、[`service/comment.go`](../../services/comment.go)。
--- ---
@@ -68,7 +68,7 @@ stateDiagram-v2
| 评论成功 | +2 | | 评论成功 | +2 |
| 帖子被点赞 | +1作者 | | 帖子被点赞 | +1作者 |
源:[`service/post.go`](../../service/post.go)、[`service/comment.go`](../../service/comment.go)、[`service/badge.go`](../../service/badge.go) `AddExp` 源:[`service/post.go`](../../services/post.go)、[`service/comment.go`](../../services/comment.go)、[`service/badge.go`](../../services/badge.go) `AddExp`
等级门槛见 [03-data-model.md](03-data-model.md) §4。管理员设 level 时应同步 Exp 到门槛值。 等级门槛见 [03-data-model.md](03-data-model.md) §4。管理员设 level 时应同步 Exp 到门槛值。
@@ -76,7 +76,7 @@ stateDiagram-v2
## 5. 内容门控红action ## 5. 内容门控红action
源:[`service/content.go`](../../service/content.go)、[`handler/api.go`](../../handler/api.go) `APIPostDetail`、[`service/unlock.go`](../../service/unlock.go) 源:[`service/content.go`](../../services/content.go)、[`handler/api.go`](../../routers/api/api.go) `APIPostDetail`、[`service/unlock.go`](../../services/unlock.go)
### 5.1 出口顺序(详情) ### 5.1 出口顺序(详情)
@@ -148,7 +148,7 @@ stateDiagram-v2
## 7. 签到与每日抽奖 ## 7. 签到与每日抽奖
源:[`service/points.go`](../../service/points.go) 源:[`service/points.go`](../../services/points.go)
### 签到 ### 签到
@@ -225,4 +225,4 @@ stateDiagram-v2
| `board_pinned` | **不**抬升 | 抬升 | | `board_pinned` | **不**抬升 | 抬升 |
| `featured` | 标记展示,不一定改变排序 | 同左 | | `featured` | 标记展示,不一定改变排序 | 同左 |
具体 SQL/排序实现见 [`service/post.go`](../../service/post.go) ListItems。 具体 SQL/排序实现见 [`service/post.go`](../../services/post.go) ListItems。

View File

@@ -2,7 +2,7 @@
> **读者**:实现前台 / 后台 UI 的 AI > **读者**:实现前台 / 后台 UI 的 AI
> **前置**[02-features.md](02-features.md) > **前置**[02-features.md](02-features.md)
> **源码**[`frontend/src/App.tsx`](../../frontend/src/App.tsx)、[`frontend/src/pages/`](../../frontend/src/pages/)、[`frontend/src/components/`](../../frontend/src/components/)、[`frontend/src/layouts/`](../../frontend/src/layouts/) > **源码**[`frontend/src/App.tsx`]((仅 mainfrontend/src/App.tsx)、[`frontend/src/pages/`]((仅 mainfrontend/src/pages/)、[`frontend/src/components/`]((仅 mainfrontend/src/components/)、[`frontend/src/layouts/`]((仅 mainfrontend/src/layouts/)
视觉可重设;**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。 视觉可重设;**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。
@@ -119,7 +119,7 @@
### 3.2 编辑器能力(应对齐) ### 3.2 编辑器能力(应对齐)
源:[`ArticleEditor.tsx`](../../frontend/src/components/ArticleEditor.tsx) 与 `editor/` 扩展 源:[`ArticleEditor.tsx`]((仅 mainfrontend/src/components/ArticleEditor.tsx) 与 `editor/` 扩展
- 标题 h2h6无 h1避免与帖标题冲突 - 标题 h2h6无 h1避免与帖标题冲突
- 粗体/斜体/删除线等基础标记 - 粗体/斜体/删除线等基础标记
@@ -129,7 +129,7 @@
- 图片上传 + 图片组布局 + 浮动/清除浮动 - 图片上传 + 图片组布局 + 浮动/清除浮动
- 表情 / 贴纸选择器多套bilibili/douyin/tieba/weibo 等静态资源) - 表情 / 贴纸选择器多套bilibili/douyin/tieba/weibo 等静态资源)
- **登录可见** / **回复可见** / **积分可见**(价格 19999节点 - **登录可见** / **回复可见** / **积分可见**(价格 19999节点
- 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`](../../frontend/src/utils/markdownContent.ts) - 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`]((仅 mainfrontend/src/utils/markdownContent.ts)
- Tab 缩进 - Tab 缩进
未保存离开:`UnsavedChangesDialog` 未保存离开:`UnsavedChangesDialog`

View File

@@ -2,7 +2,7 @@
> **读者**:部署与运维、以及实现配置层的 AI > **读者**:部署与运维、以及实现配置层的 AI
> **前置**[README.md](README.md) > **前置**[README.md](README.md)
> **源码**[`app.ini.example`](../../app.ini.example)、[`config/`](../../config/)、[`README.md`](../../README.md)、[`handler/seo.go`](../../handler/seo.go)、[`handler/seo_bot.go`](../../handler/seo_bot.go)、[`embed_static/`](../../embed_static/) > **源码**[`app.ini.example`](../../app.ini.example)、[`config/`](../../config/)、[`README.md`](../../README.md)、[`handler/seo.go`](../../routers/api/seo.go)、[`handler/seo_bot.go`](../../routers/api/seo_bot.go)、[`embed_static/`]((仅 main 分支)embed_static/)
运维形态可改;下列描述**现网**行为,便于迁移数据与对齐环境变量语义。 运维形态可改;下列描述**现网**行为,便于迁移数据与对齐环境变量语义。
@@ -91,8 +91,8 @@ data/
| 机制 | 说明 | | 机制 | 说明 |
|------|------| |------|------|
| SPA 壳注入 | [`embed_static`](../../embed_static/) 注入 title / branding JSON**无帖文 DOM** | | SPA 壳注入 | `embed_static`(仅 `main` 分支) 注入 title / branding JSON**无帖文 DOM** |
| 爬虫 HTML | User-Agent 命中时 [`seo_bot.go`](../../handler/seo_bot.go) 返回简易 HTML | | 爬虫 HTML | User-Agent 命中时 [`seo_bot.go`](../../routers/api/seo_bot.go) 返回简易 HTML |
| robots.txt / sitemap.xml | 动态生成 | | robots.txt / sitemap.xml | 动态生成 |
重构验收:用普通浏览器「查看网页源代码」应能看到帖文正文,而不仅是空 div + script。 重构验收:用普通浏览器「查看网页源代码」应能看到帖文正文,而不仅是空 div + script。
@@ -110,7 +110,7 @@ data/
- `/board/2.html` - `/board/2.html`
- `/page/about.html` - `/page/about.html`
路由应同时接受无后缀与有后缀形式。解析逻辑见 [`service/permalink.go`](../../service/permalink.go)。 路由应同时接受无后缀与有后缀形式。解析逻辑见 [`service/permalink.go`](../../services/permalink.go)。
--- ---

View File

@@ -35,19 +35,29 @@ git worktree add ../jiang13-spa main
--- ---
## 目录职责(演进中 ## 目录职责(本分支已落地
```text ```text
routers/web/ # 返回 HTML 的页面路由 cmd/jiang13/ # 入口
templates/ # Go 模板源文件(嵌入) config/ # 配置
web_src/ # CSS/JS 源码 models/ # GORM 模型(原 model/
public/assets/ # web_src 构建产物嵌入URL 前缀 `/ssr-assets/` services/ # 业务逻辑(原 service/
modules/ # 横切(模板渲染等) routers/
docs/rebuild-spec/ # 产品规格 setup.go # 路由总装(原 router/
.cursor/rules/ # AI 开发规则 web/ # HTML SSR
api/ # JSON API原 handler/
modules/
auth/ # JWT / 限流等(原 middleware/
webrender/ # 模板渲染
seo/ # PageMeta 等
templates/ # Go 模板embed
web_src/ # CSS/JS 源码
public/assets/ # 构建产物URL 前缀 `/ssr-assets/`
docs/rebuild-spec/ # 产品规格
.cursor/rules/ # AI 开发规则
``` ```
现有 `model/``service/``handler/`JSON API可先复用公开页出口改为模板 **已删除(勿恢复):** `frontend/``embed_static/`。SPA 对照仅看 `main`
--- ---

View File

@@ -61,9 +61,9 @@ flowchart LR
| 痛点 | 现状 | 对用户的影响 | | 痛点 | 现状 | 对用户的影响 |
|------|------|----------------| |------|------|----------------|
| 非真 SSR | 生产入口 [`embed_static`](../../embed_static/) 只注入 title / branding / Open Graph**不渲染帖文 DOM** | 刷新先出壳再灌数据,体验不如 SSR | | 非真 SSR | 生产入口`main``embed_static`只注入 title / branding / Open Graph**不渲染帖文 DOM** | 刷新先出壳再灌数据,体验不如 SSR |
| 爬虫双轨 | [`handler/seo_bot.go`](../../handler/seo_bot.go) 对爬虫返回独立 HTML | 用户与爬虫看到的不是同一套渲染路径 | | 爬虫双轨 | [`routers/api/seo_bot.go`](../../routers/api/seo_bot.go) 对爬虫返回独立 HTML | 用户与爬虫看到的不是同一套渲染路径 |
| 无正式 migration | Schema 靠 GORM `AutoMigrate`[`model/db.go`](../../model/db.go) | 升级靠「加字段」,难做破坏性迁移与审计 | | 无正式 migration | Schema 靠 GORM `AutoMigrate`[`models/db.go`](../../models/db.go) | 升级靠「加字段」,难做破坏性迁移与审计 |
| Cookie JWT | 无 session 表,密钥在 `data/.jwt_secret` | 可保留语义,实现可换成更好的会话方案 | | Cookie JWT | 无 session 表,密钥在 `data/.jwt_secret` | 可保留语义,实现可换成更好的会话方案 |
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染SSRHTMLSEO meta 与正文同源。技术选型自定Next.js / Nuxt / Remix / 其它均可)。 **新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染SSRHTMLSEO meta 与正文同源。技术选型自定Next.js / Nuxt / Remix / 其它均可)。
@@ -113,13 +113,13 @@ flowchart LR
| 主题 | 路径 | | 主题 | 路径 |
|------|------| |------|------|
| 路由总 | [`router/router.go`](../../router/router.go) | | 路由总 | [`routers/setup.go`](../../routers/setup.go) |
| GORM 模型 | [`model/models.go`](../../model/models.go) | | GORM 模型 | [`models/models.go`](../../models/models.go) |
| AutoMigrate | [`model/db.go`](../../model/db.go) | | AutoMigrate | [`models/db.go`](../../models/db.go) |
| 论坛设置键 | [`service/settings.go`](../../service/settings.go) | | 论坛设置键 | [`services/settings.go`](../../services/settings.go) |
| 前端 API 客户端 | [`frontend/src/api/client.ts`](../../frontend/src/api/client.ts) | | SSR 页面路由 | [`routers/web/`](../../routers/web/) |
| 前端类型 | [`frontend/src/api/types.ts`](../../frontend/src/api/types.ts) | | JSON API | [`routers/api/`](../../routers/api/) |
| 页面路由 | [`frontend/src/App.tsx`](../../frontend/src/App.tsx) | | 前端 API / 页面(对照) | 仅 `main``frontend/src/api/``frontend/src/App.tsx` |
| 产品介绍 | [`docs/introduction.md`](../introduction.md)、[`README.md`](../../README.md) | | 产品介绍 | [`docs/introduction.md`](../introduction.md)、[`README.md`](../../README.md) |
--- ---

View File

@@ -1,4 +1,4 @@
package model package models
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package model package models
import "time" import "time"

View File

@@ -1,4 +1,4 @@
package model package models
// LevelThresholds 各等级所需最低 Exp下标 0 对应 Lv1 // LevelThresholds 各等级所需最低 Exp下标 0 对应 Lv1
var LevelThresholds = []int{0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000} var LevelThresholds = []int{0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000}

View File

@@ -1,4 +1,4 @@
package model package models
import ( import (
"time" "time"

View File

@@ -1,4 +1,4 @@
package model package models
import "time" import "time"

View File

@@ -1,4 +1,4 @@
package model package models
import "time" import "time"

View File

@@ -1,4 +1,4 @@
package middleware package auth
import ( import (
"fmt" "fmt"
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
const ( const (
@@ -18,10 +18,10 @@ const (
) )
type AuthMiddleware struct { type AuthMiddleware struct {
auth *service.AuthService auth *services.AuthService
} }
func NewAuthMiddleware(auth *service.AuthService) *AuthMiddleware { func NewAuthMiddleware(auth *services.AuthService) *AuthMiddleware {
return &AuthMiddleware{auth: auth} return &AuthMiddleware{auth: auth}
} }
@@ -32,8 +32,8 @@ func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
token := extractToken(c) token := extractToken(c)
if token != "" { if token != "" {
if claims, err := m.auth.ParseToken(token); err == nil { if claims, err := m.auth.ParseToken(token); err == nil {
var user model.User var user models.User
if err := model.DB.Select("id", "username", "role").First(&user, claims.UserID).Error; err != nil { if err := models.DB.Select("id", "username", "role").First(&user, claims.UserID).Error; err != nil {
c.SetCookie(CookieName, "", -1, "/", "", false, true) c.SetCookie(CookieName, "", -1, "/", "", false, true)
} else { } else {
c.Set(CtxUserID, user.ID) c.Set(CtxUserID, user.ID)
@@ -63,8 +63,8 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return return
} }
// 检查禁言 // 检查禁言
var user model.User var user models.User
if err := model.DB.First(&user, claims.UserID).Error; err != nil || user.Banned { if err := models.DB.First(&user, claims.UserID).Error; err != nil || user.Banned {
respondBanned(c) respondBanned(c)
c.Abort() c.Abort()
return return
@@ -81,7 +81,7 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get(CtxRole) role, exists := c.Get(CtxRole)
if !exists || role != model.RoleAdmin { if !exists || role != models.RoleAdmin {
if isAPI(c) { if isAPI(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"}) c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
} else { } else {
@@ -147,7 +147,7 @@ func respondBanned(c *gin.Context) {
} }
// RateLimitMiddleware 限流中间件 // RateLimitMiddleware 限流中间件
func RateLimitMiddleware(limiter *service.RateLimiter, action string) gin.HandlerFunc { func RateLimitMiddleware(limiter *services.RateLimiter, action string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
key := c.ClientIP() key := c.ClientIP()
if uid, ok := c.Get(CtxUserID); ok { if uid, ok := c.Get(CtxUserID); ok {

16
modules/seo/pagemeta.go Normal file
View File

@@ -0,0 +1,16 @@
package seo
// PageMeta 页面级 SEO / 社交预览元数据SSR 与爬虫 HTML 共用)
type PageMeta 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
}

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"errors" "errors"
@@ -10,9 +10,9 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// APIMe 当前登录用户 // APIMe 当前登录用户
@@ -25,7 +25,7 @@ func (h *Handlers) APIMe(c *gin.Context) {
user, err := h.User.GetByID(uid) user, err := h.User.GetByID(uid)
if err != nil { if err != nil {
// 账号已删或不存在:清掉失效 cookie与未登录态一致 // 账号已删或不存在:清掉失效 cookie与未登录态一致
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true) c.SetCookie(auth.CookieName, "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"user": nil}) c.JSON(http.StatusOK, gin.H{"user": nil})
return return
} }
@@ -35,7 +35,7 @@ func (h *Handlers) APIMe(c *gin.Context) {
view := user.ToSelf() view := user.ToSelf()
if h.Badge != nil { if h.Badge != nil {
if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil { if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
view.Badges = service.BadgeViews(badges, 0) view.Badges = services.BadgeViews(badges, 0)
} }
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -51,14 +51,14 @@ func (h *Handlers) APIBoards(c *gin.Context) {
return return
} }
if boards == nil { if boards == nil {
boards = []service.BoardWithStats{} boards = []services.BoardWithStats{}
} }
c.JSON(http.StatusOK, gin.H{"boards": boards}) c.JSON(http.StatusOK, gin.H{"boards": boards})
} }
// APIHealth 健康检查(容器探活 / 负载均衡) // APIHealth 健康检查(容器探活 / 负载均衡)
func (h *Handlers) APIHealth(c *gin.Context) { func (h *Handlers) APIHealth(c *gin.Context) {
if err := model.PingDB(); err != nil { if err := models.PingDB(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unavailable", "status": "unavailable",
"error": err.Error(), "error": err.Error(),
@@ -71,10 +71,10 @@ func (h *Handlers) APIHealth(c *gin.Context) {
// APIStats 论坛概览统计 // APIStats 论坛概览统计
func (h *Handlers) APIStats(c *gin.Context) { func (h *Handlers) APIStats(c *gin.Context) {
var userCount, postCount, boardCount, commentCount int64 var userCount, postCount, boardCount, commentCount int64
model.DB.Model(&model.User{}).Count(&userCount) models.DB.Model(&models.User{}).Count(&userCount)
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount) models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount) models.DB.Model(&models.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount) models.DB.Model(&models.Comment{}).Where("status = ?", models.ContentStatusPublished).Count(&commentCount)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users": userCount, "users": userCount,
"posts": postCount, "posts": postCount,
@@ -144,19 +144,19 @@ func (h *Handlers) APIAdminDeleteBoard(c *gin.Context) {
// APIAdminDashboard 管理后台概览 // APIAdminDashboard 管理后台概览
func (h *Handlers) APIAdminDashboard(c *gin.Context) { func (h *Handlers) APIAdminDashboard(c *gin.Context) {
var userCount, postCount, boardCount, commentCount int64 var userCount, postCount, boardCount, commentCount int64
model.DB.Model(&model.User{}).Count(&userCount) models.DB.Model(&models.User{}).Count(&userCount)
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount) models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount) models.DB.Model(&models.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount) models.DB.Model(&models.Comment{}).Where("status = ?", models.ContentStatusPublished).Count(&commentCount)
pendingPosts, _ := h.Post.PendingPostCount() pendingPosts, _ := h.Post.PendingPostCount()
pendingComments, _ := h.Comment.PendingCommentCount() pendingComments, _ := h.Comment.PendingCommentCount()
pendingReports, _ := h.Report.PendingCount() pendingReports, _ := h.Report.PendingCount()
pendingFriendLinks, _ := h.FriendLinkApply.PendingCount() pendingFriendLinks, _ := h.FriendLinkApply.PendingCount()
recentPosts, _, _ := h.Post.List(service.PostListQuery{ recentPosts, _, _ := h.Post.List(services.PostListQuery{
Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all", Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all",
}) })
if recentPosts == nil { if recentPosts == nil {
recentPosts = []model.Post{} recentPosts = []models.Post{}
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount, "users": userCount, "posts": postCount, "boards": boardCount,
@@ -175,7 +175,7 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword")) keyword := strings.TrimSpace(c.Query("keyword"))
status := strings.TrimSpace(c.DefaultQuery("status", "all")) status := strings.TrimSpace(c.DefaultQuery("status", "all"))
posts, total, err := h.Post.ListItems(service.PostListQuery{ posts, total, err := h.Post.ListItems(services.PostListQuery{
Page: page, Size: size, Keyword: keyword, Page: page, Size: size, Keyword: keyword,
ViewerIsAdmin: true, Status: status, ViewerIsAdmin: true, Status: status,
}) })
@@ -184,7 +184,7 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
return return
} }
if posts == nil { if posts == nil {
posts = []service.PostListItem{} posts = []services.PostListItem{}
} }
pending, _ := h.Post.PendingPostCount() pending, _ := h.Post.PendingPostCount()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -321,7 +321,7 @@ func (h *Handlers) APIAdminTrashPosts(c *gin.Context) {
return return
} }
if posts == nil { if posts == nil {
posts = []service.TrashPostItem{} posts = []services.TrashPostItem{}
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"posts": posts, "total": total, "page": page, "posts": posts, "total": total, "page": page,
@@ -360,7 +360,7 @@ func (h *Handlers) APIAdminComments(c *gin.Context) {
return return
} }
if comments == nil { if comments == nil {
comments = []model.Comment{} comments = []models.Comment{}
} }
pending, _ := h.Comment.PendingCommentCount() pending, _ := h.Comment.PendingCommentCount()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -374,18 +374,18 @@ func (h *Handlers) APIAdminComments(c *gin.Context) {
// APIAdminApproveComment 通过评论审核 // APIAdminApproveComment 通过评论审核
func (h *Handlers) APIAdminApproveComment(c *gin.Context) { func (h *Handlers) APIAdminApproveComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Comment.SetStatus(uint(id), model.ContentStatusPublished); err != nil { if err := h.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if h.Notify != nil { if h.Notify != nil {
if comment, err := h.Comment.GetByID(uint(id)); err == nil { if comment, err := h.Comment.GetByID(uint(id)); err == nil {
comment.Status = model.ContentStatusPublished comment.Status = models.ContentStatusPublished
h.Notify.AsyncNotifyCommentPublished(comment) h.Notify.AsyncNotifyCommentPublished(comment)
h.Notify.AsyncNotifyCommentMentions(comment) h.Notify.AsyncNotifyCommentMentions(comment)
} }
} }
c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": model.ContentStatusPublished}) c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": models.ContentStatusPublished})
} }
// APIAdminRejectComment 拒绝评论并私信通知 // APIAdminRejectComment 拒绝评论并私信通知
@@ -404,7 +404,7 @@ func (h *Handlers) APIAdminRejectComment(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if err := h.Comment.SetStatus(uint(id), model.ContentStatusRejected); err != nil { if err := h.Comment.SetStatus(uint(id), models.ContentStatusRejected); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -417,13 +417,13 @@ func (h *Handlers) APIAdminRejectComment(c *gin.Context) {
_, _ = h.Message.SendSystem( _, _ = h.Message.SendSystem(
comment.UserID, comment.UserID,
"评论未通过审核", "评论未通过审核",
service.FormatCommentRejectContent(title, comment.PostID, comment.Floor, reason), services.FormatCommentRejectContent(title, comment.PostID, comment.Floor, reason),
model.MessageKindReject, models.MessageKindReject,
&pid, &pid,
nil, nil,
) )
} }
c.JSON(http.StatusOK, gin.H{"message": "已拒绝该评论并通知作者", "status": model.ContentStatusRejected}) c.JSON(http.StatusOK, gin.H{"message": "已拒绝该评论并通知作者", "status": models.ContentStatusRejected})
} }
// APIAdminDeleteComment 管理员软删除评论(进入回收站) // APIAdminDeleteComment 管理员软删除评论(进入回收站)
@@ -447,7 +447,7 @@ func (h *Handlers) APIAdminTrashComments(c *gin.Context) {
return return
} }
if comments == nil { if comments == nil {
comments = []service.TrashCommentItem{} comments = []services.TrashCommentItem{}
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"comments": comments, "total": total, "page": page, "comments": comments, "total": total, "page": page,
@@ -492,7 +492,7 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) {
size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword")) keyword := strings.TrimSpace(c.Query("keyword"))
filter := strings.TrimSpace(c.DefaultQuery("filter", "all")) filter := strings.TrimSpace(c.DefaultQuery("filter", "all"))
users, total, err := h.User.ListUsers(service.UserListQuery{ users, total, err := h.User.ListUsers(services.UserListQuery{
Page: page, Size: size, Keyword: keyword, Filter: filter, Page: page, Size: size, Keyword: keyword, Filter: filter,
}) })
if err != nil { if err != nil {
@@ -500,10 +500,10 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) {
return return
} }
if users == nil { if users == nil {
users = []model.User{} users = []models.User{}
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users": model.UsersToAdmin(users), "total": total, "page": page, "users": models.UsersToAdmin(users), "total": total, "page": page,
"total_pages": calcTotalPages(total, size), "total_pages": calcTotalPages(total, size),
"keyword": keyword, "keyword": keyword,
"filter": filter, "filter": filter,
@@ -560,7 +560,7 @@ func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
// APIAdminSettings 系统设置信息 // APIAdminSettings 系统设置信息
func (h *Handlers) APIAdminSettings(c *gin.Context) { func (h *Handlers) APIAdminSettings(c *gin.Context) {
limits := h.Settings.Limits() limits := h.Settings.Limits()
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath()) filterContent, _ := services.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
clients, _ := h.Settings.ListOAuthClients() clients, _ := h.Settings.ListOAuthClients()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"filter_path": h.Cfg.FilterWordsPath(), "filter_path": h.Cfg.FilterWordsPath(),
@@ -575,7 +575,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
"storage": h.Settings.StorageConfigPublic(), "storage": h.Settings.StorageConfigPublic(),
"branding": h.Settings.SiteBranding(), "branding": h.Settings.SiteBranding(),
"filter_words": filterContent, "filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent), "filter_word_count": services.CountFilterWords(filterContent),
}) })
} }
@@ -588,7 +588,7 @@ func (h *Handlers) APISiteBranding(c *gin.Context) {
// APIAdminUpdateBranding 更新站点品牌文案 // APIAdminUpdateBranding 更新站点品牌文案
func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) { func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) {
var req service.SiteBranding var req services.SiteBranding
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
@@ -620,7 +620,7 @@ func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"}) c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"})
return return
} }
url, err := service.SaveUploadedImage(h.Store, file, service.UploadCategorySite, kind) url, err := services.SaveUploadedImage(h.Store, file, services.UploadCategorySite, kind)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -677,7 +677,7 @@ func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
// APIAdminUpdateForumSettings 更新论坛设置 // APIAdminUpdateForumSettings 更新论坛设置
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) { func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
var req service.ForumLimits var req services.ForumLimits
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
@@ -694,7 +694,7 @@ func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
// APIAdminUpdateMailSettings 更新邮件 SMTP 配置 // APIAdminUpdateMailSettings 更新邮件 SMTP 配置
func (h *Handlers) APIAdminUpdateMailSettings(c *gin.Context) { func (h *Handlers) APIAdminUpdateMailSettings(c *gin.Context) {
var req service.MailConfig var req services.MailConfig
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
@@ -711,7 +711,7 @@ func (h *Handlers) APIAdminUpdateMailSettings(c *gin.Context) {
// APIAdminUpdateOIDCSettings 更新 OIDC Provider 全局配置 // APIAdminUpdateOIDCSettings 更新 OIDC Provider 全局配置
func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) { func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) {
var req service.OIDCConfig var req services.OIDCConfig
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
@@ -749,7 +749,7 @@ func (h *Handlers) APIProjects(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
return return
} }
list = service.AttachGiteaOwners(list, h.Badge) list = services.AttachGiteaOwners(list, h.Badge)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"projects": list, "projects": list,
"total": total, "total": total,
@@ -760,7 +760,7 @@ func (h *Handlers) APIProjects(c *gin.Context) {
// APIAdminUpdateGiteaSettings 更新 Gitea 同步配置 // APIAdminUpdateGiteaSettings 更新 Gitea 同步配置
func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) { func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
var req service.GiteaSyncConfig var req services.GiteaSyncConfig
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
@@ -777,7 +777,7 @@ func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
// APIAdminUpdateStorageSettings 更新上传存储(本地 / S3 兼容),保存后立即热切换 // APIAdminUpdateStorageSettings 更新上传存储(本地 / S3 兼容),保存后立即热切换
func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) { func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) {
var req service.StorageConfig var req services.StorageConfig
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
@@ -799,7 +799,7 @@ func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) {
// APIAdminSyncGitea 立即同步 Gitea 公开仓库 // APIAdminSyncGitea 立即同步 Gitea 公开仓库
func (h *Handlers) APIAdminSyncGitea(c *gin.Context) { func (h *Handlers) APIAdminSyncGitea(c *gin.Context) {
if h.Gitea == nil { if h.Gitea == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrGiteaNotConfigured.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrGiteaNotConfigured.Error()})
return return
} }
n, err := h.Gitea.SyncRepos() n, err := h.Gitea.SyncRepos()
@@ -826,7 +826,7 @@ func (h *Handlers) APIAdminListOAuthClients(c *gin.Context) {
// APIAdminCreateOAuthClient 创建 OAuth 应用 // APIAdminCreateOAuthClient 创建 OAuth 应用
func (h *Handlers) APIAdminCreateOAuthClient(c *gin.Context) { func (h *Handlers) APIAdminCreateOAuthClient(c *gin.Context) {
var req service.OAuthClientInput var req services.OAuthClientInput
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
@@ -850,7 +850,7 @@ func (h *Handlers) APIAdminUpdateOAuthClient(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
return return
} }
var req service.OAuthClientInput var req services.OAuthClientInput
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
@@ -897,16 +897,16 @@ func (h *Handlers) APIAdminTestMail(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写收件邮箱"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请填写收件邮箱"})
return return
} }
if err := service.ValidateEmail(req.To); err != nil { if err := services.ValidateEmail(req.To); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if !h.Settings.MailReady() { if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrMailNotConfigured.Error()})
return return
} }
siteName := h.Settings.SiteBranding().Name siteName := h.Settings.SiteBranding().Name
err := h.Mail.Send(service.NormalizeEmail(req.To), "邮件配置测试", err := h.Mail.Send(services.NormalizeEmail(req.To), "邮件配置测试",
fmt.Sprintf("这是一封来自%s的测试邮件说明 SMTP 配置正常。", siteName)) fmt.Sprintf("这是一封来自%s的测试邮件说明 SMTP 配置正常。", siteName))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -917,14 +917,14 @@ func (h *Handlers) APIAdminTestMail(c *gin.Context) {
// APIAdminFilterWords 读取敏感词配置 // APIAdminFilterWords 读取敏感词配置
func (h *Handlers) APIAdminFilterWords(c *gin.Context) { func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath()) content, err := services.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取敏感词配置失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "读取敏感词配置失败"})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"content": content, "content": content,
"word_count": service.CountFilterWords(content), "word_count": services.CountFilterWords(content),
"path": h.Cfg.FilterWordsPath(), "path": h.Cfg.FilterWordsPath(),
}) })
} }
@@ -938,13 +938,13 @@ func (h *Handlers) APIAdminUpdateFilterWords(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
if err := service.WriteFilterWordsFile(h.Cfg.FilterWordsPath(), req.Content, h.Filter); err != nil { if err := services.WriteFilterWordsFile(h.Cfg.FilterWordsPath(), req.Content, h.Filter); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存敏感词配置失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存敏感词配置失败"})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "敏感词已保存并生效", "message": "敏感词已保存并生效",
"word_count": service.CountFilterWords(req.Content), "word_count": services.CountFilterWords(req.Content),
}) })
} }
@@ -959,7 +959,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
author := strings.TrimSpace(c.Query("author")) author := strings.TrimSpace(c.Query("author"))
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true") titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
q := service.PostListQuery{ q := services.PostListQuery{
BoardID: uint(boardID), BoardID: uint(boardID),
UserID: uint(userID), UserID: uint(userID),
Page: page, Page: page,
@@ -982,10 +982,10 @@ func (h *Handlers) APIPosts(c *gin.Context) {
return return
} }
if items == nil { if items == nil {
items = []service.PostListItem{} items = []services.PostListItem{}
} }
if h.Badge != nil { if h.Badge != nil {
users := make([]*model.User, 0, len(items)) users := make([]*models.User, 0, len(items))
for i := range items { for i := range items {
if items[i].User.ID > 0 { if items[i].User.ID > 0 {
users = append(users, &items[i].User) users = append(users, &items[i].User)
@@ -1012,34 +1012,34 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
} }
uid := h.currentUserID(c) uid := h.currentUserID(c)
isAdmin := h.isAdmin(c) isAdmin := h.isAdmin(c)
if !service.CanViewPost(post, uid, isAdmin) { if !services.CanViewPost(post, uid, isAdmin) {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return return
} }
if c.Query("skip_view") != "1" && post.Status == model.ContentStatusPublished { if c.Query("skip_view") != "1" && post.Status == models.ContentStatusPublished {
h.Post.RecordView(uint(id)) h.Post.RecordView(uint(id))
} }
// 出口再消毒:兼容库内历史脏 HTML如 <style>),避免旧帖污染整页 // 出口再消毒:兼容库内历史脏 HTML如 <style>),避免旧帖污染整页
post.Content = service.SanitizePostHTML(post.Content) post.Content = services.SanitizePostHTML(post.Content)
hasReplied := uid > 0 && h.Comment.HasUserReplied(uint(id), uid) hasReplied := uid > 0 && h.Comment.HasUserReplied(uint(id), uid)
if uid == 0 { if uid == 0 {
post.Content = service.RedactMembersOnlyHTML(post.Content) post.Content = services.RedactMembersOnlyHTML(post.Content)
post.Content = service.RedactReplyOnlyHTML(post.Content) post.Content = services.RedactReplyOnlyHTML(post.Content)
} else if !isAdmin && post.UserID != uid && !hasReplied { } else if !isAdmin && post.UserID != uid && !hasReplied {
// 作者与管理员始终可见;其他用户需已回复 // 作者与管理员始终可见;其他用户需已回复
post.Content = service.RedactReplyOnlyHTML(post.Content) post.Content = services.RedactReplyOnlyHTML(post.Content)
} }
// 积分解锁块:作者/站长全文;其他人按解锁记录 redact // 积分解锁块:作者/站长全文;其他人按解锁记录 redact
if isAdmin || post.UserID == uid { if isAdmin || post.UserID == uid {
post.Content = service.RevealAllPointsOnly(post.Content) post.Content = services.RevealAllPointsOnly(post.Content)
} else { } else {
unlocked, _ := service.ListUnlockedKeys(uid, uint(id)) unlocked, _ := services.ListUnlockedKeys(uid, uint(id))
post.Content = service.RedactPointsOnlyHTML(post.Content, unlocked) post.Content = services.RedactPointsOnlyHTML(post.Content, unlocked)
} }
comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c)) comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
if h.Badge != nil { if h.Badge != nil {
if post.User.ID > 0 { if post.User.ID > 0 {
h.Badge.AttachBadgeSummaries([]*model.User{&post.User}, 3) h.Badge.AttachBadgeSummaries([]*models.User{&post.User}, 3)
} }
h.Badge.AttachBadgeSummariesOnComments(comments, 2) h.Badge.AttachBadgeSummariesOnComments(comments, 2)
} }
@@ -1060,21 +1060,21 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
"is_edited": isEdited, "is_edited": isEdited,
"post_edit_window_hours": h.Settings.PostEditWindowHours(), "post_edit_window_hours": h.Settings.PostEditWindowHours(),
} }
if post.PostType == model.PostTypePoll { if post.PostType == models.PostTypePoll {
if poll, err := service.GetPollView(uint(id), uid); err == nil { if poll, err := services.GetPollView(uint(id), uid); err == nil {
resp["poll"] = poll resp["poll"] = poll
} }
} }
if post.PostType == model.PostTypeLottery { if post.PostType == models.PostTypeLottery {
if lottery, err := service.GetPostLotteryView(post); err == nil && lottery != nil { if lottery, err := services.GetPostLotteryView(post); err == nil && lottery != nil {
resp["lottery"] = lottery resp["lottery"] = lottery
} }
} }
if post.PostType == model.PostTypeBounty && post.BountyStatus == model.BountyStatusOpen && post.BountyPoints > 0 { if post.PostType == models.PostTypeBounty && post.BountyStatus == models.BountyStatusOpen && post.BountyPoints > 0 {
canRefund, blockReason := service.CanRefundBounty(post, isAdmin) canRefund, blockReason := services.CanRefundBounty(post, isAdmin)
resp["bounty_can_refund"] = canRefund resp["bounty_can_refund"] = canRefund
resp["bounty_refund_block_reason"] = blockReason resp["bounty_refund_block_reason"] = blockReason
if n, err := service.CountEligibleBountyReplies(model.DB, post.ID, post.UserID); err == nil { if n, err := services.CountEligibleBountyReplies(models.DB, post.ID, post.UserID); err == nil {
resp["bounty_eligible_reply_count"] = n resp["bounty_eligible_reply_count"] = n
} }
} }
@@ -1091,7 +1091,7 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
} }
uid := h.currentUserID(c) uid := h.currentUserID(c)
isAdmin := h.isAdmin(c) isAdmin := h.isAdmin(c)
if !service.CanViewPost(post, uid, isAdmin) { if !services.CanViewPost(post, uid, isAdmin) {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return return
} }
@@ -1101,7 +1101,7 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
return return
} }
if comments == nil { if comments == nil {
comments = []model.Comment{} comments = []models.Comment{}
} }
if h.Badge != nil { if h.Badge != nil {
h.Badge.AttachBadgeSummariesOnComments(comments, 2) h.Badge.AttachBadgeSummariesOnComments(comments, 2)
@@ -1138,7 +1138,7 @@ func (h *Handlers) APIRecentComments(c *gin.Context) {
return return
} }
if list == nil { if list == nil {
list = []service.RecentCommentItem{} list = []services.RecentCommentItem{}
} }
c.JSON(http.StatusOK, gin.H{"comments": list}) c.JSON(http.StatusOK, gin.H{"comments": list})
} }
@@ -1151,7 +1151,7 @@ func (h *Handlers) APIRecentUsers(c *gin.Context) {
return return
} }
if list == nil { if list == nil {
list = []service.RecentUserItem{} list = []services.RecentUserItem{}
} }
c.JSON(http.StatusOK, gin.H{"users": list}) c.JSON(http.StatusOK, gin.H{"users": list})
} }
@@ -1166,7 +1166,7 @@ func (h *Handlers) APIFavorites(c *gin.Context) {
return return
} }
if favs == nil { if favs == nil {
favs = []model.PostFavorite{} favs = []models.PostFavorite{}
} }
c.JSON(http.StatusOK, gin.H{"favorites": favs, "total": total, "page": page}) c.JSON(http.StatusOK, gin.H{"favorites": favs, "total": total, "page": page})
} }
@@ -1217,6 +1217,6 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
} }
func isClientLimitError(err error) bool { func isClientLimitError(err error) bool {
return errors.Is(err, service.ErrSearchKeywordTooShort) || return errors.Is(err, services.ErrSearchKeywordTooShort) ||
errors.Is(err, service.ErrSearchKeywordTooLong) errors.Is(err, services.ErrSearchKeywordTooLong)
} }

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"errors" "errors"
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// APIMePoints 余额与流水 // APIMePoints 余额与流水
@@ -53,7 +53,7 @@ func (h *Handlers) APIMeCheckInGet(c *gin.Context) {
func (h *Handlers) APIMeCheckIn(c *gin.Context) { func (h *Handlers) APIMeCheckIn(c *gin.Context) {
st, err := h.Points.CheckIn(h.currentUserID(c)) st, err := h.Points.CheckIn(h.currentUserID(c))
if err != nil { if err != nil {
if errors.Is(err, service.ErrAlreadyCheckedIn) { if errors.Is(err, services.ErrAlreadyCheckedIn) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -102,7 +102,7 @@ func (h *Handlers) APIUnlockPostBlock(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"})
return return
} }
res, err := service.UnlockPointsBlock(h.currentUserID(c), uint(id), req.BlockKey) res, err := services.UnlockPointsBlock(h.currentUserID(c), uint(id), req.BlockKey)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -120,7 +120,7 @@ func (h *Handlers) APIAdminVerifyUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
if err := service.SetVerified(uint(id), req.Verified); err != nil { if err := services.SetVerified(uint(id), req.Verified); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -141,11 +141,11 @@ func (h *Handlers) APIAdminSetUserLevel(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
if err := service.SetUserLevel(uint(id), req.Level); err != nil { if err := services.SetUserLevel(uint(id), req.Level); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "等级已更新", "level": req.Level, "exp": model.ExpForLevel(req.Level)}) c.JSON(http.StatusOK, gin.H{"message": "等级已更新", "level": req.Level, "exp": models.ExpForLevel(req.Level)})
} }
// APIAdminAdjustPoints 调积分 // APIAdminAdjustPoints 调积分
@@ -179,7 +179,7 @@ func (h *Handlers) APIAdminListBadges(c *gin.Context) {
// APIAdminUpsertBadge 创建/更新徽章定义 // APIAdminUpsertBadge 创建/更新徽章定义
func (h *Handlers) APIAdminUpsertBadge(c *gin.Context) { func (h *Handlers) APIAdminUpsertBadge(c *gin.Context) {
var def model.BadgeDef var def models.BadgeDef
if err := c.ShouldBindJSON(&def); err != nil { if err := c.ShouldBindJSON(&def); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"fmt" "fmt"
@@ -6,7 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -28,7 +28,7 @@ func (h *Handlers) APIApplyFriendLink(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
result, err := h.FriendLinkApply.Create(service.FriendLinkApplyInput{ result, err := h.FriendLinkApply.Create(services.FriendLinkApplyInput{
UserID: uid, UserID: uid,
Name: req.Name, Name: req.Name,
URL: req.URL, URL: req.URL,
@@ -65,10 +65,10 @@ func (h *Handlers) APIUploadFriendLinkLogo(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大"}) c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大"})
return return
} }
url, err := service.SaveUploadedImage( url, err := services.SaveUploadedImage(
h.Store, h.Store,
file, file,
service.UploadCategorySite, services.UploadCategorySite,
fmt.Sprintf("fl_%d", uid), fmt.Sprintf("fl_%d", uid),
) )
if err != nil { if err != nil {
@@ -83,7 +83,7 @@ func (h *Handlers) APIAdminFriendLinkApplies(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
status := strings.TrimSpace(c.DefaultQuery("status", "pending")) status := strings.TrimSpace(c.DefaultQuery("status", "pending"))
list, total, err := h.FriendLinkApply.ListAdmin(service.FriendLinkApplyListQuery{ list, total, err := h.FriendLinkApply.ListAdmin(services.FriendLinkApplyListQuery{
Page: page, Size: size, Status: status, Page: page, Size: size, Status: status,
}) })
if err != nil { if err != nil {
@@ -236,7 +236,7 @@ func (h *Handlers) APIUpdateFriendLinkApply(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
result, err := h.FriendLinkApply.Update(uid, uint(id), service.FriendLinkApplyInput{ result, err := h.FriendLinkApply.Update(uid, uint(id), services.FriendLinkApplyInput{
UserID: uid, UserID: uid,
Name: req.Name, Name: req.Name,
URL: req.URL, URL: req.URL,

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"encoding/base64" "encoding/base64"
@@ -10,58 +10,58 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// Handlers 聚合所有 HTTP 处理器 // Handlers 聚合所有 HTTP 处理器
type Handlers struct { type Handlers struct {
Cfg *config.Config Cfg *config.Config
Store *service.UploadStore Store *services.UploadStore
Auth *service.AuthService Auth *services.AuthService
User *service.UserService User *services.UserService
Board *service.BoardService Board *services.BoardService
Post *service.PostService Post *services.PostService
Comment *service.CommentService Comment *services.CommentService
Message *service.MessageService Message *services.MessageService
Notify *service.NotifyService Notify *services.NotifyService
Report *service.ReportService Report *services.ReportService
Backup *service.BackupService Backup *services.BackupService
Filter *service.SensitiveFilter Filter *services.SensitiveFilter
Limiter *service.RateLimiter Limiter *services.RateLimiter
Settings *service.ForumSettingsService Settings *services.ForumSettingsService
Captcha *service.CaptchaService Captcha *services.CaptchaService
Mail *service.MailService Mail *services.MailService
EmailCode *service.EmailCodeService EmailCode *services.EmailCodeService
OIDC *service.OIDCService OIDC *services.OIDCService
Gitea *service.GiteaService Gitea *services.GiteaService
Points *service.PointsService Points *services.PointsService
Badge *service.BadgeService Badge *services.BadgeService
SitePage *service.SitePageService SitePage *services.SitePageService
FriendLinkApply *service.FriendLinkApplyService FriendLinkApply *services.FriendLinkApplyService
} }
func (h *Handlers) setAuthCookie(c *gin.Context, token string) { func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
c.SetCookie(middleware.CookieName, token, int(service.TokenExpire.Seconds()), "/", "", false, true) c.SetCookie(auth.CookieName, token, int(services.TokenExpire.Seconds()), "/", "", false, true)
} }
func (h *Handlers) currentUserID(c *gin.Context) uint { func (h *Handlers) currentUserID(c *gin.Context) uint {
if v, ok := c.Get(middleware.CtxUserID); ok { if v, ok := c.Get(auth.CtxUserID); ok {
return v.(uint) return v.(uint)
} }
return 0 return 0
} }
func (h *Handlers) isAdmin(c *gin.Context) bool { func (h *Handlers) isAdmin(c *gin.Context) bool {
if v, ok := c.Get(middleware.CtxRole); ok { if v, ok := c.Get(auth.CtxRole); ok {
return v == model.RoleAdmin return v == models.RoleAdmin
} }
return false return false
} }
// loadCurrentUser 加载当前登录用户完整资料(含认证/积分) // loadCurrentUser 加载当前登录用户完整资料(含认证/积分)
func (h *Handlers) loadCurrentUser(c *gin.Context) (*model.User, error) { func (h *Handlers) loadCurrentUser(c *gin.Context) (*models.User, error) {
uid := h.currentUserID(c) uid := h.currentUserID(c)
if uid == 0 { if uid == 0 {
return nil, errors.New("未登录") return nil, errors.New("未登录")
@@ -133,7 +133,7 @@ func (h *Handlers) APIRegisterConfig(c *gin.Context) {
"mail_ready": mailReady, "mail_ready": mailReady,
"require_email_code": mailReady, "require_email_code": mailReady,
"register_open": userCount == 0 || mailReady, "register_open": userCount == 0 || mailReady,
"email_code_len": service.EmailCodeLen, "email_code_len": services.EmailCodeLen,
}) })
} }
@@ -147,7 +147,7 @@ func (h *Handlers) APISendRegisterEmailCode(c *gin.Context) {
return return
} }
if !h.Settings.MailReady() { if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrMailNotConfigured.Error()})
return return
} }
if err := h.EmailCode.SendRegisterCode(req.Email); err != nil { if err := h.EmailCode.SendRegisterCode(req.Email); err != nil {
@@ -167,7 +167,7 @@ func (h *Handlers) APISendResetEmailCode(c *gin.Context) {
return return
} }
if !h.Settings.MailReady() { if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrMailNotConfigured.Error()})
return return
} }
if err := h.EmailCode.SendResetCode(req.Email); err != nil { if err := h.EmailCode.SendResetCode(req.Email); err != nil {
@@ -189,11 +189,11 @@ func (h *Handlers) APIResetPassword(c *gin.Context) {
return return
} }
if !h.Settings.MailReady() { if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrMailNotConfigured.Error()})
return return
} }
if !h.EmailCode.VerifyPurpose(service.EmailCodePurposeReset, req.Email, req.EmailCode) { if !h.EmailCode.VerifyPurpose(services.EmailCodePurposeReset, req.Email, req.EmailCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrEmailCodeInvalid.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrEmailCodeInvalid.Error()})
return return
} }
if err := h.User.ResetPasswordByEmail(req.Email, req.NewPassword); err != nil { if err := h.User.ResetPasswordByEmail(req.Email, req.NewPassword); err != nil {
@@ -244,12 +244,12 @@ func (h *Handlers) APIRegister(c *gin.Context) {
userCount := h.Auth.UserCount() userCount := h.Auth.UserCount()
mailReady := h.Settings.MailReady() mailReady := h.Settings.MailReady()
if userCount > 0 && !mailReady { if userCount > 0 && !mailReady {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrRegisterClosed.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrRegisterClosed.Error()})
return return
} }
if mailReady { if mailReady {
if !h.EmailCode.Verify(req.Email, req.EmailCode) { if !h.EmailCode.Verify(req.Email, req.EmailCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrEmailCodeInvalid.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrEmailCodeInvalid.Error()})
return return
} }
} }
@@ -283,7 +283,7 @@ func (h *Handlers) APILogin(c *gin.Context) {
} }
func (h *Handlers) APILogout(c *gin.Context) { func (h *Handlers) APILogout(c *gin.Context) {
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true) c.SetCookie(auth.CookieName, "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "已退出"}) c.JSON(http.StatusOK, gin.H{"message": "已退出"})
} }
@@ -323,7 +323,7 @@ func (h *Handlers) APIUserPublic(c *gin.Context) {
if h.Badge != nil { if h.Badge != nil {
_ = h.Badge.EvaluateAuto(user.ID) _ = h.Badge.EvaluateAuto(user.ID)
if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil { if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
view.Badges = service.BadgeViews(badges, 0) view.Badges = services.BadgeViews(badges, 0)
} }
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -396,10 +396,10 @@ func (h *Handlers) APIUploadPostImage(c *gin.Context) {
return return
} }
uid := h.currentUserID(c) uid := h.currentUserID(c)
url, err := service.SaveUploadedImage( url, err := services.SaveUploadedImage(
h.Store, h.Store,
file, file,
service.UploadCategoryPosts, services.UploadCategoryPosts,
fmt.Sprintf("%d", uid), fmt.Sprintf("%d", uid),
) )
if err != nil { if err != nil {
@@ -421,20 +421,20 @@ func (h *Handlers) APICreatePost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
extras := service.ParsePostExtrasFromForm( extras := services.ParsePostExtrasFromForm(
c.PostForm("poll_options"), c.PostForm("poll_options"),
c.PostForm("bounty_points"), c.PostForm("bounty_points"),
c.PostForm("lottery_winner_count"), c.PostForm("lottery_winner_count"),
) )
if post.PostType == model.PostTypePoll || post.PostType == model.PostTypeBounty || post.PostType == model.PostTypeLottery { if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty || post.PostType == models.PostTypeLottery {
if err := service.FinalizeSpecialPostCreate(post, h.currentUserID(c), extras); err != nil { if err := services.FinalizeSpecialPostCreate(post, h.currentUserID(c), extras); err != nil {
_ = h.Post.Delete(h.currentUserID(c), post.ID, true) _ = h.Post.Delete(h.currentUserID(c), post.ID, true)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
} }
msg := "发帖成功" msg := "发帖成功"
if post.Status == model.ContentStatusPending { if post.Status == models.ContentStatusPending {
msg = "已提交审核,通过后将公开显示" msg = "已提交审核,通过后将公开显示"
if h.Notify != nil { if h.Notify != nil {
h.Notify.AsyncNotifyPendingPost(post) h.Notify.AsyncNotifyPendingPost(post)
@@ -479,8 +479,8 @@ func (h *Handlers) APIToggleLike(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
var post model.Post var post models.Post
model.DB.First(&post, id) models.DB.First(&post, id)
c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": post.LikeCount}) c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": post.LikeCount})
} }
@@ -535,7 +535,7 @@ func (h *Handlers) APICreateComment(c *gin.Context) {
return return
} }
in := service.CommentCreateInput{ in := services.CommentCreateInput{
UserID: uid, UserID: uid,
PostID: uint(postID), PostID: uint(postID),
Content: content, Content: content,
@@ -551,14 +551,14 @@ func (h *Handlers) APICreateComment(c *gin.Context) {
msg := "评论成功" msg := "评论成功"
if h.Notify != nil { if h.Notify != nil {
switch comment.Status { switch comment.Status {
case model.ContentStatusPublished: case models.ContentStatusPublished:
h.Notify.AsyncNotifyCommentPublished(comment) h.Notify.AsyncNotifyCommentPublished(comment)
h.Notify.AsyncNotifyCommentMentions(comment) h.Notify.AsyncNotifyCommentMentions(comment)
case model.ContentStatusPending: case models.ContentStatusPending:
msg = "评论已提交,审核通过后公开显示" msg = "评论已提交,审核通过后公开显示"
h.Notify.AsyncNotifyPendingComment(comment) h.Notify.AsyncNotifyPendingComment(comment)
} }
} else if comment.Status == model.ContentStatusPending { } else if comment.Status == models.ContentStatusPending {
msg = "评论已提交,审核通过后公开显示" msg = "评论已提交,审核通过后公开显示"
} }
c.JSON(http.StatusOK, gin.H{"message": msg, "floor": comment.Floor, "id": comment.ID, "status": comment.Status}) c.JSON(http.StatusOK, gin.H{"message": msg, "floor": comment.Floor, "id": comment.ID, "status": comment.Status})
@@ -586,7 +586,7 @@ func (h *Handlers) APIUpdateComment(c *gin.Context) {
status := "" status := ""
if comment, e := h.Comment.GetByID(uint(id)); e == nil { if comment, e := h.Comment.GetByID(uint(id)); e == nil {
status = comment.Status status = comment.Status
if status == model.ContentStatusPending && !h.isAdmin(c) { if status == models.ContentStatusPending && !h.isAdmin(c) {
msg = "评论已更新,审核通过后公开显示" msg = "评论已更新,审核通过后公开显示"
} }
if enteredPending && h.Notify != nil { if enteredPending && h.Notify != nil {

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"net/http" "net/http"

View File

@@ -1,19 +1,19 @@
package handler package api
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// APIMessageConversations 会话列表(按对方聚合) // APIMessageConversations 会话列表(按对方聚合)
func (h *Handlers) APIMessageConversations(c *gin.Context) { func (h *Handlers) APIMessageConversations(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "30")) size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
list, total, err := h.Message.ListConversations(service.ConversationListQuery{ list, total, err := h.Message.ListConversations(services.ConversationListQuery{
UserID: h.currentUserID(c), UserID: h.currentUserID(c),
Page: page, Page: page,
Size: size, Size: size,
@@ -41,7 +41,7 @@ func (h *Handlers) APIConversationMessages(c *gin.Context) {
before, _ := strconv.ParseUint(c.DefaultQuery("before", "0"), 10, 64) before, _ := strconv.ParseUint(c.DefaultQuery("before", "0"), 10, 64)
uid := h.currentUserID(c) uid := h.currentUserID(c)
list, total, err := h.Message.ListConversationMessages(service.ConversationMessagesQuery{ list, total, err := h.Message.ListConversationMessages(services.ConversationMessagesQuery{
UserID: uid, UserID: uid,
PeerID: uint(peerID), PeerID: uint(peerID),
Page: page, Page: page,
@@ -63,10 +63,10 @@ func (h *Handlers) APIConversationMessages(c *gin.Context) {
} }
} }
var peer *model.User var peer *models.User
if peerID > 0 { if peerID > 0 {
var u model.User var u models.User
if err := model.DB.First(&u, uint(peerID)).Error; err == nil { if err := models.DB.First(&u, uint(peerID)).Error; err == nil {
peer = &u peer = &u
} }
} }
@@ -147,7 +147,7 @@ func (h *Handlers) APISendMessage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
msg, err := h.Message.Send(service.MessageSendInput{ msg, err := h.Message.Send(services.MessageSendInput{
FromUserID: h.currentUserID(c), FromUserID: h.currentUserID(c),
ToUserID: req.ToUserID, ToUserID: req.ToUserID,
Subject: req.Subject, Subject: req.Subject,

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"encoding/base64" "encoding/base64"
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// OIDCDiscovery OpenID Provider 元数据 // OIDCDiscovery OpenID Provider 元数据
@@ -47,7 +47,7 @@ func (h *Handlers) OIDCAuthorize(c *gin.Context) {
return return
} }
req := service.AuthorizeRequest{ req := services.AuthorizeRequest{
ClientID: c.Query("client_id"), ClientID: c.Query("client_id"),
RedirectURI: c.Query("redirect_uri"), RedirectURI: c.Query("redirect_uri"),
ResponseType: c.Query("response_type"), ResponseType: c.Query("response_type"),
@@ -60,7 +60,7 @@ func (h *Handlers) OIDCAuthorize(c *gin.Context) {
if err := h.OIDC.ValidateAuthorize(req); err != nil { if err := h.OIDC.ValidateAuthorize(req); err != nil {
// redirect_uri 未通过校验时不能重定向,避免开放重定向 // redirect_uri 未通过校验时不能重定向,避免开放重定向
if errors.Is(err, service.ErrOIDCInvalidRedirect) || errors.Is(err, service.ErrOIDCInvalidClient) { if errors.Is(err, services.ErrOIDCInvalidRedirect) || errors.Is(err, services.ErrOIDCInvalidClient) {
c.String(http.StatusBadRequest, err.Error()) c.String(http.StatusBadRequest, err.Error())
return return
} }
@@ -77,7 +77,7 @@ func (h *Handlers) OIDCAuthorize(c *gin.Context) {
callback, err := h.OIDC.IssueAuthCode(uid, req) callback, err := h.OIDC.IssueAuthCode(uid, req)
if err != nil { if err != nil {
if errors.Is(err, service.ErrOIDCUserBanned) { if errors.Is(err, services.ErrOIDCUserBanned) {
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言") h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
return return
} }
@@ -121,7 +121,7 @@ func (h *Handlers) OIDCToken(c *gin.Context) {
} }
} }
resp, err := h.OIDC.ExchangeCode(service.TokenRequest{ resp, err := h.OIDC.ExchangeCode(services.TokenRequest{
GrantType: c.PostForm("grant_type"), GrantType: c.PostForm("grant_type"),
Code: c.PostForm("code"), Code: c.PostForm("code"),
RedirectURI: c.PostForm("redirect_uri"), RedirectURI: c.PostForm("redirect_uri"),
@@ -133,12 +133,12 @@ func (h *Handlers) OIDCToken(c *gin.Context) {
status := http.StatusBadRequest status := http.StatusBadRequest
code := "invalid_grant" code := "invalid_grant"
switch { switch {
case errors.Is(err, service.ErrOIDCInvalidClient): case errors.Is(err, services.ErrOIDCInvalidClient):
status = http.StatusUnauthorized status = http.StatusUnauthorized
code = "invalid_client" code = "invalid_client"
case errors.Is(err, service.ErrOIDCInvalidRequest): case errors.Is(err, services.ErrOIDCInvalidRequest):
code = "invalid_request" code = "invalid_request"
case errors.Is(err, service.ErrOIDCPKCEFailed): case errors.Is(err, services.ErrOIDCPKCEFailed):
code = "invalid_grant" code = "invalid_grant"
} }
c.JSON(status, gin.H{"error": code, "error_description": err.Error()}) c.JSON(status, gin.H{"error": code, "error_description": err.Error()})
@@ -179,7 +179,7 @@ func (h *Handlers) OIDCLogout(c *gin.Context) {
state = c.PostForm("state") state = c.PostForm("state")
} }
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true) c.SetCookie(auth.CookieName, "", -1, "/", "", false, true)
if h.OIDC == nil { if h.OIDC == nil {
c.Redirect(http.StatusFound, "/") c.Redirect(http.StatusFound, "/")

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"net/http" "net/http"
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// APICreatePostReport 举报帖子 // APICreatePostReport 举报帖子
@@ -53,7 +53,7 @@ func (h *Handlers) APIAdminReports(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
status := c.DefaultQuery("status", "pending") status := c.DefaultQuery("status", "pending")
list, total, err := h.Report.ListAdmin(service.ReportListQuery{ list, total, err := h.Report.ListAdmin(services.ReportListQuery{
Status: status, Status: status,
Page: page, Page: page,
Size: size, Size: size,
@@ -84,7 +84,7 @@ func (h *Handlers) APIAdminHandleReport(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
rep, err := h.Report.Handle(service.HandleReportInput{ rep, err := h.Report.Handle(services.HandleReportInput{
ReportID: uint(id), ReportID: uint(id),
HandlerID: h.currentUserID(c), HandlerID: h.currentUserID(c),
Action: req.Action, Action: req.Action,
@@ -101,11 +101,11 @@ func (h *Handlers) APIAdminHandleReport(c *gin.Context) {
// APIAdminApprovePost 通过帖子审核 // APIAdminApprovePost 通过帖子审核
func (h *Handlers) APIAdminApprovePost(c *gin.Context) { func (h *Handlers) APIAdminApprovePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Post.SetStatus(uint(id), model.ContentStatusPublished); err != nil { if err := h.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "帖子已通过审核", "status": model.ContentStatusPublished}) c.JSON(http.StatusOK, gin.H{"message": "帖子已通过审核", "status": models.ContentStatusPublished})
} }
// APIAdminRejectPost 拒绝帖子并私信通知作者(标记为 rejected不进回收站 // APIAdminRejectPost 拒绝帖子并私信通知作者(标记为 rejected不进回收站
@@ -133,7 +133,7 @@ func (h *Handlers) APIAdminRejectPost(c *gin.Context) {
title := post.Title title := post.Title
postID := post.ID postID := post.ID
if err := h.Post.SetStatus(postID, model.ContentStatusRejected); err != nil { if err := h.Post.SetStatus(postID, models.ContentStatusRejected); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -142,8 +142,8 @@ func (h *Handlers) APIAdminRejectPost(c *gin.Context) {
_, msgErr := h.Message.SendSystem( _, msgErr := h.Message.SendSystem(
authorID, authorID,
"帖子《"+title+"》未通过审核", "帖子《"+title+"》未通过审核",
service.FormatRejectContent(title, postID, reason), services.FormatRejectContent(title, postID, reason),
model.MessageKindReject, models.MessageKindReject,
&pid, &pid,
nil, nil,
) )
@@ -151,13 +151,13 @@ func (h *Handlers) APIAdminRejectPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "帖子已拒绝,但私信通知失败:" + msgErr.Error(), "message": "帖子已拒绝,但私信通知失败:" + msgErr.Error(),
"notified": false, "notified": false,
"status": model.ContentStatusRejected, "status": models.ContentStatusRejected,
}) })
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "已拒绝该帖并私信通知作者", "message": "已拒绝该帖并私信通知作者",
"notified": true, "notified": true,
"status": model.ContentStatusRejected, "status": models.ContentStatusRejected,
}) })
} }

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"encoding/json" "encoding/json"
@@ -11,9 +11,9 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/embed_static" "git.iioio.com/freefire/jiang13-forum/modules/seo"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
var ( var (
@@ -60,7 +60,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
now := time.Now().UTC() now := time.Now().UTC()
permalink := h.Settings.Permalink() permalink := h.Settings.Permalink()
urls := []service.SitemapURL{ urls := []services.SitemapURL{
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"}, {Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
{Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"}, {Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"},
{Loc: base + "/links", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"}, {Loc: base + "/links", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"},
@@ -68,8 +68,8 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
if boards, err := h.Board.List(); err == nil { if boards, err := h.Board.List(); err == nil {
for _, board := range boards { for _, board := range boards {
urls = append(urls, service.SitemapURL{ urls = append(urls, services.SitemapURL{
Loc: base + service.QueryBoardHome(board.ID, permalink), Loc: base + services.QueryBoardHome(board.ID, permalink),
LastMod: board.UpdatedAt.UTC(), LastMod: board.UpdatedAt.UTC(),
ChangeFreq: "daily", ChangeFreq: "daily",
Priority: "0.7", Priority: "0.7",
@@ -83,7 +83,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
if lm.IsZero() { if lm.IsZero() {
lm = p.CreatedAt lm = p.CreatedAt
} }
urls = append(urls, service.SitemapURL{ urls = append(urls, services.SitemapURL{
Loc: base + permalink.PostPath(p.ID), Loc: base + permalink.PostPath(p.ID),
LastMod: lm.UTC(), LastMod: lm.UTC(),
ChangeFreq: "weekly", ChangeFreq: "weekly",
@@ -94,7 +94,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil { if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil {
for _, u := range users { for _, u := range users {
urls = append(urls, service.SitemapURL{ urls = append(urls, services.SitemapURL{
Loc: base + permalink.UserPath(u.ID), Loc: base + permalink.UserPath(u.ID),
LastMod: u.UpdatedAt.UTC(), LastMod: u.UpdatedAt.UTC(),
ChangeFreq: "weekly", ChangeFreq: "weekly",
@@ -109,7 +109,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
if lm.IsZero() { if lm.IsZero() {
lm = p.CreatedAt lm = p.CreatedAt
} }
urls = append(urls, service.SitemapURL{ urls = append(urls, services.SitemapURL{
Loc: base + permalink.PagePath(p.Slug), Loc: base + permalink.PagePath(p.Slug),
LastMod: lm.UTC(), LastMod: lm.UTC(),
ChangeFreq: "monthly", ChangeFreq: "monthly",
@@ -160,14 +160,14 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
if siteName == "" { if siteName == "" {
siteName = "姜十三论坛" siteName = "姜十三论坛"
} }
defaultImage := service.AbsoluteURL(base, brand.DefaultShareImage()) defaultImage := services.AbsoluteURL(base, brand.DefaultShareImage())
siteKeywords := brand.MetaKeywords() siteKeywords := brand.MetaKeywords()
permalink := h.Settings.Permalink() permalink := h.Settings.Permalink()
// 旧版 /?board=id → 规范板块路径 // 旧版 /?board=id → 规范板块路径
if path == "/" || path == "" { if path == "/" || path == "" {
if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 { if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 {
target := service.QueryBoardHome(uint(boardID), permalink) target := services.QueryBoardHome(uint(boardID), permalink)
if q := c.Request.URL.RawQuery; q != "" { if q := c.Request.URL.RawQuery; q != "" {
// 保留 sort/keyword 等 query去掉 board // 保留 sort/keyword 等 query去掉 board
vals := c.Request.URL.Query() vals := c.Request.URL.Query()
@@ -181,7 +181,7 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
} }
} }
isBot := service.IsSEOCrawler(c.Request.UserAgent()) isBot := services.IsSEOCrawler(c.Request.UserAgent())
if isBot { if isBot {
c.Header("Vary", "User-Agent") c.Header("Vary", "User-Agent")
} }
@@ -201,11 +201,11 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
if desc == "" { if desc == "" {
desc = brand.MetaDescription() desc = brand.MetaDescription()
} }
meta := attachSiteSEO(&embed_static.SPAPageMeta{ meta := attachSiteSEO(&seo.PageMeta{
Title: pageTitle(board.Name, siteName), Title: pageTitle(board.Name, siteName),
Description: service.TruncateRunes(desc, seoDescMax), Description: services.TruncateRunes(desc, seoDescMax),
Keywords: service.JoinSEOKeywords(board.Name, siteKeywords), Keywords: services.JoinSEOKeywords(board.Name, siteKeywords),
Canonical: service.AbsoluteURL(base, bm.Canonical), Canonical: services.AbsoluteURL(base, bm.Canonical),
OGType: "website", OGType: "website",
OGImage: defaultImage, OGImage: defaultImage,
}, siteName, siteKeywords) }, siteName, siteKeywords)
@@ -213,7 +213,7 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board))) c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board)))
return return
} }
embed_static.ServeSPAWithMeta(c, meta) servePendingSSR(c, meta.Title, `<p>板块页 SSR 迁移中,请先从 <a href="/">首页</a> 浏览。</p>`)
return return
} }
@@ -224,16 +224,16 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
return return
} }
post, err := h.Post.FindByID(pm.ID) post, err := h.Post.FindByID(pm.ID)
if err != nil || !service.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) { if err != nil || !services.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
return return
} }
postKeywords := service.JoinSEOKeywords(post.Board.Name, siteKeywords) postKeywords := services.JoinSEOKeywords(post.Board.Name, siteKeywords)
if isBot { if isBot {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post))) c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post)))
return return
} }
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, postKeywords)) servePendingSSR(c, pageTitle(post.Title, siteName), `<p>帖子详情 SSR 迁移中,请先从 <a href="/">首页</a> 返回。</p>`)
return return
} }
@@ -252,7 +252,7 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user))) c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user)))
return return
} }
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, siteKeywords)) servePendingSSR(c, pageTitle(user.Nickname, siteName), `<p>用户主页 SSR 迁移中,请先从 <a href="/">首页</a> 返回。</p>`)
return return
} }
@@ -267,12 +267,12 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
return return
} }
desc := service.ExcerptFromHTML(page.Content, seoDescMax) desc := services.ExcerptFromHTML(page.Content, seoDescMax)
meta := attachSiteSEO(&embed_static.SPAPageMeta{ meta := attachSiteSEO(&seo.PageMeta{
Title: pageTitle(page.Title, siteName), Title: pageTitle(page.Title, siteName),
Description: desc, Description: desc,
Keywords: service.JoinSEOKeywords(page.Title, siteKeywords), Keywords: services.JoinSEOKeywords(page.Title, siteKeywords),
Canonical: service.AbsoluteURL(base, pg.Canonical), Canonical: services.AbsoluteURL(base, pg.Canonical),
OGType: "article", OGType: "article",
OGImage: defaultImage, OGImage: defaultImage,
}, siteName, siteKeywords) }, siteName, siteKeywords)
@@ -281,7 +281,7 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderBotHTML(meta, body))) c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderBotHTML(meta, body)))
return return
} }
embed_static.ServeSPAWithMeta(c, meta) servePendingSSR(c, meta.Title, page.Content)
return return
} }
@@ -291,13 +291,23 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
return return
} }
// 其余已知路由:SPA + head meta首页对爬虫额外返回可读正文 // 其余已知路由:爬虫可读首页;用户走占位页(首页本身已由 routers/web SSR
meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage) meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
if isBot && (path == "/" || path == "") { if isBot && (path == "/" || path == "") {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand))) c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand)))
return return
} }
embed_static.ServeSPAWithMeta(c, meta) servePendingSSR(c, meta.Title, `<p>该页面 SSR 迁移中。<a href="/">返回首页</a></p>`)
}
func servePendingSSR(c *gin.Context, title, bodyHTML string) {
if strings.TrimSpace(title) == "" {
title = "姜十三论坛"
}
page := fmt.Sprintf(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>%s</title><link rel="stylesheet" href="/ssr-assets/site.css"/></head><body class="j13-body"><main class="j13-main" style="max-width:800px;margin:2rem auto;padding:1rem">%s</main></body></html>`,
html.EscapeString(title), bodyHTML)
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
} }
func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string, isBot bool) { func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string, isBot bool) {
@@ -306,14 +316,17 @@ func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(botNotFoundHTML(base, siteName, keywords, path))) c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(botNotFoundHTML(base, siteName, keywords, path)))
return return
} }
embed_static.ServeSPAWithMeta(c, notFoundPageMeta(base, siteName, keywords, path)) page := fmt.Sprintf(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><title>%s</title></head><body><h1>404</h1><p>页面不存在。</p><p><a href="/">返回首页</a></p></body></html>`,
html.EscapeString(pageTitle("页面不存在", siteName)))
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(page))
} }
func notFoundPageMeta(base, siteName, keywords, path string) *embed_static.SPAPageMeta { func notFoundPageMeta(base, siteName, keywords, path string) *seo.PageMeta {
return attachSiteSEO(&embed_static.SPAPageMeta{ return attachSiteSEO(&seo.PageMeta{
Title: pageTitle("页面不存在", siteName), Title: pageTitle("页面不存在", siteName),
Description: "您访问的页面不存在或已删除", Description: "您访问的页面不存在或已删除",
Canonical: service.AbsoluteURL(base, path), Canonical: services.AbsoluteURL(base, path),
OGType: "website", OGType: "website",
Robots: "noindex,follow", Robots: "noindex,follow",
Status: http.StatusNotFound, Status: http.StatusNotFound,
@@ -321,7 +334,7 @@ func notFoundPageMeta(base, siteName, keywords, path string) *embed_static.SPAPa
} }
// attachSiteSEO 填充站点级 keywords / og:site_name / og:locale // attachSiteSEO 填充站点级 keywords / og:site_name / og:locale
func attachSiteSEO(meta *embed_static.SPAPageMeta, siteName, keywords string) *embed_static.SPAPageMeta { func attachSiteSEO(meta *seo.PageMeta, siteName, keywords string) *seo.PageMeta {
if meta == nil { if meta == nil {
return nil return nil
} }
@@ -341,7 +354,7 @@ func isKnownPublicPath(path string) bool {
if seoPostEditRe.MatchString(path) { if seoPostEditRe.MatchString(path) {
return true return true
} }
permalink := service.PermalinkConfig{} permalink := services.PermalinkConfig{}
if permalink.MatchBoardPath(path).OK { if permalink.MatchBoardPath(path).OK {
return true return true
} }
@@ -351,15 +364,15 @@ func isKnownPublicPath(path string) bool {
return false return false
} }
func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.SiteBranding, base, siteName, defaultImage string) *embed_static.SPAPageMeta { func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand services.SiteBranding, base, siteName, defaultImage string) *seo.PageMeta {
siteTitle := brand.DocumentTitle() siteTitle := brand.DocumentTitle()
homeDesc := service.TruncateRunes(brand.MetaDescription(), seoDescMax) homeDesc := services.TruncateRunes(brand.MetaDescription(), seoDescMax)
siteKeywords := brand.MetaKeywords() siteKeywords := brand.MetaKeywords()
meta := attachSiteSEO(&embed_static.SPAPageMeta{ meta := attachSiteSEO(&seo.PageMeta{
Title: siteTitle, Title: siteTitle,
Description: homeDesc, Description: homeDesc,
Keywords: siteKeywords, Keywords: siteKeywords,
Canonical: service.AbsoluteURL(base, pathWithQuery(c)), Canonical: services.AbsoluteURL(base, pathWithQuery(c)),
OGType: "website", OGType: "website",
OGImage: defaultImage, OGImage: defaultImage,
}, siteName, siteKeywords) }, siteName, siteKeywords)
@@ -379,9 +392,9 @@ func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.S
desc = brand.MetaDescription() desc = brand.MetaDescription()
} }
meta.Title = pageTitle(board.Name, siteName) meta.Title = pageTitle(board.Name, siteName)
meta.Description = service.TruncateRunes(desc, seoDescMax) meta.Description = services.TruncateRunes(desc, seoDescMax)
meta.Canonical = service.AbsoluteURL(base, service.QueryBoardHome(board.ID, h.Settings.Permalink())) meta.Canonical = services.AbsoluteURL(base, services.QueryBoardHome(board.ID, h.Settings.Permalink()))
meta.Keywords = service.JoinSEOKeywords(board.Name, siteKeywords) meta.Keywords = services.JoinSEOKeywords(board.Name, siteKeywords)
return meta return meta
} }
// 无效板块 id仍显示首页但可标记 noindex // 无效板块 id仍显示首页但可标记 noindex
@@ -393,38 +406,38 @@ func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.S
"@type": "WebSite", "@type": "WebSite",
"name": siteName, "name": siteName,
"description": meta.Description, "description": meta.Description,
"url": service.AbsoluteURL(base, "/"), "url": services.AbsoluteURL(base, "/"),
}) })
} }
if path == "/projects" { if path == "/projects" {
meta.Title = pageTitle("项目", siteName) meta.Title = pageTitle("项目", siteName)
meta.Description = service.TruncateRunes(siteName+" 的公开项目列表", seoDescMax) meta.Description = services.TruncateRunes(siteName+" 的公开项目列表", seoDescMax)
meta.Keywords = service.JoinSEOKeywords("项目", siteKeywords) meta.Keywords = services.JoinSEOKeywords("项目", siteKeywords)
} }
if path == "/links" { if path == "/links" {
meta.Title = pageTitle("友情链接", siteName) meta.Title = pageTitle("友情链接", siteName)
meta.Description = service.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax) meta.Description = services.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax)
meta.Keywords = service.JoinSEOKeywords("友情链接", siteKeywords) meta.Keywords = services.JoinSEOKeywords("友情链接", siteKeywords)
} }
return meta return meta
} }
func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model.Post) *embed_static.SPAPageMeta { func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *models.Post) *seo.PageMeta {
permalink := h.Settings.Permalink() permalink := h.Settings.Permalink()
content := service.RedactGatedPostHTML(post.Content) content := services.RedactGatedPostHTML(post.Content)
plain := post.ContentPlain plain := post.ContentPlain
if plain == "" { if plain == "" {
plain = service.StripHTMLForSearch(content) plain = services.StripHTMLForSearch(content)
} }
desc := service.TruncateRunes(plain, seoDescMax) desc := services.TruncateRunes(plain, seoDescMax)
author := service.DisplayName(&post.User) author := services.DisplayName(&post.User)
canonical := service.AbsoluteURL(base, permalink.PostPath(post.ID)) canonical := services.AbsoluteURL(base, permalink.PostPath(post.ID))
ogImage := service.AbsoluteURL(base, service.FirstImageURL(content)) ogImage := services.AbsoluteURL(base, services.FirstImageURL(content))
if ogImage == "" { if ogImage == "" {
ogImage = service.AbsoluteURL(base, post.User.Avatar) ogImage = services.AbsoluteURL(base, post.User.Avatar)
} }
if ogImage == "" { if ogImage == "" {
ogImage = defaultImage ogImage = defaultImage
@@ -442,7 +455,7 @@ func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model
"author": map[string]any{ "author": map[string]any{
"@type": "Person", "@type": "Person",
"name": author, "name": author,
"url": service.AbsoluteURL(base, permalink.UserPath(post.UserID)), "url": services.AbsoluteURL(base, permalink.UserPath(post.UserID)),
}, },
"interactionStatistic": map[string]any{ "interactionStatistic": map[string]any{
"@type": "InteractionCounter", "@type": "InteractionCounter",
@@ -456,12 +469,12 @@ func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model
if ogImage != "" { if ogImage != "" {
jsonld["image"] = []string{ogImage} jsonld["image"] = []string{ogImage}
} }
body := service.TruncateRunes(plain, seoPrerenderMax) body := services.TruncateRunes(plain, seoPrerenderMax)
if body != "" { if body != "" {
jsonld["articleBody"] = body jsonld["articleBody"] = body
} }
return &embed_static.SPAPageMeta{ return &seo.PageMeta{
Title: pageTitle(post.Title, siteName), Title: pageTitle(post.Title, siteName),
Description: desc, Description: desc,
Canonical: canonical, Canonical: canonical,
@@ -471,16 +484,16 @@ func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model
} }
} }
func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *model.User) *embed_static.SPAPageMeta { func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *models.User) *seo.PageMeta {
permalink := h.Settings.Permalink() permalink := h.Settings.Permalink()
name := service.DisplayName(user) name := services.DisplayName(user)
desc := strings.TrimSpace(user.Signature) desc := strings.TrimSpace(user.Signature)
if desc == "" { if desc == "" {
desc = name + " 的主页" desc = name + " 的主页"
} }
desc = service.TruncateRunes(desc, seoDescMax) desc = services.TruncateRunes(desc, seoDescMax)
canonical := service.AbsoluteURL(base, permalink.UserPath(user.ID)) canonical := services.AbsoluteURL(base, permalink.UserPath(user.ID))
ogImage := service.AbsoluteURL(base, user.Avatar) ogImage := services.AbsoluteURL(base, user.Avatar)
if ogImage == "" { if ogImage == "" {
ogImage = defaultImage ogImage = defaultImage
} }
@@ -500,7 +513,7 @@ func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *model
jsonld["mainEntity"].(map[string]any)["image"] = ogImage jsonld["mainEntity"].(map[string]any)["image"] = ogImage
} }
return &embed_static.SPAPageMeta{ return &seo.PageMeta{
Title: pageTitle(name+" 的主页", siteName), Title: pageTitle(name+" 的主页", siteName),
Description: desc, Description: desc,
Canonical: canonical, Canonical: canonical,
@@ -538,13 +551,13 @@ func pathWithQuery(c *gin.Context) string {
if path == "" { if path == "" {
path = "/" path = "/"
} }
permalink := service.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board permalink := services.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board
if q := c.Request.URL.RawQuery; q != "" { if q := c.Request.URL.RawQuery; q != "" {
if path == "/" { if path == "/" {
board := c.Query("board") board := c.Query("board")
if board != "" { if board != "" {
_ = permalink _ = permalink
return service.LegacyQueryBoardHome(uint(parseUintOrZero(board))) return services.LegacyQueryBoardHome(uint(parseUintOrZero(board)))
} }
return "/" return "/"
} }

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"fmt" "fmt"
@@ -6,16 +6,16 @@ import (
"strings" "strings"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/embed_static" "git.iioio.com/freefire/jiang13-forum/modules/seo"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// 爬虫专用伪静态 HTML无 SPA仅 User-Agent 命中爬虫时返回,避免用户刷新闪屏) // 爬虫专用伪静态 HTML无 SPA仅 User-Agent 命中爬虫时返回,避免用户刷新闪屏)
func renderBotHTML(meta *embed_static.SPAPageMeta, bodyInner string) string { func renderBotHTML(meta *seo.PageMeta, bodyInner string) string {
if meta == nil { if meta == nil {
meta = &embed_static.SPAPageMeta{} meta = &seo.PageMeta{}
} }
ogType := strings.TrimSpace(meta.OGType) ogType := strings.TrimSpace(meta.OGType)
if ogType == "" { if ogType == "" {
@@ -87,7 +87,7 @@ func writeEscapedMeta(b *strings.Builder, attr, key, content string) {
b.WriteString("<meta " + attr + "=\"" + html.EscapeString(key) + "\" content=\"" + html.EscapeString(content) + "\"/>") b.WriteString("<meta " + attr + "=\"" + html.EscapeString(key) + "\" content=\"" + html.EscapeString(content) + "\"/>")
} }
func (h *Handlers) botBoardHTML(meta *embed_static.SPAPageMeta, board model.Board) string { func (h *Handlers) botBoardHTML(meta *seo.PageMeta, board models.Board) string {
desc := strings.TrimSpace(board.Description) desc := strings.TrimSpace(board.Description)
if desc == "" { if desc == "" {
desc = meta.Description desc = meta.Description
@@ -99,7 +99,7 @@ func (h *Handlers) botBoardHTML(meta *embed_static.SPAPageMeta, board model.Boar
return renderBotHTML(meta, body) return renderBotHTML(meta, body)
} }
func (h *Handlers) botHomeHTML(meta *embed_static.SPAPageMeta, brand service.SiteBranding) string { func (h *Handlers) botHomeHTML(meta *seo.PageMeta, brand services.SiteBranding) string {
name := strings.TrimSpace(brand.Name) name := strings.TrimSpace(brand.Name)
if name == "" { if name == "" {
name = "姜十三论坛" name = "姜十三论坛"
@@ -117,10 +117,10 @@ func (h *Handlers) botHomeHTML(meta *embed_static.SPAPageMeta, brand service.Sit
return renderBotHTML(meta, body.String()) return renderBotHTML(meta, body.String())
} }
func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *model.Post) string { func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *models.Post) string {
meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords) meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
content := service.SanitizePostHTML(service.RedactGatedPostHTML(post.Content)) content := services.SanitizePostHTML(services.RedactGatedPostHTML(post.Content))
author := service.DisplayName(&post.User) author := services.DisplayName(&post.User)
var body strings.Builder var body strings.Builder
body.WriteString("<article>") body.WriteString("<article>")
body.WriteString("<h1>" + html.EscapeString(post.Title) + "</h1>") body.WriteString("<h1>" + html.EscapeString(post.Title) + "</h1>")
@@ -138,9 +138,9 @@ func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, po
return renderBotHTML(meta, body.String()) return renderBotHTML(meta, body.String())
} }
func (h *Handlers) botUserHTML(base, siteName, defaultImage, keywords string, user *model.User) string { func (h *Handlers) botUserHTML(base, siteName, defaultImage, keywords string, user *models.User) string {
meta := attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, keywords) meta := attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, keywords)
name := service.DisplayName(user) name := services.DisplayName(user)
sig := strings.TrimSpace(user.Signature) sig := strings.TrimSpace(user.Signature)
var body strings.Builder var body strings.Builder
body.WriteString("<h1>" + html.EscapeString(name) + " 的主页</h1>") body.WriteString("<h1>" + html.EscapeString(name) + " 的主页</h1>")

View File

@@ -1,12 +1,12 @@
package handler package api
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// APIPages 已发布单页摘要列表 // APIPages 已发布单页摘要列表
@@ -17,7 +17,7 @@ func (h *Handlers) APIPages(c *gin.Context) {
return return
} }
if pages == nil { if pages == nil {
pages = []service.SitePageSummary{} pages = []services.SitePageSummary{}
} }
c.JSON(http.StatusOK, gin.H{"pages": pages}) c.JSON(http.StatusOK, gin.H{"pages": pages})
} }
@@ -57,14 +57,14 @@ func (h *Handlers) APIAdminPages(c *gin.Context) {
return return
} }
if pages == nil { if pages == nil {
pages = []model.SitePage{} pages = []models.SitePage{}
} }
c.JSON(http.StatusOK, gin.H{"pages": pages}) c.JSON(http.StatusOK, gin.H{"pages": pages})
} }
// APIAdminCreatePage 创建单页 // APIAdminCreatePage 创建单页
func (h *Handlers) APIAdminCreatePage(c *gin.Context) { func (h *Handlers) APIAdminCreatePage(c *gin.Context) {
var in service.SitePageInput var in services.SitePageInput
if err := c.ShouldBindJSON(&in); err != nil { if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
return return
@@ -80,7 +80,7 @@ func (h *Handlers) APIAdminCreatePage(c *gin.Context) {
// APIAdminUpdatePage 更新单页 // APIAdminUpdatePage 更新单页
func (h *Handlers) APIAdminUpdatePage(c *gin.Context) { func (h *Handlers) APIAdminUpdatePage(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var in service.SitePageInput var in services.SitePageInput
if err := c.ShouldBindJSON(&in); err != nil { if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
return return
@@ -137,11 +137,11 @@ func (h *Handlers) APIPollVote(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
return return
} }
if err := service.VotePoll(uint(id), h.currentUserID(c), body.OptionIDs); err != nil { if err := services.VotePoll(uint(id), h.currentUserID(c), body.OptionIDs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
poll, _ := service.GetPollView(uint(id), h.currentUserID(c)) poll, _ := services.GetPollView(uint(id), h.currentUserID(c))
c.JSON(http.StatusOK, gin.H{"message": "投票成功", "poll": poll}) c.JSON(http.StatusOK, gin.H{"message": "投票成功", "poll": poll})
} }
@@ -153,11 +153,11 @@ func (h *Handlers) APIPollClose(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return return
} }
if err := service.ClosePoll(uint(id), h.currentUserID(c), h.isAdmin(c), post.UserID); err != nil { if err := services.ClosePoll(uint(id), h.currentUserID(c), h.isAdmin(c), post.UserID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
poll, _ := service.GetPollView(uint(id), h.currentUserID(c)) poll, _ := services.GetPollView(uint(id), h.currentUserID(c))
c.JSON(http.StatusOK, gin.H{"message": "投票已结束", "poll": poll}) c.JSON(http.StatusOK, gin.H{"message": "投票已结束", "poll": poll})
} }
@@ -169,7 +169,7 @@ func (h *Handlers) APIBountyAward(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择评论"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请选择评论"})
return return
} }
if err := service.AwardBounty(uint(id), h.currentUserID(c), h.isAdmin(c), uint(commentID)); err != nil { if err := services.AwardBounty(uint(id), h.currentUserID(c), h.isAdmin(c), uint(commentID)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -179,7 +179,7 @@ func (h *Handlers) APIBountyAward(c *gin.Context) {
// APIBountyRefund 退回悬赏 // APIBountyRefund 退回悬赏
func (h *Handlers) APIBountyRefund(c *gin.Context) { func (h *Handlers) APIBountyRefund(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := service.RefundBounty(uint(id), h.currentUserID(c), h.isAdmin(c)); err != nil { if err := services.RefundBounty(uint(id), h.currentUserID(c), h.isAdmin(c)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -189,7 +189,7 @@ func (h *Handlers) APIBountyRefund(c *gin.Context) {
// APILotteryDraw 帖内抽奖开奖 // APILotteryDraw 帖内抽奖开奖
func (h *Handlers) APILotteryDraw(c *gin.Context) { func (h *Handlers) APILotteryDraw(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
view, err := service.DrawPostLottery(uint(id), h.currentUserID(c), h.isAdmin(c)) view, err := services.DrawPostLottery(uint(id), h.currentUserID(c), h.isAdmin(c))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return

View File

@@ -1,4 +1,4 @@
package handler package api
import ( import (
"net/http" "net/http"
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
) )
// ServeImageThumb 帖子图片缩略图(按需生成并缓存) // ServeImageThumb 帖子图片缩略图(按需生成并缓存)
@@ -15,7 +15,7 @@ import (
func (h *Handlers) ServeImageThumb(c *gin.Context) { func (h *Handlers) ServeImageThumb(c *gin.Context) {
rel := strings.TrimPrefix(c.Param("filepath"), "/") rel := strings.TrimPrefix(c.Param("filepath"), "/")
uploadsRoot := filepath.Join(h.Cfg.DataDir, "uploads") uploadsRoot := filepath.Join(h.Cfg.DataDir, "uploads")
thumbPath, err := service.EnsureUploadThumb(uploadsRoot, rel) thumbPath, err := services.EnsureUploadThumb(uploadsRoot, rel)
if err != nil { if err != nil {
// 生成失败时回退原图,避免正文裂图 // 生成失败时回退原图,避免正文裂图
orig := filepath.Join(uploadsRoot, filepath.FromSlash(rel)) orig := filepath.Join(uploadsRoot, filepath.FromSlash(rel))

View File

@@ -1,7 +1,6 @@
package router package routers
import ( import (
"encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http" "net/http"
@@ -9,12 +8,11 @@ import (
"path/filepath" "path/filepath"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/embed_static" "git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/handler"
"git.iioio.com/freefire/jiang13-forum/middleware"
webpublic "git.iioio.com/freefire/jiang13-forum/public" webpublic "git.iioio.com/freefire/jiang13-forum/public"
"git.iioio.com/freefire/jiang13-forum/routers/api"
webpages "git.iioio.com/freefire/jiang13-forum/routers/web" webpages "git.iioio.com/freefire/jiang13-forum/routers/web"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -24,7 +22,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery()) r.Use(gin.Recovery())
r.Use(gin.Logger()) r.Use(gin.Logger())
// SSR 静态资源(独立前缀,避免与 SPA /assets/* 冲突) // SSR 静态资源
if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil { if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil {
ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub))) ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub)))
r.GET("/ssr-assets/*filepath", func(c *gin.Context) { r.GET("/ssr-assets/*filepath", func(c *gin.Context) {
@@ -32,59 +30,42 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
ssrFiles.ServeHTTP(c.Writer, c.Request) ssrFiles.ServeHTTP(c.Writer, c.Request)
}) })
} }
if cfg.DevMode {
// 生产:内嵌 React SPA未迁移路径仍走 SPAdevVite 可对照旧前台 fmt.Fprintf(os.Stderr, "[dev] SSR 请访问 http://localhost:%d (对照 SPA 请 checkout main\n", cfg.Port)
if !cfg.DevMode {
if err := embed_static.SetupEmbed(r); err != nil {
return nil, err
}
} else {
fmt.Fprintf(os.Stderr, "[dev] SSR 页面请访问 http://localhost:3000 ;旧 SPA 对照可用 Vite :5173\n")
} }
filter := service.NewSensitiveFilter() filter := services.NewSensitiveFilter()
_ = service.WriteDefaultFilterWords(cfg.FilterWordsPath()) _ = services.WriteDefaultFilterWords(cfg.FilterWordsPath())
filter.LoadFromFile(cfg.FilterWordsPath()) filter.LoadFromFile(cfg.FilterWordsPath())
settingsSvc := service.NewForumSettingsService() settingsSvc := services.NewForumSettingsService()
// SPA 入口 HTML 注入标题与品牌 JSON避免刷新时先闪默认文案 authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
embed_static.SetSPADocumentTitle(func() string { userSvc := services.NewUserService(filter, settingsSvc)
return settingsSvc.SiteBranding().DocumentTitle() boardSvc := services.NewBoardService()
})
embed_static.SetSPABrandingJSON(func() []byte {
b, err := json.Marshal(settingsSvc.SiteBranding())
if err != nil {
return nil
}
return b
})
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := service.NewUserService(filter, settingsSvc)
boardSvc := service.NewBoardService()
boardSvc.EnsureDefaultBoard() boardSvc.EnsureDefaultBoard()
postSvc := service.NewPostService(filter, settingsSvc) postSvc := services.NewPostService(filter, settingsSvc)
commentSvc := service.NewCommentService(filter, settingsSvc) commentSvc := services.NewCommentService(filter, settingsSvc)
messageSvc := service.NewMessageService(filter, settingsSvc) messageSvc := services.NewMessageService(filter, settingsSvc)
reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc) reportSvc := services.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir) backupSvc := services.NewBackupService(cfg.DBPath(), cfg.DataDir)
limiter := service.NewRateLimiter(settingsSvc) limiter := services.NewRateLimiter(settingsSvc)
captchaSvc := service.NewCaptchaService() captchaSvc := services.NewCaptchaService()
mailSvc := service.NewMailService(settingsSvc) mailSvc := services.NewMailService(settingsSvc)
emailCodeSvc := service.NewEmailCodeService(mailSvc) emailCodeSvc := services.NewEmailCodeService(mailSvc)
notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc) notifySvc := services.NewNotifyService(messageSvc, mailSvc, settingsSvc)
friendLinkApplySvc := service.NewFriendLinkApplyService(settingsSvc, messageSvc) friendLinkApplySvc := services.NewFriendLinkApplyService(settingsSvc, messageSvc)
oidcSvc, err := service.NewOIDCService(cfg, settingsSvc) oidcSvc, err := services.NewOIDCService(cfg, settingsSvc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
giteaSvc := service.NewGiteaService(settingsSvc) giteaSvc := services.NewGiteaService(settingsSvc)
giteaSvc.StartBackgroundSync() giteaSvc.StartBackgroundSync()
uploadStore := service.NewUploadStore(cfg.DataDir, settingsSvc) uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc)
if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil { if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil {
// 配置不完整时保持本地磁盘,避免进程无法启动;管理员可在后台修正后热切换 // 配置不完整时保持本地磁盘,避免进程无法启动;管理员可在后台修正后热切换
fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err) fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err)
_ = uploadStore.Apply(service.StorageConfig{Type: "local"}) _ = uploadStore.Apply(services.StorageConfig{Type: "local"})
} }
// 后台同步存量文件到媒体索引,避免列表依赖实时扫盘 // 后台同步存量文件到媒体索引,避免列表依赖实时扫盘
go func() { go func() {
@@ -95,18 +76,18 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
} }
}() }()
h := &handler.Handlers{ h := &api.Handlers{
Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc, Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc,
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc, Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
Backup: backupSvc, Backup: backupSvc,
Filter: filter, Limiter: limiter, Settings: settingsSvc, Filter: filter, Limiter: limiter, Settings: settingsSvc,
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
OIDC: oidcSvc, Gitea: giteaSvc, OIDC: oidcSvc, Gitea: giteaSvc,
Points: service.NewPointsService(), Badge: service.NewBadgeService(), Points: services.NewPointsService(), Badge: services.NewBadgeService(),
SitePage: service.NewSitePageService(filter), SitePage: services.NewSitePageService(filter),
FriendLinkApply: friendLinkApplySvc, FriendLinkApply: friendLinkApplySvc,
} }
authMW := middleware.NewAuthMiddleware(authSvc) authMW := auth.NewAuthMiddleware(authSvc)
// Gitea 式 SSR 公开页(优先于 SPA // Gitea 式 SSR 公开页(优先于 SPA
webpages.Register(r, webpages.Deps{ webpages.Register(r, webpages.Deps{
@@ -148,9 +129,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/pages/:slug", h.APIPageDetail) pubAPI.GET("/pages/:slug", h.APIPageDetail)
pubAPI.GET("/captcha", h.APICaptcha) pubAPI.GET("/captcha", h.APICaptcha)
pubAPI.GET("/register/config", h.APIRegisterConfig) pubAPI.GET("/register/config", h.APIRegisterConfig)
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode) pubAPI.POST("/register/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
pubAPI.POST("/password-reset/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode) pubAPI.POST("/password-reset/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
pubAPI.POST("/password-reset", middleware.RateLimitMiddleware(limiter, "login"), h.APIResetPassword) pubAPI.POST("/password-reset", auth.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
pubAPI.GET("/posts", h.APIPosts) pubAPI.GET("/posts", h.APIPosts)
pubAPI.GET("/posts/hot", h.APIHotPosts) pubAPI.GET("/posts/hot", h.APIHotPosts)
pubAPI.GET("/tags", h.APITags) pubAPI.GET("/tags", h.APITags)
@@ -161,10 +142,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/users/:id", h.APIUserPublic) pubAPI.GET("/users/:id", h.APIUserPublic)
pubAPI.GET("/posts/:id", h.APIPostDetail) pubAPI.GET("/posts/:id", h.APIPostDetail)
pubAPI.GET("/posts/:id/comments", h.APIPostComments) pubAPI.GET("/posts/:id/comments", h.APIPostComments)
pubAPI.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, "comment"), h.APICreateComment) pubAPI.POST("/posts/:id/comments", auth.RateLimitMiddleware(limiter, "comment"), h.APICreateComment)
pubAPI.GET("/projects", h.APIProjects) pubAPI.GET("/projects", h.APIProjects)
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, "register"), h.APIRegister) pubAPI.POST("/register", auth.RateLimitMiddleware(limiter, "register"), h.APIRegister)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, "login"), h.APILogin) pubAPI.POST("/login", auth.RateLimitMiddleware(limiter, "login"), h.APILogin)
} }
// 需登录 API // 需登录 API
@@ -178,7 +159,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/profile/password", h.APIUpdatePassword) api.POST("/profile/password", h.APIUpdatePassword)
api.POST("/profile/avatar", h.APIUploadAvatar) api.POST("/profile/avatar", h.APIUploadAvatar)
api.POST("/uploads/image", h.APIUploadPostImage) api.POST("/uploads/image", h.APIUploadPostImage)
api.POST("/posts", middleware.RateLimitMiddleware(limiter, "post"), h.APICreatePost) api.POST("/posts", auth.RateLimitMiddleware(limiter, "post"), h.APICreatePost)
api.PUT("/posts/:id", h.APIUpdatePost) api.PUT("/posts/:id", h.APIUpdatePost)
api.DELETE("/posts/:id", h.APIDeletePost) api.DELETE("/posts/:id", h.APIDeletePost)
api.GET("/posts/:id/revisions", h.APIPostRevisions) api.GET("/posts/:id/revisions", h.APIPostRevisions)
@@ -191,17 +172,17 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/posts/:id/bounty/award", h.APIBountyAward) api.POST("/posts/:id/bounty/award", h.APIBountyAward)
api.POST("/posts/:id/bounty/refund", h.APIBountyRefund) api.POST("/posts/:id/bounty/refund", h.APIBountyRefund)
api.POST("/posts/:id/lottery/draw", h.APILotteryDraw) api.POST("/posts/:id/lottery/draw", h.APILotteryDraw)
api.POST("/posts/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport) api.POST("/posts/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
api.GET("/messages/unread-count", h.APIMessageUnreadCount) api.GET("/messages/unread-count", h.APIMessageUnreadCount)
api.GET("/messages/notifications", h.APIMessageNotifications) api.GET("/messages/notifications", h.APIMessageNotifications)
api.POST("/messages/notifications/read", h.APIMarkNotificationsRead) api.POST("/messages/notifications/read", h.APIMarkNotificationsRead)
api.GET("/messages/conversations", h.APIMessageConversations) api.GET("/messages/conversations", h.APIMessageConversations)
api.GET("/messages/conversations/:peerId", h.APIConversationMessages) api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead) api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
api.POST("/messages", middleware.RateLimitMiddleware(limiter, "message"), h.APISendMessage) api.POST("/messages", auth.RateLimitMiddleware(limiter, "message"), h.APISendMessage)
api.POST("/messages/read-all", h.APIMarkAllMessagesRead) api.POST("/messages/read-all", h.APIMarkAllMessagesRead)
api.POST("/comments/:id/like", h.APIToggleCommentLike) api.POST("/comments/:id/like", h.APIToggleCommentLike)
api.POST("/comments/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport) api.POST("/comments/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport)
api.DELETE("/comments/:id", h.APIDeleteComment) api.DELETE("/comments/:id", h.APIDeleteComment)
api.PUT("/comments/:id", h.APIUpdateComment) api.PUT("/comments/:id", h.APIUpdateComment)
api.GET("/me/points", h.APIMePoints) api.GET("/me/points", h.APIMePoints)
@@ -209,11 +190,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/me/check-in", h.APIMeCheckIn) api.POST("/me/check-in", h.APIMeCheckIn)
api.GET("/me/lottery", h.APIMeLotteryGet) api.GET("/me/lottery", h.APIMeLotteryGet)
api.POST("/me/lottery", h.APIMeLotteryDraw) api.POST("/me/lottery", h.APIMeLotteryDraw)
api.POST("/posts/:id/unlock", middleware.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock) api.POST("/posts/:id/unlock", auth.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
api.POST("/friend-links/apply", middleware.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink) api.POST("/friend-links/apply", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink)
api.POST("/friend-links/logo", middleware.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo) api.POST("/friend-links/logo", auth.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo)
api.GET("/friend-links/my-applies", h.APIMyFriendLinkApplies) api.GET("/friend-links/my-applies", h.APIMyFriendLinkApplies)
api.PUT("/friend-links/applies/:id", middleware.RateLimitMiddleware(limiter, "friend_link"), h.APIUpdateFriendLinkApply) api.PUT("/friend-links/applies/:id", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIUpdateFriendLinkApply)
api.DELETE("/friend-links/applies/:id", h.APICancelFriendLinkApply) api.DELETE("/friend-links/applies/:id", h.APICancelFriendLinkApply)
} }
@@ -288,41 +269,27 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup) adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
} }
// 后台管理页面由 React SPA 渲染JSON API 见上方 /api/admin // 管理后台 HTMLSSR 尚未迁移;勿用 /*filepath与 /admin/login 冲突
// dev 模式下前端由 Vite 提供,后台页面路由不在此注册 adminPendingHTML := `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><title>管理后台</title></head><body><h1>管理后台 SSR 迁移中</h1><p>API 仍可用UI 请暂时对照 <code>main</code> 分支 SPA或等待后续模板页。</p><p><a href="/">返回首页</a></p></body></html>`
if !cfg.DevMode { adminPending := func(c *gin.Context) {
admin := r.Group("/admin") c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, adminPendingHTML)
}
admin := r.Group("/admin")
{
admin.GET("/login", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
})
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
{ {
admin.GET("/login", func(c *gin.Context) { adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
c.Redirect(http.StatusFound, "/login") adminAuth.GET("/dashboard", adminPending)
}) adminAuth.GET("/:page", adminPending)
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
{
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
for _, page := range []string{"dashboard", "boards", "pages", "links", "posts", "comments", "reports", "users", "badges", "media", "settings"} {
adminAuth.GET("/"+page, embed_static.ServeSPANoIndex)
}
}
} }
} }
// 未迁移路径:生产仍回落 React SPA/ 与 /board/:id 已由 routers/web 接管 // 未迁移公开路径:爬虫可读 HTML / 用户占位(首页与板块已由 routers/web 接管
if cfg.DevMode { r.NoRoute(h.ServePublicSPA)
r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{
"error": "页面未找到。SSR 首页请打开 / ;旧 SPA 请用 Vite http://localhost:5173",
})
})
} else {
r.NoRoute(func(c *gin.Context) {
if embed_static.IsSPARoute(c.Request.URL.Path) {
h.ServePublicSPA(c)
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
})
}
return r, nil return r, nil
} }

View File

@@ -1,4 +1,4 @@
package web package web
import ( import (
"net/http" "net/http"
@@ -6,18 +6,18 @@ import (
"strings" "strings"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webrender" "git.iioio.com/freefire/jiang13-forum/modules/webrender"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// Deps 页面路由依赖(复用现有 service避免 Phase 1 大搬家) // Deps 页面路由依赖(复用现有 service避免 Phase 1 大搬家)
type Deps struct { type Deps struct {
Settings *service.ForumSettingsService Settings *services.ForumSettingsService
Board *service.BoardService Board *services.BoardService
Post *service.PostService Post *services.PostService
} }
// BoardView 侧栏板块 // BoardView 侧栏板块
@@ -61,7 +61,7 @@ type HomePageData struct {
} }
// Register 注册已迁移的 SSR 页面(优先于 SPA // Register 注册已迁移的 SSR 页面(优先于 SPA
func Register(r *gin.Engine, deps Deps, authMW *middleware.AuthMiddleware) { func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g := r.Group("/", authMW.OptionalAuth()) g := r.Group("/", authMW.OptionalAuth())
g.GET("/", deps.Home) g.GET("/", deps.Home)
g.GET("/board/:id", deps.Home) g.GET("/board/:id", deps.Home)
@@ -99,21 +99,21 @@ func (d Deps) Home(c *gin.Context) {
} }
var uid uint var uid uint
if v, ok := c.Get(middleware.CtxUserID); ok { if v, ok := c.Get(auth.CtxUserID); ok {
uid, _ = v.(uint) uid, _ = v.(uint)
} }
isAdmin := false isAdmin := false
if v, ok := c.Get(middleware.CtxRole); ok { if v, ok := c.Get(auth.CtxRole); ok {
switch r := v.(type) { switch r := v.(type) {
case model.Role: case models.Role:
isAdmin = r == model.RoleAdmin isAdmin = r == models.RoleAdmin
case string: case string:
isAdmin = r == string(model.RoleAdmin) isAdmin = r == string(models.RoleAdmin)
} }
} }
username, _ := c.Get(middleware.CtxUsername) username, _ := c.Get(auth.CtxUsername)
q := service.PostListQuery{ q := services.PostListQuery{
BoardID: boardID, BoardID: boardID,
Page: page, Page: page,
Size: size, Size: size,

View File

@@ -1,4 +1,4 @@
package service package services
import "testing" import "testing"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"
@@ -6,7 +6,7 @@ import (
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
// 最近访问写入节流,避免每次 API 都打库 // 最近访问写入节流,避免每次 API 都打库
@@ -19,7 +19,7 @@ const TokenExpire = 7 * 24 * time.Hour
type Claims struct { type Claims struct {
UserID uint `json:"user_id"` UserID uint `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Role model.Role `json:"role"` Role models.Role `json:"role"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -36,12 +36,12 @@ func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSe
// UserCount 当前用户数 // UserCount 当前用户数
func (s *AuthService) UserCount() int64 { func (s *AuthService) UserCount() int64 {
var n int64 var n int64
model.DB.Model(&model.User{}).Count(&n) models.DB.Model(&models.User{}).Count(&n)
return n return n
} }
// Register 用户注册 // Register 用户注册
func (s *AuthService) Register(username, password, nickname, email string) (*model.User, error) { func (s *AuthService) Register(username, password, nickname, email string) (*models.User, error) {
if err := ValidateUsername(username); err != nil { if err := ValidateUsername(username); err != nil {
return nil, err return nil, err
} }
@@ -53,11 +53,11 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod
return nil, err return nil, err
} }
var exist model.User var exist models.User
if err := model.DB.Where("username = ?", username).First(&exist).Error; err == nil { if err := models.DB.Where("username = ?", username).First(&exist).Error; err == nil {
return nil, ErrUserExists return nil, ErrUserExists
} }
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil { if err := models.DB.Where("email = ?", email).First(&exist).Error; err == nil {
return nil, ErrEmailExists return nil, ErrEmailExists
} }
@@ -71,28 +71,28 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod
nickname = s.filter.Filter(nickname) nickname = s.filter.Filter(nickname)
// 首个注册用户自动成为管理员 // 首个注册用户自动成为管理员
role := model.RoleUser role := models.RoleUser
if s.UserCount() == 0 { if s.UserCount() == 0 {
role = model.RoleAdmin role = models.RoleAdmin
} }
user := &model.User{ user := &models.User{
Username: username, Username: username,
Email: email, Email: email,
Password: hash, Password: hash,
Nickname: nickname, Nickname: nickname,
Role: role, Role: role,
} }
if err := model.DB.Create(user).Error; err != nil { if err := models.DB.Create(user).Error; err != nil {
return nil, err return nil, err
} }
return user, nil return user, nil
} }
// Login 用户登录,返回 JWT tokenclientIP 写入上次登录记录 // Login 用户登录,返回 JWT tokenclientIP 写入上次登录记录
func (s *AuthService) Login(username, password, clientIP string) (string, *model.User, error) { func (s *AuthService) Login(username, password, clientIP string) (string, *models.User, error) {
var user model.User var user models.User
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil { if err := models.DB.Where("username = ?", username).First(&user).Error; err != nil {
return "", nil, ErrInvalidCred return "", nil, ErrInvalidCred
} }
if user.Banned { if user.Banned {
@@ -107,13 +107,13 @@ func (s *AuthService) Login(username, password, clientIP string) (string, *model
} }
// recordLogin 记录上次登录时间与 IP登录同时视为一次访问失败不影响登录 // recordLogin 记录上次登录时间与 IP登录同时视为一次访问失败不影响登录
func (s *AuthService) recordLogin(user *model.User, clientIP string) { func (s *AuthService) recordLogin(user *models.User, clientIP string) {
now := time.Now() now := time.Now()
ip := clientIP ip := clientIP
if len(ip) > 45 { if len(ip) > 45 {
ip = ip[:45] ip = ip[:45]
} }
_ = model.DB.Model(user).Updates(map[string]interface{}{ _ = models.DB.Model(user).Updates(map[string]interface{}{
"last_login_at": now, "last_login_at": now,
"last_login_ip": ip, "last_login_ip": ip,
"last_access_at": now, "last_access_at": now,
@@ -136,11 +136,11 @@ func (s *AuthService) TouchLastAccess(userID uint) {
} }
} }
lastAccessTouchCache.Store(userID, now) lastAccessTouchCache.Store(userID, now)
_ = model.DB.Model(&model.User{}).Where("id = ?", userID).Update("last_access_at", now).Error _ = models.DB.Model(&models.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
} }
// GenerateToken 生成 JWT // GenerateToken 生成 JWT
func (s *AuthService) GenerateToken(user *model.User) (string, error) { func (s *AuthService) GenerateToken(user *models.User) (string, error) {
claims := Claims{ claims := Claims{
UserID: user.ID, UserID: user.ID,
Username: user.Username, Username: user.Username,

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"fmt" "fmt"

View File

@@ -1,10 +1,10 @@
package service package services
import ( import (
"errors" "errors"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -14,34 +14,34 @@ type BadgeService struct{}
func NewBadgeService() *BadgeService { return &BadgeService{} } func NewBadgeService() *BadgeService { return &BadgeService{} }
// ListDefs 列出徽章定义 // ListDefs 列出徽章定义
func (s *BadgeService) ListDefs(includeDisabled bool) ([]model.BadgeDef, error) { func (s *BadgeService) ListDefs(includeDisabled bool) ([]models.BadgeDef, error) {
q := model.DB.Order("sort_order asc, id asc") q := models.DB.Order("sort_order asc, id asc")
if !includeDisabled { if !includeDisabled {
q = q.Where("enabled = ?", true) q = q.Where("enabled = ?", true)
} }
var rows []model.BadgeDef var rows []models.BadgeDef
err := q.Find(&rows).Error err := q.Find(&rows).Error
return rows, err return rows, err
} }
// UpsertDef 创建或更新徽章定义(按 code // UpsertDef 创建或更新徽章定义(按 code
func (s *BadgeService) UpsertDef(def *model.BadgeDef) error { func (s *BadgeService) UpsertDef(def *models.BadgeDef) error {
if def.Code == "" || def.Name == "" { if def.Code == "" || def.Name == "" {
return errors.New("徽章代码与名称不能为空") return errors.New("徽章代码与名称不能为空")
} }
if def.Kind != model.BadgeKindAuto && def.Kind != model.BadgeKindLimited { if def.Kind != models.BadgeKindAuto && def.Kind != models.BadgeKindLimited {
return errors.New("无效的徽章类型") return errors.New("无效的徽章类型")
} }
var existing model.BadgeDef var existing models.BadgeDef
err := model.DB.Where("code = ?", def.Code).Limit(1).Find(&existing).Error err := models.DB.Where("code = ?", def.Code).Limit(1).Find(&existing).Error
if err != nil { if err != nil {
return err return err
} }
if existing.ID == 0 { if existing.ID == 0 {
return model.DB.Create(def).Error return models.DB.Create(def).Error
} }
def.ID = existing.ID def.ID = existing.ID
return model.DB.Model(&existing).Updates(map[string]interface{}{ return models.DB.Model(&existing).Updates(map[string]interface{}{
"name": def.Name, "name": def.Name,
"description": def.Description, "description": def.Description,
"icon": def.Icon, "icon": def.Icon,
@@ -55,22 +55,22 @@ func (s *BadgeService) UpsertDef(def *model.BadgeDef) error {
// AwardLimited 站长颁发限定徽章 // AwardLimited 站长颁发限定徽章
func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error { func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error {
var def model.BadgeDef var def models.BadgeDef
if err := model.DB.First(&def, badgeID).Error; err != nil { if err := models.DB.First(&def, badgeID).Error; err != nil {
return errors.New("徽章不存在") return errors.New("徽章不存在")
} }
if def.Kind != model.BadgeKindLimited { if def.Kind != models.BadgeKindLimited {
return errors.New("仅可颁发限定徽章") return errors.New("仅可颁发限定徽章")
} }
if !def.Enabled { if !def.Enabled {
return errors.New("徽章已停用") return errors.New("徽章已停用")
} }
var n int64 var n int64
model.DB.Model(&model.UserBadge{}).Where("user_id = ? AND badge_id = ?", userID, badgeID).Count(&n) models.DB.Model(&models.UserBadge{}).Where("user_id = ? AND badge_id = ?", userID, badgeID).Count(&n)
if n > 0 { if n > 0 {
return errors.New("用户已拥有该徽章") return errors.New("用户已拥有该徽章")
} }
return model.DB.Create(&model.UserBadge{ return models.DB.Create(&models.UserBadge{
UserID: userID, UserID: userID,
BadgeID: badgeID, BadgeID: badgeID,
AwardedAt: time.Now(), AwardedAt: time.Now(),
@@ -80,7 +80,7 @@ func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error {
// Revoke 收回徽章 // Revoke 收回徽章
func (s *BadgeService) Revoke(userID, badgeID uint) error { func (s *BadgeService) Revoke(userID, badgeID uint) error {
res := model.DB.Where("user_id = ? AND badge_id = ?", userID, badgeID).Delete(&model.UserBadge{}) res := models.DB.Where("user_id = ? AND badge_id = ?", userID, badgeID).Delete(&models.UserBadge{})
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -91,21 +91,21 @@ func (s *BadgeService) Revoke(userID, badgeID uint) error {
} }
// ListUserBadges 用户已获徽章(含定义) // ListUserBadges 用户已获徽章(含定义)
func (s *BadgeService) ListUserBadges(userID uint) ([]model.UserBadge, error) { func (s *BadgeService) ListUserBadges(userID uint) ([]models.UserBadge, error) {
var rows []model.UserBadge var rows []models.UserBadge
err := model.DB.Preload("Badge").Where("user_id = ?", userID). err := models.DB.Preload("Badge").Where("user_id = ?", userID).
Order("awarded_at desc").Find(&rows).Error Order("awarded_at desc").Find(&rows).Error
return rows, err return rows, err
} }
// BadgeViews 转为展示视图(最多 limit 枚0=全部) // BadgeViews 转为展示视图(最多 limit 枚0=全部)
func BadgeViews(rows []model.UserBadge, limit int) []model.UserBadgeView { func BadgeViews(rows []models.UserBadge, limit int) []models.UserBadgeView {
out := make([]model.UserBadgeView, 0, len(rows)) out := make([]models.UserBadgeView, 0, len(rows))
for _, r := range rows { for _, r := range rows {
if r.Badge.ID == 0 || !r.Badge.Enabled { if r.Badge.ID == 0 || !r.Badge.Enabled {
continue continue
} }
out = append(out, model.UserBadgeView{ out = append(out, models.UserBadgeView{
Code: r.Badge.Code, Code: r.Badge.Code,
Name: r.Badge.Name, Name: r.Badge.Name,
Description: r.Badge.Description, Description: r.Badge.Description,
@@ -121,25 +121,25 @@ func BadgeViews(rows []model.UserBadge, limit int) []model.UserBadgeView {
// EvaluateAuto 检查并授予符合条件的自动徽章 // EvaluateAuto 检查并授予符合条件的自动徽章
func (s *BadgeService) EvaluateAuto(userID uint) error { func (s *BadgeService) EvaluateAuto(userID uint) error {
var user model.User var user models.User
if err := model.DB.First(&user, userID).Error; err != nil { if err := models.DB.First(&user, userID).Error; err != nil {
return err return err
} }
var defs []model.BadgeDef var defs []models.BadgeDef
if err := model.DB.Where("kind = ? AND enabled = ?", model.BadgeKindAuto, true).Find(&defs).Error; err != nil { if err := models.DB.Where("kind = ? AND enabled = ?", models.BadgeKindAuto, true).Find(&defs).Error; err != nil {
return err return err
} }
tenureDays := int(time.Since(user.CreatedAt).Hours() / 24) tenureDays := int(time.Since(user.CreatedAt).Hours() / 24)
var likes int64 var likes int64
_ = model.DB.Model(&model.Post{}). _ = models.DB.Model(&models.Post{}).
Select("COALESCE(SUM(like_count), 0)"). Select("COALESCE(SUM(like_count), 0)").
Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished). Where("user_id = ? AND status = ?", userID, models.ContentStatusPublished).
Scan(&likes).Error Scan(&likes).Error
income := user.CreatorIncomeTotal income := user.CreatorIncomeTotal
owned := map[uint]bool{} owned := map[uint]bool{}
var existing []model.UserBadge var existing []models.UserBadge
_ = model.DB.Where("user_id = ?", userID).Find(&existing).Error _ = models.DB.Where("user_id = ?", userID).Find(&existing).Error
for _, e := range existing { for _, e := range existing {
owned[e.BadgeID] = true owned[e.BadgeID] = true
} }
@@ -150,17 +150,17 @@ func (s *BadgeService) EvaluateAuto(userID uint) error {
} }
ok := false ok := false
switch d.Metric { switch d.Metric {
case model.BadgeMetricTenureDays: case models.BadgeMetricTenureDays:
ok = tenureDays >= d.Threshold ok = tenureDays >= d.Threshold
case model.BadgeMetricLikesReceived: case models.BadgeMetricLikesReceived:
ok = int(likes) >= d.Threshold ok = int(likes) >= d.Threshold
case model.BadgeMetricCreatorIncome: case models.BadgeMetricCreatorIncome:
ok = income >= d.Threshold ok = income >= d.Threshold
} }
if !ok { if !ok {
continue continue
} }
_ = model.DB.Create(&model.UserBadge{ _ = models.DB.Create(&models.UserBadge{
UserID: userID, UserID: userID,
BadgeID: d.ID, BadgeID: d.ID,
AwardedAt: time.Now(), AwardedAt: time.Now(),
@@ -171,7 +171,7 @@ func (s *BadgeService) EvaluateAuto(userID uint) error {
} }
// AttachBadgeSummaries 批量为用户填充展示用徽章(最多 perUser 枚) // AttachBadgeSummaries 批量为用户填充展示用徽章(最多 perUser 枚)
func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) { func (s *BadgeService) AttachBadgeSummaries(users []*models.User, perUser int) {
if len(users) == 0 { if len(users) == 0 {
return return
} }
@@ -184,7 +184,7 @@ func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) {
if u == nil || u.ID == 0 { if u == nil || u.ID == 0 {
continue continue
} }
u.Level = model.LevelFromExp(u.Exp) u.Level = models.LevelFromExp(u.Exp)
if !seen[u.ID] { if !seen[u.ID] {
seen[u.ID] = true seen[u.ID] = true
ids = append(ids, u.ID) ids = append(ids, u.ID)
@@ -193,10 +193,10 @@ func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) {
if len(ids) == 0 { if len(ids) == 0 {
return return
} }
var rows []model.UserBadge var rows []models.UserBadge
_ = model.DB.Preload("Badge").Where("user_id IN ?", ids). _ = models.DB.Preload("Badge").Where("user_id IN ?", ids).
Order("awarded_at desc").Find(&rows).Error Order("awarded_at desc").Find(&rows).Error
grouped := map[uint][]model.UserBadgeView{} grouped := map[uint][]models.UserBadgeView{}
for _, r := range rows { for _, r := range rows {
if r.Badge.ID == 0 || !r.Badge.Enabled { if r.Badge.ID == 0 || !r.Badge.Enabled {
continue continue
@@ -205,7 +205,7 @@ func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) {
if len(list) >= perUser { if len(list) >= perUser {
continue continue
} }
list = append(list, model.UserBadgeView{ list = append(list, models.UserBadgeView{
Code: r.Badge.Code, Code: r.Badge.Code,
Name: r.Badge.Name, Name: r.Badge.Name,
Description: r.Badge.Description, Description: r.Badge.Description,
@@ -223,8 +223,8 @@ func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) {
} }
// AttachBadgeSummariesOnPosts 给帖子作者填充徽章摘要 // AttachBadgeSummariesOnPosts 给帖子作者填充徽章摘要
func (s *BadgeService) AttachBadgeSummariesOnPosts(posts []model.Post, perUser int) { func (s *BadgeService) AttachBadgeSummariesOnPosts(posts []models.Post, perUser int) {
users := make([]*model.User, 0, len(posts)) users := make([]*models.User, 0, len(posts))
for i := range posts { for i := range posts {
if posts[i].User.ID > 0 { if posts[i].User.ID > 0 {
users = append(users, &posts[i].User) users = append(users, &posts[i].User)
@@ -234,8 +234,8 @@ func (s *BadgeService) AttachBadgeSummariesOnPosts(posts []model.Post, perUser i
} }
// AttachBadgeSummariesOnComments 给评论作者填充徽章摘要 // AttachBadgeSummariesOnComments 给评论作者填充徽章摘要
func (s *BadgeService) AttachBadgeSummariesOnComments(comments []model.Comment, perUser int) { func (s *BadgeService) AttachBadgeSummariesOnComments(comments []models.Comment, perUser int) {
users := make([]*model.User, 0, len(comments)) users := make([]*models.User, 0, len(comments))
for i := range comments { for i := range comments {
if comments[i].User.ID > 0 { if comments[i].User.ID > 0 {
users = append(users, &comments[i].User) users = append(users, &comments[i].User)
@@ -249,24 +249,24 @@ func AddExp(userID uint, delta int) {
if userID == 0 || delta <= 0 { if userID == 0 || delta <= 0 {
return return
} }
_ = model.DB.Model(&model.User{}).Where("id = ?", userID). _ = models.DB.Model(&models.User{}).Where("id = ?", userID).
UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error
} }
// SetUserLevel 站长设等级(调整 Exp 到门槛) // SetUserLevel 站长设等级(调整 Exp 到门槛)
func SetUserLevel(userID uint, level int) error { func SetUserLevel(userID uint, level int) error {
if level < 1 || level > model.MaxLevel() { if level < 1 || level > models.MaxLevel() {
return errors.New("等级须在 110") return errors.New("等级须在 110")
} }
exp := model.ExpForLevel(level) exp := models.ExpForLevel(level)
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("exp", exp).Error return models.DB.Model(&models.User{}).Where("id = ?", userID).Update("exp", exp).Error
} }
// SetVerified 设置认证 // SetVerified 设置认证
func SetVerified(userID uint, verified bool) error { func SetVerified(userID uint, verified bool) error {
var user model.User var user models.User
if err := model.DB.First(&user, userID).Error; err != nil { if err := models.DB.First(&user, userID).Error; err != nil {
return errors.New("用户不存在") return errors.New("用户不存在")
} }
return model.DB.Model(&user).Update("verified", verified).Error return models.DB.Model(&user).Update("verified", verified).Error
} }

View File

@@ -1,9 +1,9 @@
package service package services
import ( import (
"errors" "errors"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
type BoardService struct{} type BoardService struct{}
@@ -14,13 +14,13 @@ func NewBoardService() *BoardService {
// BoardWithStats 板块及帖子数量 // BoardWithStats 板块及帖子数量
type BoardWithStats struct { type BoardWithStats struct {
model.Board models.Board
PostCount int `json:"post_count"` PostCount int `json:"post_count"`
} }
func (s *BoardService) List() ([]model.Board, error) { func (s *BoardService) List() ([]models.Board, error) {
var boards []model.Board var boards []models.Board
err := model.DB.Order("sort_order asc, id asc").Find(&boards).Error err := models.DB.Order("sort_order asc, id asc").Find(&boards).Error
return boards, err return boards, err
} }
@@ -32,34 +32,34 @@ func (s *BoardService) ListWithStats() ([]BoardWithStats, error) {
result := make([]BoardWithStats, len(boards)) result := make([]BoardWithStats, len(boards))
for i, b := range boards { for i, b := range boards {
var count int64 var count int64
model.DB.Model(&model.Post{}). models.DB.Model(&models.Post{}).
Where("board_id = ? AND status = ?", b.ID, model.ContentStatusPublished). Where("board_id = ? AND status = ?", b.ID, models.ContentStatusPublished).
Count(&count) Count(&count)
result[i] = BoardWithStats{Board: b, PostCount: int(count)} result[i] = BoardWithStats{Board: b, PostCount: int(count)}
} }
return result, nil return result, nil
} }
func (s *BoardService) GetByID(id uint) (*model.Board, error) { func (s *BoardService) GetByID(id uint) (*models.Board, error) {
var board model.Board var board models.Board
if err := model.DB.First(&board, id).Error; err != nil { if err := models.DB.First(&board, id).Error; err != nil {
return nil, ErrBoardNotFound return nil, ErrBoardNotFound
} }
return &board, nil return &board, nil
} }
func (s *BoardService) Create(name, desc, icon string, colorIndex, sortOrder int) (*model.Board, error) { func (s *BoardService) Create(name, desc, icon string, colorIndex, sortOrder int) (*models.Board, error) {
board := &model.Board{ board := &models.Board{
Name: name, Name: name,
Description: desc, Description: desc,
Icon: NormalizeBoardIcon(icon), Icon: NormalizeBoardIcon(icon),
ColorIndex: NormalizeBoardColorIndex(colorIndex), ColorIndex: NormalizeBoardColorIndex(colorIndex),
SortOrder: sortOrder, SortOrder: sortOrder,
} }
return board, model.DB.Create(board).Error return board, models.DB.Create(board).Error
} }
func (s *BoardService) Update(id uint, name, desc, icon string, colorIndex, sortOrder int) error { func (s *BoardService) Update(id uint, name, desc, icon string, colorIndex, sortOrder int) error {
return model.DB.Model(&model.Board{}).Where("id = ?", id).Updates(map[string]interface{}{ return models.DB.Model(&models.Board{}).Where("id = ?", id).Updates(map[string]interface{}{
"name": name, "name": name,
"description": desc, "description": desc,
"icon": NormalizeBoardIcon(icon), "icon": NormalizeBoardIcon(icon),
@@ -70,17 +70,17 @@ func (s *BoardService) Update(id uint, name, desc, icon string, colorIndex, sort
func (s *BoardService) Delete(id uint) error { func (s *BoardService) Delete(id uint) error {
var count int64 var count int64
model.DB.Model(&model.Post{}).Where("board_id = ?", id).Count(&count) models.DB.Model(&models.Post{}).Where("board_id = ?", id).Count(&count)
if count > 0 { if count > 0 {
return errors.New("该板块下还有帖子,无法删除") return errors.New("该板块下还有帖子,无法删除")
} }
return model.DB.Delete(&model.Board{}, id).Error return models.DB.Delete(&models.Board{}, id).Error
} }
// EnsureDefaultBoard 若尚无板块则创建默认「综合讨论」,便于全新安装后直接发帖 // EnsureDefaultBoard 若尚无板块则创建默认「综合讨论」,便于全新安装后直接发帖
func (s *BoardService) EnsureDefaultBoard() { func (s *BoardService) EnsureDefaultBoard() {
var n int64 var n int64
if err := model.DB.Model(&model.Board{}).Count(&n).Error; err != nil || n > 0 { if err := models.DB.Model(&models.Board{}).Count(&n).Error; err != nil || n > 0 {
return return
} }
_, _ = s.Create("综合讨论", "默认板块,欢迎发帖交流", "message-square", 0, 0) _, _ = s.Create("综合讨论", "默认板块,欢迎发帖交流", "message-square", 0, 0)

View File

@@ -1,4 +1,4 @@
package service package services
import "strings" import "strings"

View File

@@ -1,9 +1,9 @@
package service package services
import ( import (
"errors" "errors"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -19,27 +19,27 @@ const bountyRefundBlockReason = "已有用户回复,无法自行取消悬赏
// CountEligibleBountyReplies 统计他人已发布的有效回复数(不含楼主) // CountEligibleBountyReplies 统计他人已发布的有效回复数(不含楼主)
func CountEligibleBountyReplies(db *gorm.DB, postID, authorID uint) (int64, error) { func CountEligibleBountyReplies(db *gorm.DB, postID, authorID uint) (int64, error) {
if db == nil { if db == nil {
db = model.DB db = models.DB
} }
var n int64 var n int64
err := db.Model(&model.Comment{}). err := db.Model(&models.Comment{}).
Where("post_id = ? AND status = ? AND user_id != ?", postID, model.ContentStatusPublished, authorID). Where("post_id = ? AND status = ? AND user_id != ?", postID, models.ContentStatusPublished, authorID).
Count(&n).Error Count(&n).Error
return n, err return n, err
} }
// CanRefundBounty 当前查看者是否可取消悬赏(管理员始终可强制取消) // CanRefundBounty 当前查看者是否可取消悬赏(管理员始终可强制取消)
func CanRefundBounty(post *model.Post, viewerIsAdmin bool) (bool, string) { func CanRefundBounty(post *models.Post, viewerIsAdmin bool) (bool, string) {
if post == nil || post.PostType != model.PostTypeBounty { if post == nil || post.PostType != models.PostTypeBounty {
return false, "" return false, ""
} }
if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 { if post.BountyStatus != models.BountyStatusOpen || post.BountyPoints < 1 {
return false, "" return false, ""
} }
if viewerIsAdmin { if viewerIsAdmin {
return true, "" return true, ""
} }
n, err := CountEligibleBountyReplies(model.DB, post.ID, post.UserID) n, err := CountEligibleBountyReplies(models.DB, post.ID, post.UserID)
if err != nil { if err != nil {
return false, "" return false, ""
} }
@@ -54,42 +54,42 @@ func EscrowBounty(tx *gorm.DB, userID, postID uint, points int) error {
if points < 1 { if points < 1 {
return ErrBountyInvalidPoint return ErrBountyInvalidPoint
} }
_, err := AdjustPointsTx(tx, userID, -points, model.PointReasonBountyEscrow, "post", postID, "发布悬赏帖") _, err := AdjustPointsTx(tx, userID, -points, models.PointReasonBountyEscrow, "post", postID, "发布悬赏帖")
return err return err
} }
// AwardBounty 采纳评论并发放悬赏 // AwardBounty 采纳评论并发放悬赏
func AwardBounty(postID, operatorID uint, isAdmin bool, commentID uint) error { func AwardBounty(postID, operatorID uint, isAdmin bool, commentID uint) error {
var post model.Post var post models.Post
if err := model.DB.First(&post, postID).Error; err != nil { if err := models.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound return ErrPostNotFound
} }
if post.PostType != model.PostTypeBounty { if post.PostType != models.PostTypeBounty {
return errors.New("非悬赏帖") return errors.New("非悬赏帖")
} }
if !isAdmin && post.UserID != operatorID { if !isAdmin && post.UserID != operatorID {
return ErrPermissionDenied return ErrPermissionDenied
} }
if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 { if post.BountyStatus != models.BountyStatusOpen || post.BountyPoints < 1 {
return ErrBountyNotOpen return ErrBountyNotOpen
} }
var comment model.Comment var comment models.Comment
if err := model.DB.First(&comment, commentID).Error; err != nil { if err := models.DB.First(&comment, commentID).Error; err != nil {
return errors.New("评论不存在") return errors.New("评论不存在")
} }
if comment.PostID != postID || comment.Status != model.ContentStatusPublished { if comment.PostID != postID || comment.Status != models.ContentStatusPublished {
return errors.New("评论无效") return errors.New("评论无效")
} }
if comment.UserID == post.UserID { if comment.UserID == post.UserID {
return ErrBountySelfAward return ErrBountySelfAward
} }
points := post.BountyPoints points := post.BountyPoints
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
if _, err := AdjustPointsTx(tx, comment.UserID, points, model.PointReasonBountyAward, "post", postID, "悬赏采纳"); err != nil { if _, err := AdjustPointsTx(tx, comment.UserID, points, models.PointReasonBountyAward, "post", postID, "悬赏采纳"); err != nil {
return err return err
} }
return tx.Model(&post).Updates(map[string]interface{}{ return tx.Model(&post).Updates(map[string]interface{}{
"bounty_status": model.BountyStatusAwarded, "bounty_status": models.BountyStatusAwarded,
"bounty_comment_id": commentID, "bounty_comment_id": commentID,
}).Error }).Error
}) })
@@ -97,21 +97,21 @@ func AwardBounty(postID, operatorID uint, isAdmin bool, commentID uint) error {
// RefundBounty 取消悬赏并退回积分 // RefundBounty 取消悬赏并退回积分
func RefundBounty(postID, operatorID uint, isAdmin bool) error { func RefundBounty(postID, operatorID uint, isAdmin bool) error {
var post model.Post var post models.Post
if err := model.DB.First(&post, postID).Error; err != nil { if err := models.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound return ErrPostNotFound
} }
if post.PostType != model.PostTypeBounty { if post.PostType != models.PostTypeBounty {
return errors.New("非悬赏帖") return errors.New("非悬赏帖")
} }
if !isAdmin && post.UserID != operatorID { if !isAdmin && post.UserID != operatorID {
return ErrPermissionDenied return ErrPermissionDenied
} }
if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 { if post.BountyStatus != models.BountyStatusOpen || post.BountyPoints < 1 {
return ErrBountyNotOpen return ErrBountyNotOpen
} }
if !isAdmin { if !isAdmin {
n, err := CountEligibleBountyReplies(model.DB, post.ID, post.UserID) n, err := CountEligibleBountyReplies(models.DB, post.ID, post.UserID)
if err != nil { if err != nil {
return err return err
} }
@@ -120,31 +120,31 @@ func RefundBounty(postID, operatorID uint, isAdmin bool) error {
} }
} }
points := post.BountyPoints points := post.BountyPoints
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
if _, err := AdjustPointsTx(tx, post.UserID, points, model.PointReasonBountyRefund, "post", postID, "悬赏退回"); err != nil { if _, err := AdjustPointsTx(tx, post.UserID, points, models.PointReasonBountyRefund, "post", postID, "悬赏退回"); err != nil {
return err return err
} }
return tx.Model(&post).Updates(map[string]interface{}{ return tx.Model(&post).Updates(map[string]interface{}{
"bounty_status": model.BountyStatusRefunded, "bounty_status": models.BountyStatusRefunded,
"bounty_points": 0, "bounty_points": 0,
}).Error }).Error
}) })
} }
// RefundBountyIfOpen 删帖时自动退回未采纳悬赏 // RefundBountyIfOpen 删帖时自动退回未采纳悬赏
func RefundBountyIfOpen(tx *gorm.DB, post *model.Post) error { func RefundBountyIfOpen(tx *gorm.DB, post *models.Post) error {
if post == nil || post.PostType != model.PostTypeBounty { if post == nil || post.PostType != models.PostTypeBounty {
return nil return nil
} }
if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 { if post.BountyStatus != models.BountyStatusOpen || post.BountyPoints < 1 {
return nil return nil
} }
points := post.BountyPoints points := post.BountyPoints
if _, err := AdjustPointsTx(tx, post.UserID, points, model.PointReasonBountyRefund, "post", post.ID, "删帖退回悬赏"); err != nil { if _, err := AdjustPointsTx(tx, post.UserID, points, models.PointReasonBountyRefund, "post", post.ID, "删帖退回悬赏"); err != nil {
return err return err
} }
return tx.Model(post).Updates(map[string]interface{}{ return tx.Model(post).Updates(map[string]interface{}{
"bounty_status": model.BountyStatusRefunded, "bounty_status": models.BountyStatusRefunded,
"bounty_points": 0, "bounty_points": 0,
}).Error }).Error
} }

View File

@@ -1,11 +1,11 @@
package service package services
import ( import (
"errors" "errors"
"testing" "testing"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -15,26 +15,26 @@ func setupBountyTestDB(t *testing.T) *gorm.DB {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := db.AutoMigrate(&model.User{}, &model.Post{}, &model.Comment{}, &model.PointLedger{}); err != nil { if err := db.AutoMigrate(&models.User{}, &models.Post{}, &models.Comment{}, &models.PointLedger{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
prev := model.DB prev := models.DB
model.DB = db models.DB = db
t.Cleanup(func() { model.DB = prev }) t.Cleanup(func() { models.DB = prev })
return db return db
} }
func seedBountyPost(t *testing.T, db *gorm.DB, authorID uint, points int) model.Post { func seedBountyPost(t *testing.T, db *gorm.DB, authorID uint, points int) models.Post {
t.Helper() t.Helper()
post := model.Post{ post := models.Post{
UserID: authorID, UserID: authorID,
BoardID: 1, BoardID: 1,
Title: "悬赏测试", Title: "悬赏测试",
Content: "内容", Content: "内容",
PostType: model.PostTypeBounty, PostType: models.PostTypeBounty,
BountyPoints: points, BountyPoints: points,
BountyStatus: model.BountyStatusOpen, BountyStatus: models.BountyStatusOpen,
Status: model.ContentStatusPublished, Status: models.ContentStatusPublished,
} }
if err := db.Create(&post).Error; err != nil { if err := db.Create(&post).Error; err != nil {
t.Fatal(err) t.Fatal(err)
@@ -44,7 +44,7 @@ func seedBountyPost(t *testing.T, db *gorm.DB, authorID uint, points int) model.
func seedUser(t *testing.T, db *gorm.DB, id uint, points int) { func seedUser(t *testing.T, db *gorm.DB, id uint, points int) {
t.Helper() t.Helper()
u := model.User{ u := models.User{
ID: id, ID: id,
Username: "user" + string(rune('0'+id)), Username: "user" + string(rune('0'+id)),
Password: "hash", Password: "hash",
@@ -58,7 +58,7 @@ func seedUser(t *testing.T, db *gorm.DB, id uint, points int) {
func seedComment(t *testing.T, db *gorm.DB, postID, userID uint, floor int, status string) { func seedComment(t *testing.T, db *gorm.DB, postID, userID uint, floor int, status string) {
t.Helper() t.Helper()
c := model.Comment{ c := models.Comment{
PostID: postID, PostID: postID,
UserID: userID, UserID: userID,
Floor: floor, Floor: floor,
@@ -79,25 +79,25 @@ func TestCountEligibleBountyReplies(t *testing.T) {
t.Fatalf("无回复时期望 0得到 %d err=%v", n, err) t.Fatalf("无回复时期望 0得到 %d err=%v", n, err)
} }
seedComment(t, db, post.ID, 1, 1, model.ContentStatusPublished) seedComment(t, db, post.ID, 1, 1, models.ContentStatusPublished)
n, err = CountEligibleBountyReplies(db, post.ID, 1) n, err = CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 0 { if err != nil || n != 0 {
t.Fatalf("楼主自己的回复不应计入,得到 %d", n) t.Fatalf("楼主自己的回复不应计入,得到 %d", n)
} }
seedComment(t, db, post.ID, 2, 2, model.ContentStatusPublished) seedComment(t, db, post.ID, 2, 2, models.ContentStatusPublished)
n, err = CountEligibleBountyReplies(db, post.ID, 1) n, err = CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 1 { if err != nil || n != 1 {
t.Fatalf("他人 published 回复期望 1得到 %d", n) t.Fatalf("他人 published 回复期望 1得到 %d", n)
} }
seedComment(t, db, post.ID, 3, 3, model.ContentStatusPending) seedComment(t, db, post.ID, 3, 3, models.ContentStatusPending)
n, err = CountEligibleBountyReplies(db, post.ID, 1) n, err = CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 1 { if err != nil || n != 1 {
t.Fatalf("pending 回复不应增加计数,得到 %d", n) t.Fatalf("pending 回复不应增加计数,得到 %d", n)
} }
seedComment(t, db, post.ID, 0, 4, model.ContentStatusPublished) seedComment(t, db, post.ID, 0, 4, models.ContentStatusPublished)
n, err = CountEligibleBountyReplies(db, post.ID, 1) n, err = CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 2 { if err != nil || n != 2 {
t.Fatalf("游客回复应计入,得到 %d", n) t.Fatalf("游客回复应计入,得到 %d", n)
@@ -113,7 +113,7 @@ func TestCanRefundBounty(t *testing.T) {
t.Fatalf("无回复时楼主应可退can=%v reason=%q", can, reason) t.Fatalf("无回复时楼主应可退can=%v reason=%q", can, reason)
} }
seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished) seedComment(t, db, post.ID, 2, 1, models.ContentStatusPublished)
can, reason = CanRefundBounty(&post, false) can, reason = CanRefundBounty(&post, false)
if can || reason != bountyRefundBlockReason { if can || reason != bountyRefundBlockReason {
t.Fatalf("有他人回复时楼主不可退can=%v reason=%q", can, reason) t.Fatalf("有他人回复时楼主不可退can=%v reason=%q", can, reason)
@@ -130,7 +130,7 @@ func TestRefundBountyBlockedForAuthorWithReplies(t *testing.T) {
seedUser(t, db, 1, 0) seedUser(t, db, 1, 0)
seedUser(t, db, 2, 0) seedUser(t, db, 2, 0)
post := seedBountyPost(t, db, 1, 8) post := seedBountyPost(t, db, 1, 8)
seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished) seedComment(t, db, post.ID, 2, 1, models.ContentStatusPublished)
err := RefundBounty(post.ID, 1, false) err := RefundBounty(post.ID, 1, false)
if !errors.Is(err, ErrBountyRefundBlocked) { if !errors.Is(err, ErrBountyRefundBlocked) {
@@ -146,14 +146,14 @@ func TestRefundBountyAllowedWithoutReplies(t *testing.T) {
if err := RefundBounty(post.ID, 1, false); err != nil { if err := RefundBounty(post.ID, 1, false); err != nil {
t.Fatalf("无回复时楼主应可退回err=%v", err) t.Fatalf("无回复时楼主应可退回err=%v", err)
} }
var updated model.Post var updated models.Post
if err := db.First(&updated, post.ID).Error; err != nil { if err := db.First(&updated, post.ID).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if updated.BountyStatus != model.BountyStatusRefunded || updated.BountyPoints != 0 { if updated.BountyStatus != models.BountyStatusRefunded || updated.BountyPoints != 0 {
t.Fatalf("状态应为 refunded 且积分为 0得到 status=%s points=%d", updated.BountyStatus, updated.BountyPoints) t.Fatalf("状态应为 refunded 且积分为 0得到 status=%s points=%d", updated.BountyStatus, updated.BountyPoints)
} }
var author model.User var author models.User
if err := db.First(&author, 1).Error; err != nil { if err := db.First(&author, 1).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -167,7 +167,7 @@ func TestRefundBountyAdminBypassWithReplies(t *testing.T) {
seedUser(t, db, 1, 0) seedUser(t, db, 1, 0)
seedUser(t, db, 2, 0) seedUser(t, db, 2, 0)
post := seedBountyPost(t, db, 1, 4) post := seedBountyPost(t, db, 1, 4)
seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished) seedComment(t, db, post.ID, 2, 1, models.ContentStatusPublished)
if err := RefundBounty(post.ID, 99, true); err != nil { if err := RefundBounty(post.ID, 99, true); err != nil {
t.Fatalf("管理员应可强制退回err=%v", err) t.Fatalf("管理员应可强制退回err=%v", err)

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"crypto/rand" "crypto/rand"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"
@@ -7,7 +7,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
type CommentService struct { type CommentService struct {
@@ -25,9 +25,9 @@ func (s *CommentService) HasUserReplied(postID, userID uint) bool {
return false return false
} }
var count int64 var count int64
err := model.DB.Model(&model.Comment{}). err := models.DB.Model(&models.Comment{}).
Where("post_id = ? AND user_id = ? AND status IN ?", postID, userID, Where("post_id = ? AND user_id = ? AND status IN ?", postID, userID,
[]string{model.ContentStatusPublished, model.ContentStatusPending}). []string{models.ContentStatusPublished, models.ContentStatusPending}).
Limit(1). Limit(1).
Count(&count).Error Count(&count).Error
return err == nil && count > 0 return err == nil && count > 0
@@ -44,7 +44,7 @@ type CommentCreateInput struct {
IsPrivate bool IsPrivate bool
} }
func (s *CommentService) canViewPrivate(c model.Comment, viewerID uint, isAdmin bool, postAuthorID uint, guestSet map[uint]struct{}) bool { func (s *CommentService) canViewPrivate(c models.Comment, viewerID uint, isAdmin bool, postAuthorID uint, guestSet map[uint]struct{}) bool {
if !c.IsPrivate { if !c.IsPrivate {
return true return true
} }
@@ -63,8 +63,8 @@ func (s *CommentService) canViewPrivate(c model.Comment, viewerID uint, isAdmin
return false return false
} }
func (s *CommentService) fillReplyTargets(comments []model.Comment, loadMissing bool) { func (s *CommentService) fillReplyTargets(comments []models.Comment, loadMissing bool) {
idMap := make(map[uint]model.Comment, len(comments)) idMap := make(map[uint]models.Comment, len(comments))
for _, c := range comments { for _, c := range comments {
idMap[c.ID] = c idMap[c.ID] = c
} }
@@ -78,27 +78,27 @@ func (s *CommentService) fillReplyTargets(comments []model.Comment, loadMissing
continue continue
} }
if loadMissing { if loadMissing {
var target model.Comment var target models.Comment
if model.DB.Preload("User").First(&target, *comments[i].ReplyTo).Error == nil { if models.DB.Preload("User").First(&target, *comments[i].ReplyTo).Error == nil {
comments[i].ReplyTarget = &target comments[i].ReplyTarget = &target
} }
} }
} }
} }
func canViewComment(c model.Comment, viewerID uint, isAdmin bool) bool { func canViewComment(c models.Comment, viewerID uint, isAdmin bool) bool {
if isAdmin || c.Status == model.ContentStatusPublished || c.Status == "" { if isAdmin || c.Status == models.ContentStatusPublished || c.Status == "" {
return true return true
} }
if c.Status == model.ContentStatusPending || c.Status == model.ContentStatusRejected { if c.Status == models.ContentStatusPending || c.Status == models.ContentStatusRejected {
return viewerID > 0 && c.UserID == viewerID return viewerID > 0 && c.UserID == viewerID
} }
return false return false
} }
func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAuthorID uint, visibleGuestIDs []uint) ([]model.Comment, error) { func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAuthorID uint, visibleGuestIDs []uint) ([]models.Comment, error) {
var comments []model.Comment var comments []models.Comment
err := model.DB.Preload("User").Where("post_id = ?", postID).Order("floor asc").Find(&comments).Error err := models.DB.Preload("User").Where("post_id = ?", postID).Order("floor asc").Find(&comments).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -108,12 +108,12 @@ func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAut
guestSet[id] = struct{}{} guestSet[id] = struct{}{}
} }
allByID := make(map[uint]model.Comment, len(comments)) allByID := make(map[uint]models.Comment, len(comments))
for _, c := range comments { for _, c := range comments {
allByID[c.ID] = c allByID[c.ID] = c
} }
visible := make([]model.Comment, 0, len(comments)) visible := make([]models.Comment, 0, len(comments))
visibleIDs := make(map[uint]struct{}, len(comments)) visibleIDs := make(map[uint]struct{}, len(comments))
for i := range comments { for i := range comments {
if !canViewComment(comments[i], viewerID, isAdmin) { if !canViewComment(comments[i], viewerID, isAdmin) {
@@ -145,7 +145,7 @@ func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAut
} }
// resolveThreadParent 计算嵌套展示父节点:优先直接父评论,否则沿 reply_to 向上找到最近可见祖先 // resolveThreadParent 计算嵌套展示父节点:优先直接父评论,否则沿 reply_to 向上找到最近可见祖先
func resolveThreadParent(replyTo *uint, visibleIDs map[uint]struct{}, allByID map[uint]model.Comment) *uint { func resolveThreadParent(replyTo *uint, visibleIDs map[uint]struct{}, allByID map[uint]models.Comment) *uint {
if replyTo == nil { if replyTo == nil {
return nil return nil
} }
@@ -169,7 +169,7 @@ func resolveThreadParent(replyTo *uint, visibleIDs map[uint]struct{}, allByID ma
return nil return nil
} }
func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) { func (s *CommentService) Create(in CommentCreateInput) (*models.Comment, error) {
content := SanitizePostHTML(strings.TrimSpace(in.Content)) content := SanitizePostHTML(strings.TrimSpace(in.Content))
content = s.filter.Filter(content) content = s.filter.Filter(content)
if content == "" { if content == "" {
@@ -179,16 +179,16 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
return nil, err return nil, err
} }
var post model.Post var post models.Post
if err := model.DB.First(&post, in.PostID).Error; err != nil { if err := models.DB.First(&post, in.PostID).Error; err != nil {
return nil, ErrPostNotFound return nil, ErrPostNotFound
} }
if in.UserID == 0 { if in.UserID == 0 {
return nil, errors.New("请登录后评论") return nil, errors.New("请登录后评论")
} }
var user model.User var user models.User
if err := model.DB.First(&user, in.UserID).Error; err != nil { if err := models.DB.First(&user, in.UserID).Error; err != nil {
return nil, errors.New("用户不存在") return nil, errors.New("用户不存在")
} }
if user.Banned { if user.Banned {
@@ -201,31 +201,31 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
} }
// 未公开帖仅作者/管理员可评论 // 未公开帖仅作者/管理员可评论
if post.Status != model.ContentStatusPublished && post.Status != "" { if post.Status != models.ContentStatusPublished && post.Status != "" {
if user.Role != model.RoleAdmin && post.UserID != in.UserID { if user.Role != models.RoleAdmin && post.UserID != in.UserID {
return nil, errors.New("帖子审核中,暂不可评论") return nil, errors.New("帖子审核中,暂不可评论")
} }
} }
var maxFloor int var maxFloor int
model.DB.Model(&model.Comment{}).Where("post_id = ?", in.PostID).Select("COALESCE(MAX(floor), 0)").Scan(&maxFloor) models.DB.Model(&models.Comment{}).Where("post_id = ?", in.PostID).Select("COALESCE(MAX(floor), 0)").Scan(&maxFloor)
if in.ReplyTo != nil { if in.ReplyTo != nil {
var target model.Comment var target models.Comment
if err := model.DB.Where("id = ? AND post_id = ?", *in.ReplyTo, in.PostID).First(&target).Error; err != nil { if err := models.DB.Where("id = ? AND post_id = ?", *in.ReplyTo, in.PostID).First(&target).Error; err != nil {
return nil, ErrCommentNotFound return nil, ErrCommentNotFound
} }
if !canViewComment(target, in.UserID, user.Role == model.RoleAdmin) { if !canViewComment(target, in.UserID, user.Role == models.RoleAdmin) {
return nil, ErrCommentNotFound return nil, ErrCommentNotFound
} }
} }
status := model.ContentStatusPending status := models.ContentStatusPending
if user.SkipsModeration() { if user.SkipsModeration() {
status = model.ContentStatusPublished status = models.ContentStatusPublished
} }
comment := &model.Comment{ comment := &models.Comment{
PostID: in.PostID, PostID: in.PostID,
UserID: in.UserID, UserID: in.UserID,
Floor: maxFloor + 1, Floor: maxFloor + 1,
@@ -237,10 +237,10 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
IsPrivate: in.IsPrivate, IsPrivate: in.IsPrivate,
Status: status, Status: status,
} }
if err := model.DB.Create(comment).Error; err != nil { if err := models.DB.Create(comment).Error; err != nil {
return nil, err return nil, err
} }
if status == model.ContentStatusPublished { if status == models.ContentStatusPublished {
AddExp(in.UserID, 2) AddExp(in.UserID, 2)
} }
return comment, nil return comment, nil
@@ -249,39 +249,39 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
// SetStatus 设置评论审核状态 // SetStatus 设置评论审核状态
func (s *CommentService) SetStatus(commentID uint, status string) error { func (s *CommentService) SetStatus(commentID uint, status string) error {
switch status { switch status {
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected: case models.ContentStatusPending, models.ContentStatusPublished, models.ContentStatusRejected:
default: default:
return errors.New("无效的审核状态") return errors.New("无效的审核状态")
} }
var comment model.Comment var comment models.Comment
if err := model.DB.Select("id", "user_id", "status").First(&comment, commentID).Error; err != nil { if err := models.DB.Select("id", "user_id", "status").First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound return ErrCommentNotFound
} }
prev := comment.Status prev := comment.Status
res := model.DB.Model(&model.Comment{}).Where("id = ?", commentID).Update("status", status) res := models.DB.Model(&models.Comment{}).Where("id = ?", commentID).Update("status", status)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
if res.RowsAffected == 0 { if res.RowsAffected == 0 {
return ErrCommentNotFound return ErrCommentNotFound
} }
if status == model.ContentStatusPublished && prev != model.ContentStatusPublished && comment.UserID > 0 { if status == models.ContentStatusPublished && prev != models.ContentStatusPublished && comment.UserID > 0 {
AddExp(comment.UserID, 2) AddExp(comment.UserID, 2)
} }
return nil return nil
} }
// GetByID 获取评论 // GetByID 获取评论
func (s *CommentService) GetByID(id uint) (*model.Comment, error) { func (s *CommentService) GetByID(id uint) (*models.Comment, error) {
var c model.Comment var c models.Comment
if err := model.DB.Preload("User").Preload("Post").First(&c, id).Error; err != nil { if err := models.DB.Preload("User").Preload("Post").First(&c, id).Error; err != nil {
return nil, ErrCommentNotFound return nil, ErrCommentNotFound
} }
return &c, nil return &c, nil
} }
// fillLiked 批量标记当前用户是否已点赞 // fillLiked 批量标记当前用户是否已点赞
func (s *CommentService) fillLiked(comments []model.Comment, viewerID uint) { func (s *CommentService) fillLiked(comments []models.Comment, viewerID uint) {
if viewerID == 0 || len(comments) == 0 { if viewerID == 0 || len(comments) == 0 {
return return
} }
@@ -289,8 +289,8 @@ func (s *CommentService) fillLiked(comments []model.Comment, viewerID uint) {
for _, c := range comments { for _, c := range comments {
ids = append(ids, c.ID) ids = append(ids, c.ID)
} }
var likes []model.CommentLike var likes []models.CommentLike
model.DB.Where("user_id = ? AND comment_id IN ?", viewerID, ids).Find(&likes) models.DB.Where("user_id = ? AND comment_id IN ?", viewerID, ids).Find(&likes)
likedSet := make(map[uint]struct{}, len(likes)) likedSet := make(map[uint]struct{}, len(likes))
for _, l := range likes { for _, l := range likes {
likedSet[l.CommentID] = struct{}{} likedSet[l.CommentID] = struct{}{}
@@ -302,29 +302,29 @@ func (s *CommentService) fillLiked(comments []model.Comment, viewerID uint) {
// ToggleLike 切换评论点赞 // ToggleLike 切换评论点赞
func (s *CommentService) ToggleLike(userID, commentID uint) (liked bool, likeCount int, err error) { func (s *CommentService) ToggleLike(userID, commentID uint) (liked bool, likeCount int, err error) {
var comment model.Comment var comment models.Comment
if err := model.DB.Select("id", "like_count").First(&comment, commentID).Error; err != nil { if err := models.DB.Select("id", "like_count").First(&comment, commentID).Error; err != nil {
return false, 0, ErrCommentNotFound return false, 0, ErrCommentNotFound
} }
var like model.CommentLike var like models.CommentLike
result := model.DB.Where("comment_id = ? AND user_id = ?", commentID, userID).Limit(1).Find(&like) result := models.DB.Where("comment_id = ? AND user_id = ?", commentID, userID).Limit(1).Find(&like)
if result.Error != nil { if result.Error != nil {
return false, 0, result.Error return false, 0, result.Error
} }
if result.RowsAffected > 0 { if result.RowsAffected > 0 {
if err := model.DB.Delete(&like).Error; err != nil { if err := models.DB.Delete(&like).Error; err != nil {
return false, 0, err return false, 0, err
} }
model.DB.Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("CASE WHEN like_count > 0 THEN like_count - 1 ELSE 0 END")) models.DB.Model(&models.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("CASE WHEN like_count > 0 THEN like_count - 1 ELSE 0 END"))
_ = model.DB.Select("like_count").First(&comment, commentID) _ = models.DB.Select("like_count").First(&comment, commentID)
return false, comment.LikeCount, nil return false, comment.LikeCount, nil
} }
like = model.CommentLike{CommentID: commentID, UserID: userID} like = models.CommentLike{CommentID: commentID, UserID: userID}
if err := model.DB.Create(&like).Error; err != nil { if err := models.DB.Create(&like).Error; err != nil {
return false, 0, err return false, 0, err
} }
model.DB.Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("like_count + 1")) models.DB.Model(&models.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("like_count + 1"))
_ = model.DB.Select("like_count").First(&comment, commentID) _ = models.DB.Select("like_count").First(&comment, commentID)
return true, comment.LikeCount, nil return true, comment.LikeCount, nil
} }
@@ -334,14 +334,14 @@ func (s *CommentService) IsLiked(userID, commentID uint) bool {
return false return false
} }
var count int64 var count int64
model.DB.Model(&model.CommentLike{}).Where("comment_id = ? AND user_id = ?", commentID, userID).Count(&count) models.DB.Model(&models.CommentLike{}).Where("comment_id = ? AND user_id = ?", commentID, userID).Count(&count)
return count > 0 return count > 0
} }
// PendingCommentCount 待审评论数 // PendingCommentCount 待审评论数
func (s *CommentService) PendingCommentCount() (int64, error) { func (s *CommentService) PendingCommentCount() (int64, error) {
var n int64 var n int64
err := model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPending).Count(&n).Error err := models.DB.Model(&models.Comment{}).Where("status = ?", models.ContentStatusPending).Count(&n).Error
return n, err return n, err
} }
@@ -353,8 +353,8 @@ func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
} }
func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) { func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
var comment model.Comment var comment models.Comment
if err := model.DB.First(&comment, commentID).Error; err != nil { if err := models.DB.First(&comment, commentID).Error; err != nil {
return "", false, ErrCommentNotFound return "", false, ErrCommentNotFound
} }
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) { if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
@@ -380,8 +380,8 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration
} }
enteredPending := false enteredPending := false
err := model.DB.Transaction(func(tx *gorm.DB) error { err := models.DB.Transaction(func(tx *gorm.DB) error {
rev := model.CommentRevision{ rev := models.CommentRevision{
CommentID: commentID, CommentID: commentID,
EditorID: userID, EditorID: userID,
Content: comment.Content, Content: comment.Content,
@@ -391,7 +391,7 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration
} }
updates := map[string]interface{}{"content": content} updates := map[string]interface{}{"content": content}
if !skipModeration { if !skipModeration {
updates["status"] = model.ContentStatusPending updates["status"] = models.ContentStatusPending
enteredPending = true enteredPending = true
} }
return tx.Model(&comment).Updates(updates).Error return tx.Model(&comment).Updates(updates).Error
@@ -413,11 +413,11 @@ func collectReplySubtreeIDs(db *gorm.DB, rootID uint, softDeletedOnly bool) ([]u
seen := map[uint]struct{}{rootID: {}} seen := map[uint]struct{}{rootID: {}}
frontier := []uint{rootID} frontier := []uint{rootID}
for len(frontier) > 0 { for len(frontier) > 0 {
childQ := q.Model(&model.Comment{}).Select("id").Where("reply_to IN ?", frontier) childQ := q.Model(&models.Comment{}).Select("id").Where("reply_to IN ?", frontier)
if softDeletedOnly { if softDeletedOnly {
childQ = childQ.Where("deleted_at IS NOT NULL") childQ = childQ.Where("deleted_at IS NOT NULL")
} }
var children []model.Comment var children []models.Comment
if err := childQ.Find(&children).Error; err != nil { if err := childQ.Find(&children).Error; err != nil {
return nil, err return nil, err
} }
@@ -436,20 +436,20 @@ func collectReplySubtreeIDs(db *gorm.DB, rootID uint, softDeletedOnly bool) ([]u
// AdminDelete 软删除评论及其回复树(进入回收站);修订与点赞保留以便恢复 // AdminDelete 软删除评论及其回复树(进入回收站);修订与点赞保留以便恢复
func (s *CommentService) AdminDelete(commentID uint) error { func (s *CommentService) AdminDelete(commentID uint) error {
var root model.Comment var root models.Comment
if err := model.DB.First(&root, commentID).Error; err != nil { if err := models.DB.First(&root, commentID).Error; err != nil {
return ErrCommentNotFound return ErrCommentNotFound
} }
ids, err := collectReplySubtreeIDs(model.DB, commentID, false) ids, err := collectReplySubtreeIDs(models.DB, commentID, false)
if err != nil { if err != nil {
return err return err
} }
return model.DB.Where("id IN ?", ids).Delete(&model.Comment{}).Error return models.DB.Where("id IN ?", ids).Delete(&models.Comment{}).Error
} }
// TrashCommentItem 评论回收站列表项 // TrashCommentItem 评论回收站列表项
type TrashCommentItem struct { type TrashCommentItem struct {
model.Comment models.Comment
DeletedAt time.Time `json:"deleted_at"` DeletedAt time.Time `json:"deleted_at"`
} }
@@ -459,7 +459,7 @@ func (s *CommentService) ListTrash(page, size int, keyword string) ([]TrashComme
page = 1 page = 1
} }
size = s.settings.NormalizePageSize(size) size = s.settings.NormalizePageSize(size)
db := model.DB.Unscoped().Model(&model.Comment{}). db := models.DB.Unscoped().Model(&models.Comment{}).
Where("comments.deleted_at IS NOT NULL"). Where("comments.deleted_at IS NOT NULL").
Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL"). Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
Preload("User").Preload("Post") Preload("User").Preload("Post")
@@ -475,7 +475,7 @@ func (s *CommentService) ListTrash(page, size int, keyword string) ([]TrashComme
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var comments []model.Comment var comments []models.Comment
if err := db.Order("comments.deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil { if err := db.Order("comments.deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -491,65 +491,65 @@ func (s *CommentService) ListTrash(page, size int, keyword string) ([]TrashComme
// Restore 从回收站恢复评论及其已软删的回复树 // Restore 从回收站恢复评论及其已软删的回复树
func (s *CommentService) Restore(commentID uint) error { func (s *CommentService) Restore(commentID uint) error {
var comment model.Comment var comment models.Comment
if err := model.DB.Unscoped().First(&comment, commentID).Error; err != nil { if err := models.DB.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound return ErrCommentNotFound
} }
if !comment.DeletedAt.Valid { if !comment.DeletedAt.Valid {
return errors.New("评论未被删除") return errors.New("评论未被删除")
} }
// 所属帖子必须仍存在且未删除 // 所属帖子必须仍存在且未删除
var post model.Post var post models.Post
if err := model.DB.First(&post, comment.PostID).Error; err != nil { if err := models.DB.First(&post, comment.PostID).Error; err != nil {
return errors.New("所属帖子不存在或已在回收站,请先恢复帖子") return errors.New("所属帖子不存在或已在回收站,请先恢复帖子")
} }
ids, err := collectReplySubtreeIDs(model.DB, commentID, true) ids, err := collectReplySubtreeIDs(models.DB, commentID, true)
if err != nil { if err != nil {
return err return err
} }
return model.DB.Unscoped().Model(&model.Comment{}). return models.DB.Unscoped().Model(&models.Comment{}).
Where("id IN ?", ids). Where("id IN ?", ids).
Update("deleted_at", nil).Error Update("deleted_at", nil).Error
} }
// Purge 永久删除回收站中的评论及其已软删回复(含修订、点赞) // Purge 永久删除回收站中的评论及其已软删回复(含修订、点赞)
func (s *CommentService) Purge(commentID uint) error { func (s *CommentService) Purge(commentID uint) error {
var comment model.Comment var comment models.Comment
if err := model.DB.Unscoped().First(&comment, commentID).Error; err != nil { if err := models.DB.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound return ErrCommentNotFound
} }
if !comment.DeletedAt.Valid { if !comment.DeletedAt.Valid {
return errors.New("仅可彻底删除回收站中的评论,请先删除评论") return errors.New("仅可彻底删除回收站中的评论,请先删除评论")
} }
ids, err := collectReplySubtreeIDs(model.DB, commentID, true) ids, err := collectReplySubtreeIDs(models.DB, commentID, true)
if err != nil { if err != nil {
return err return err
} }
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentRevision{}).Error; err != nil { if err := tx.Where("comment_id IN ?", ids).Delete(&models.CommentRevision{}).Error; err != nil {
return err return err
} }
if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentLike{}).Error; err != nil { if err := tx.Where("comment_id IN ?", ids).Delete(&models.CommentLike{}).Error; err != nil {
return err return err
} }
return tx.Unscoped().Where("id IN ?", ids).Delete(&model.Comment{}).Error return tx.Unscoped().Where("id IN ?", ids).Delete(&models.Comment{}).Error
}) })
} }
// ListRevisions 评论编辑历史(管理员查看) // ListRevisions 评论编辑历史(管理员查看)
func (s *CommentService) ListRevisions(commentID uint) ([]model.CommentRevision, error) { func (s *CommentService) ListRevisions(commentID uint) ([]models.CommentRevision, error) {
if _, err := s.GetByID(commentID); err != nil { if _, err := s.GetByID(commentID); err != nil {
return nil, err return nil, err
} }
var revs []model.CommentRevision var revs []models.CommentRevision
err := model.DB.Preload("Editor"). err := models.DB.Preload("Editor").
Where("comment_id = ?", commentID). Where("comment_id = ?", commentID).
Order("id desc").Find(&revs).Error Order("id desc").Find(&revs).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
if revs == nil { if revs == nil {
revs = []model.CommentRevision{} revs = []models.CommentRevision{}
} }
return revs, nil return revs, nil
} }
@@ -572,9 +572,9 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error
if limit < 1 { if limit < 1 {
limit = 8 limit = 8
} }
var comments []model.Comment var comments []models.Comment
err := model.DB.Preload("User").Preload("Post"). err := models.DB.Preload("User").Preload("Post").
Where("is_private = ? AND status = ?", false, model.ContentStatusPublished). Where("is_private = ? AND status = ?", false, models.ContentStatusPublished).
Order("id desc").Limit(limit * 2). // 多取一些以跳过已删帖 Order("id desc").Limit(limit * 2). // 多取一些以跳过已删帖
Find(&comments).Error Find(&comments).Error
if err != nil { if err != nil {
@@ -630,21 +630,21 @@ func truncateRunes(s string, n int) string {
} }
// ListRecent 管理员查看最近评论 // ListRecent 管理员查看最近评论
func (s *CommentService) ListRecent(page, size int, status string) ([]model.Comment, int64, error) { func (s *CommentService) ListRecent(page, size int, status string) ([]models.Comment, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
if size < 1 { if size < 1 {
size = 20 size = 20
} }
db := model.DB.Model(&model.Comment{}) db := models.DB.Model(&models.Comment{})
switch status { switch status {
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected: case models.ContentStatusPending, models.ContentStatusPublished, models.ContentStatusRejected:
db = db.Where("status = ?", status) db = db.Where("status = ?", status)
} }
var total int64 var total int64
db.Count(&total) db.Count(&total)
var comments []model.Comment var comments []models.Comment
err := db.Preload("User").Preload("Post"). err := db.Preload("User").Preload("Post").
Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC"). Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC").
Offset((page - 1) * size).Limit(size).Find(&comments).Error Offset((page - 1) * size).Limit(size).Find(&comments).Error

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"regexp" "regexp"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"strings" "strings"

View File

@@ -1,4 +1,4 @@
package service package services
import "strings" import "strings"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"crypto/rand" "crypto/rand"
@@ -8,7 +8,7 @@ import (
"sync" "sync"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
const ( const (
@@ -65,8 +65,8 @@ func (s *EmailCodeService) sendCode(purpose, email string) error {
return err return err
} }
var exist model.User var exist models.User
found := model.DB.Where("email = ?", email).First(&exist).Error == nil found := models.DB.Where("email = ?", email).First(&exist).Error == nil
switch purpose { switch purpose {
case EmailCodePurposeRegister: case EmailCodePurposeRegister:
if found { if found {

View File

@@ -1,4 +1,4 @@
package service package services
import "os" import "os"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"os" "os"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"
@@ -8,7 +8,7 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -41,7 +41,7 @@ type FriendLinkApplyInput struct {
} }
type FriendLinkApplyCreateResult struct { type FriendLinkApplyCreateResult struct {
Apply *model.FriendLinkApply Apply *models.FriendLinkApply
} }
type FriendLinkApplyService struct { type FriendLinkApplyService struct {
@@ -106,41 +106,41 @@ func (s *FriendLinkApplyService) Create(in FriendLinkApplyInput) (*FriendLinkApp
return nil, ErrFriendLinkApplyPending return nil, ErrFriendLinkApplyPending
} }
apply := &model.FriendLinkApply{ apply := &models.FriendLinkApply{
UserID: in.UserID, UserID: in.UserID,
Name: name, Name: name,
URL: href, URL: href,
Logo: logo, Logo: logo,
ReciprocalPageURL: reciprocal, ReciprocalPageURL: reciprocal,
LinkOnHomepage: in.LinkOnHomepage, LinkOnHomepage: in.LinkOnHomepage,
Status: model.FriendLinkApplyStatusPending, Status: models.FriendLinkApplyStatusPending,
} }
if err := model.DB.Create(apply).Error; err != nil { if err := models.DB.Create(apply).Error; err != nil {
return nil, err return nil, err
} }
s.startReciprocalCheck(apply.ID, reciprocal, in.OurSiteURL) s.startReciprocalCheck(apply.ID, reciprocal, in.OurSiteURL)
_ = model.DB.Preload("User").First(apply, apply.ID).Error _ = models.DB.Preload("User").First(apply, apply.ID).Error
return &FriendLinkApplyCreateResult{Apply: apply}, nil return &FriendLinkApplyCreateResult{Apply: apply}, nil
} }
// PendingCount 待审数量 // PendingCount 待审数量
func (s *FriendLinkApplyService) PendingCount() (int64, error) { func (s *FriendLinkApplyService) PendingCount() (int64, error) {
var n int64 var n int64
err := model.DB.Model(&model.FriendLinkApply{}). err := models.DB.Model(&models.FriendLinkApply{}).
Where("status = ?", model.FriendLinkApplyStatusPending). Where("status = ?", models.FriendLinkApplyStatusPending).
Count(&n).Error Count(&n).Error
return n, err return n, err
} }
// ListAdmin 管理员列表 // ListAdmin 管理员列表
func (s *FriendLinkApplyService) ListAdmin(q FriendLinkApplyListQuery) ([]model.FriendLinkApply, int64, error) { func (s *FriendLinkApplyService) ListAdmin(q FriendLinkApplyListQuery) ([]models.FriendLinkApply, int64, error) {
if q.Page < 1 { if q.Page < 1 {
q.Page = 1 q.Page = 1
} }
if q.Size < 1 || q.Size > 50 { if q.Size < 1 || q.Size > 50 {
q.Size = 20 q.Size = 20
} }
db := model.DB.Model(&model.FriendLinkApply{}) db := models.DB.Model(&models.FriendLinkApply{})
status := strings.TrimSpace(q.Status) status := strings.TrimSpace(q.Status)
if status != "" && status != "all" { if status != "" && status != "all" {
db = db.Where("status = ?", status) db = db.Where("status = ?", status)
@@ -149,7 +149,7 @@ func (s *FriendLinkApplyService) ListAdmin(q FriendLinkApplyListQuery) ([]model.
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var list []model.FriendLinkApply var list []models.FriendLinkApply
err := db.Preload("User"). err := db.Preload("User").
Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC"). Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC").
Offset((q.Page - 1) * q.Size). Offset((q.Page - 1) * q.Size).
@@ -161,22 +161,22 @@ func (s *FriendLinkApplyService) ListAdmin(q FriendLinkApplyListQuery) ([]model.
return list, total, nil return list, total, nil
} }
func (s *FriendLinkApplyService) getPending(id uint) (*model.FriendLinkApply, error) { func (s *FriendLinkApplyService) getPending(id uint) (*models.FriendLinkApply, error) {
var apply model.FriendLinkApply var apply models.FriendLinkApply
if err := model.DB.Preload("User").First(&apply, id).Error; err != nil { if err := models.DB.Preload("User").First(&apply, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrFriendLinkApplyNotFound return nil, ErrFriendLinkApplyNotFound
} }
return nil, err return nil, err
} }
if apply.Status != model.FriendLinkApplyStatusPending { if apply.Status != models.FriendLinkApplyStatusPending {
return nil, ErrFriendLinkApplyHandled return nil, ErrFriendLinkApplyHandled
} }
return &apply, nil return &apply, nil
} }
// Approve 通过申请并写入友链 // Approve 通过申请并写入友链
func (s *FriendLinkApplyService) Approve(id uint) (*model.FriendLinkApply, error) { func (s *FriendLinkApplyService) Approve(id uint) (*models.FriendLinkApply, error) {
apply, err := s.getPending(id) apply, err := s.getPending(id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -211,13 +211,13 @@ func (s *FriendLinkApplyService) Approve(id uint) (*model.FriendLinkApply, error
} }
now := time.Now() now := time.Now()
if err := model.DB.Model(apply).Updates(map[string]interface{}{ if err := models.DB.Model(apply).Updates(map[string]interface{}{
"status": model.FriendLinkApplyStatusApproved, "status": models.FriendLinkApplyStatusApproved,
"reviewed_at": now, "reviewed_at": now,
}).Error; err != nil { }).Error; err != nil {
return nil, err return nil, err
} }
apply.Status = model.FriendLinkApplyStatusApproved apply.Status = models.FriendLinkApplyStatusApproved
apply.ReviewedAt = &now apply.ReviewedAt = &now
if s.messages != nil && apply.UserID > 0 { if s.messages != nil && apply.UserID > 0 {
@@ -226,27 +226,27 @@ func (s *FriendLinkApplyService) Approve(id uint) (*model.FriendLinkApply, error
"你申请的友情链接「%s」%s已通过审核现已展示在友情链接页面。\n\n如有疑问可回复本私信联系管理员。", "你申请的友情链接「%s」%s已通过审核现已展示在友情链接页面。\n\n如有疑问可回复本私信联系管理员。",
apply.Name, apply.URL, apply.Name, apply.URL,
) )
_, _ = s.messages.SendSystem(apply.UserID, subject, content, model.MessageKindSystem, nil, nil) _, _ = s.messages.SendSystem(apply.UserID, subject, content, models.MessageKindSystem, nil, nil)
} }
return apply, nil return apply, nil
} }
// Reject 拒绝申请 // Reject 拒绝申请
func (s *FriendLinkApplyService) Reject(id uint, note string) (*model.FriendLinkApply, error) { func (s *FriendLinkApplyService) Reject(id uint, note string) (*models.FriendLinkApply, error) {
apply, err := s.getPending(id) apply, err := s.getPending(id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
note = strings.TrimSpace(note) note = strings.TrimSpace(note)
now := time.Now() now := time.Now()
if err := model.DB.Model(apply).Updates(map[string]interface{}{ if err := models.DB.Model(apply).Updates(map[string]interface{}{
"status": model.FriendLinkApplyStatusRejected, "status": models.FriendLinkApplyStatusRejected,
"review_note": note, "review_note": note,
"reviewed_at": now, "reviewed_at": now,
}).Error; err != nil { }).Error; err != nil {
return nil, err return nil, err
} }
apply.Status = model.FriendLinkApplyStatusRejected apply.Status = models.FriendLinkApplyStatusRejected
apply.ReviewNote = note apply.ReviewNote = note
apply.ReviewedAt = &now apply.ReviewedAt = &now
@@ -260,7 +260,7 @@ func (s *FriendLinkApplyService) Reject(id uint, note string) (*model.FriendLink
"你申请的友情链接「%s」%s未通过审核。\n\n原因\n%s\n\n如有疑问可回复本私信联系管理员。", "你申请的友情链接「%s」%s未通过审核。\n\n原因\n%s\n\n如有疑问可回复本私信联系管理员。",
apply.Name, apply.URL, reason, apply.Name, apply.URL, reason,
) )
_, _ = s.messages.SendSystem(apply.UserID, subject, content, model.MessageKindReject, nil, nil) _, _ = s.messages.SendSystem(apply.UserID, subject, content, models.MessageKindReject, nil, nil)
} }
return apply, nil return apply, nil
} }
@@ -302,8 +302,8 @@ func (s *FriendLinkApplyService) prepareApplyFields(in FriendLinkApplyInput, all
} }
func (s *FriendLinkApplyService) hasPendingApplyForURL(userID, excludeID uint, href string) (bool, error) { func (s *FriendLinkApplyService) hasPendingApplyForURL(userID, excludeID uint, href string) (bool, error) {
db := model.DB.Model(&model.FriendLinkApply{}). db := models.DB.Model(&models.FriendLinkApply{}).
Where("user_id = ? AND status = ? AND url = ?", userID, model.FriendLinkApplyStatusPending, href) Where("user_id = ? AND status = ? AND url = ?", userID, models.FriendLinkApplyStatusPending, href)
if excludeID > 0 { if excludeID > 0 {
db = db.Where("id <> ?", excludeID) db = db.Where("id <> ?", excludeID)
} }
@@ -346,8 +346,8 @@ func (s *FriendLinkApplyService) removePublishedFriendLink(href string) error {
// Update 修改并重新提交友链申请(待审 / 已拒绝 / 已通过) // Update 修改并重新提交友链申请(待审 / 已拒绝 / 已通过)
func (s *FriendLinkApplyService) Update(userID, id uint, in FriendLinkApplyInput) (*FriendLinkApplyCreateResult, error) { func (s *FriendLinkApplyService) Update(userID, id uint, in FriendLinkApplyInput) (*FriendLinkApplyCreateResult, error) {
var apply model.FriendLinkApply var apply models.FriendLinkApply
if err := model.DB.First(&apply, id).Error; err != nil { if err := models.DB.First(&apply, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrFriendLinkApplyNotFound return nil, ErrFriendLinkApplyNotFound
} }
@@ -356,13 +356,13 @@ func (s *FriendLinkApplyService) Update(userID, id uint, in FriendLinkApplyInput
if apply.UserID != userID { if apply.UserID != userID {
return nil, errors.New("无权操作该申请") return nil, errors.New("无权操作该申请")
} }
if apply.Status != model.FriendLinkApplyStatusPending && if apply.Status != models.FriendLinkApplyStatusPending &&
apply.Status != model.FriendLinkApplyStatusRejected && apply.Status != models.FriendLinkApplyStatusRejected &&
apply.Status != model.FriendLinkApplyStatusApproved { apply.Status != models.FriendLinkApplyStatusApproved {
return nil, errors.New("该申请不可修改") return nil, errors.New("该申请不可修改")
} }
wasApproved := apply.Status == model.FriendLinkApplyStatusApproved wasApproved := apply.Status == models.FriendLinkApplyStatusApproved
allowPublishedURL := "" allowPublishedURL := ""
if wasApproved { if wasApproved {
allowPublishedURL = apply.URL allowPublishedURL = apply.URL
@@ -395,25 +395,25 @@ func (s *FriendLinkApplyService) Update(userID, id uint, in FriendLinkApplyInput
"reciprocal_verified": false, "reciprocal_verified": false,
"reciprocal_check_note": "", "reciprocal_check_note": "",
"reciprocal_checked_at": nil, "reciprocal_checked_at": nil,
"status": model.FriendLinkApplyStatusPending, "status": models.FriendLinkApplyStatusPending,
"review_note": "", "review_note": "",
"reviewed_at": nil, "reviewed_at": nil,
} }
if err := model.DB.Model(&apply).Updates(updates).Error; err != nil { if err := models.DB.Model(&apply).Updates(updates).Error; err != nil {
return nil, err return nil, err
} }
s.startReciprocalCheck(apply.ID, reciprocal, in.OurSiteURL) s.startReciprocalCheck(apply.ID, reciprocal, in.OurSiteURL)
_ = model.DB.Preload("User").First(&apply, apply.ID).Error _ = models.DB.Preload("User").First(&apply, apply.ID).Error
return &FriendLinkApplyCreateResult{Apply: &apply}, nil return &FriendLinkApplyCreateResult{Apply: &apply}, nil
} }
// RecheckReciprocal 管理员触发重新检测回链 // RecheckReciprocal 管理员触发重新检测回链
func (s *FriendLinkApplyService) RecheckReciprocal(id uint, ourSiteURL string) (*model.FriendLinkApply, error) { func (s *FriendLinkApplyService) RecheckReciprocal(id uint, ourSiteURL string) (*models.FriendLinkApply, error) {
if !s.settings.FriendLinkReciprocalCheckEnabled() { if !s.settings.FriendLinkReciprocalCheckEnabled() {
return nil, errors.New("回链检测已关闭") return nil, errors.New("回链检测已关闭")
} }
var apply model.FriendLinkApply var apply models.FriendLinkApply
if err := model.DB.Preload("User").First(&apply, id).Error; err != nil { if err := models.DB.Preload("User").First(&apply, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrFriendLinkApplyNotFound return nil, ErrFriendLinkApplyNotFound
} }
@@ -437,7 +437,7 @@ func (s *FriendLinkApplyService) startReciprocalCheck(applyID uint, pageURL, our
return return
} }
now := time.Now() now := time.Now()
_ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{ _ = models.DB.Model(&models.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
"reciprocal_verified": false, "reciprocal_verified": false,
"reciprocal_check_note": "", "reciprocal_check_note": "",
"reciprocal_checked_at": now, "reciprocal_checked_at": now,
@@ -445,9 +445,9 @@ func (s *FriendLinkApplyService) startReciprocalCheck(applyID uint, pageURL, our
} }
// ListMine 当前用户的友链申请 // ListMine 当前用户的友链申请
func (s *FriendLinkApplyService) ListMine(userID uint) ([]model.FriendLinkApply, error) { func (s *FriendLinkApplyService) ListMine(userID uint) ([]models.FriendLinkApply, error) {
var list []model.FriendLinkApply var list []models.FriendLinkApply
err := model.DB.Where("user_id = ?", userID). err := models.DB.Where("user_id = ?", userID).
Order("id DESC"). Order("id DESC").
Limit(50). Limit(50).
Find(&list).Error Find(&list).Error
@@ -456,8 +456,8 @@ func (s *FriendLinkApplyService) ListMine(userID uint) ([]model.FriendLinkApply,
// Cancel 撤销待审申请 // Cancel 撤销待审申请
func (s *FriendLinkApplyService) Cancel(userID, id uint) error { func (s *FriendLinkApplyService) Cancel(userID, id uint) error {
var apply model.FriendLinkApply var apply models.FriendLinkApply
if err := model.DB.First(&apply, id).Error; err != nil { if err := models.DB.First(&apply, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFriendLinkApplyNotFound return ErrFriendLinkApplyNotFound
} }
@@ -466,8 +466,8 @@ func (s *FriendLinkApplyService) Cancel(userID, id uint) error {
if apply.UserID != userID { if apply.UserID != userID {
return errors.New("无权操作该申请") return errors.New("无权操作该申请")
} }
if apply.Status != model.FriendLinkApplyStatusPending { if apply.Status != models.FriendLinkApplyStatusPending {
return ErrFriendLinkApplyHandled return ErrFriendLinkApplyHandled
} }
return model.DB.Delete(&apply).Error return models.DB.Delete(&apply).Error
} }

View File

@@ -1,10 +1,10 @@
package service package services
import ( import (
"encoding/json" "encoding/json"
"strings" "strings"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
// EnrichFriendLinksLogos 为缺少 LOGO 的已发布友链,从已通过申请中按 URL 回填 // EnrichFriendLinksLogos 为缺少 LOGO 的已发布友链,从已通过申请中按 URL 回填
@@ -27,9 +27,9 @@ func EnrichFriendLinksLogos(links []FriendLink) []FriendLink {
return links return links
} }
var applies []model.FriendLinkApply var applies []models.FriendLinkApply
_ = model.DB. _ = models.DB.
Where("status = ? AND logo <> ''", model.FriendLinkApplyStatusApproved). Where("status = ? AND logo <> ''", models.FriendLinkApplyStatusApproved).
Order("id DESC"). Order("id DESC").
Find(&applies).Error Find(&applies).Error

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"context" "context"

View File

@@ -1,10 +1,10 @@
package service package services
import ( import (
"sync" "sync"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
const reciprocalCheckConcurrency = 3 const reciprocalCheckConcurrency = 3
@@ -45,7 +45,7 @@ func runReciprocalCheck(applyID uint, gen uint64, pageURL, ourSiteURL string) {
} }
reciprocalCheckMu.Unlock() reciprocalCheckMu.Unlock()
_ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{ _ = models.DB.Model(&models.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
"reciprocal_verified": verified, "reciprocal_verified": verified,
"reciprocal_check_note": note, "reciprocal_check_note": note,
"reciprocal_checked_at": now, "reciprocal_checked_at": now,
@@ -54,7 +54,7 @@ func runReciprocalCheck(applyID uint, gen uint64, pageURL, ourSiteURL string) {
// ResetReciprocalCheckState 重置为检测中,供重新检测使用 // ResetReciprocalCheckState 重置为检测中,供重新检测使用
func ResetReciprocalCheckState(applyID uint) { func ResetReciprocalCheckState(applyID uint) {
_ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{ _ = models.DB.Model(&models.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
"reciprocal_verified": false, "reciprocal_verified": false,
"reciprocal_check_note": "", "reciprocal_check_note": "",
"reciprocal_checked_at": nil, "reciprocal_checked_at": nil,

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"net/url" "net/url"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"encoding/json" "encoding/json"
@@ -13,7 +13,7 @@ import (
"sync" "sync"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
var ( var (
@@ -26,11 +26,11 @@ type GiteaOwnerView struct {
ID uint `json:"id"` ID uint `json:"id"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Role model.Role `json:"role"` Role models.Role `json:"role"`
Verified bool `json:"verified"` Verified bool `json:"verified"`
Exp int `json:"exp"` Exp int `json:"exp"`
Level int `json:"level"` Level int `json:"level"`
Badges []model.UserBadgeView `json:"badges,omitempty"` Badges []models.UserBadgeView `json:"badges,omitempty"`
} }
// GiteaRepoView 前台展示 // GiteaRepoView 前台展示
@@ -123,7 +123,7 @@ func (g *GiteaService) ListPublic(page, size int, q string) ([]GiteaRepoView, in
BackfillForumUserIDs() BackfillForumUserIDs()
q = strings.TrimSpace(q) q = strings.TrimSpace(q)
db := model.DB.Model(&model.GiteaRepo{}).Where("private = ? AND forum_user_id IS NOT NULL AND forum_user_id > 0", false) db := models.DB.Model(&models.GiteaRepo{}).Where("private = ? AND forum_user_id IS NOT NULL AND forum_user_id > 0", false)
if q != "" { if q != "" {
like := "%" + escapeLikePattern(q) + "%" like := "%" + escapeLikePattern(q) + "%"
db = db.Where( db = db.Where(
@@ -138,7 +138,7 @@ func (g *GiteaService) ListPublic(page, size int, q string) ([]GiteaRepoView, in
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var rows []model.GiteaRepo var rows []models.GiteaRepo
err := db.Order("updated_at_remote desc, id desc"). err := db.Order("updated_at_remote desc, id desc").
Offset((page - 1) * size). Offset((page - 1) * size).
Limit(size). Limit(size).
@@ -155,13 +155,13 @@ func (g *GiteaService) ListPublic(page, size int, q string) ([]GiteaRepoView, in
// BackfillForumUserIDs 将缺失 forum_user_id 的公开仓按 owner_login忽略大小写匹配论坛 username // BackfillForumUserIDs 将缺失 forum_user_id 的公开仓按 owner_login忽略大小写匹配论坛 username
func BackfillForumUserIDs() int { func BackfillForumUserIDs() int {
var rows []model.GiteaRepo var rows []models.GiteaRepo
if err := model.DB.Where("private = ? AND (forum_user_id IS NULL OR forum_user_id = 0)", false). if err := models.DB.Where("private = ? AND (forum_user_id IS NULL OR forum_user_id = 0)", false).
Find(&rows).Error; err != nil || len(rows) == 0 { Find(&rows).Error; err != nil || len(rows) == 0 {
return 0 return 0
} }
var users []model.User var users []models.User
if err := model.DB.Select("id", "username").Where("banned = ?", false).Find(&users).Error; err != nil || len(users) == 0 { if err := models.DB.Select("id", "username").Where("banned = ?", false).Find(&users).Error; err != nil || len(users) == 0 {
return 0 return 0
} }
byLogin := make(map[string]uint, len(users)) byLogin := make(map[string]uint, len(users))
@@ -179,7 +179,7 @@ func BackfillForumUserIDs() int {
if !ok || uid == 0 { if !ok || uid == 0 {
continue continue
} }
if err := model.DB.Model(&rows[i]).Update("forum_user_id", uid).Error; err != nil { if err := models.DB.Model(&rows[i]).Update("forum_user_id", uid).Error; err != nil {
log.Printf("[gitea] 回填 forum_user_id 失败 repo=%s: %v", rows[i].FullName, err) log.Printf("[gitea] 回填 forum_user_id 失败 repo=%s: %v", rows[i].FullName, err)
continue continue
} }
@@ -222,13 +222,13 @@ func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoVie
logins = append(logins, key) logins = append(logins, key)
} }
byID := make(map[uint]*model.User) byID := make(map[uint]*models.User)
byLogin := make(map[string]*model.User) byLogin := make(map[string]*models.User)
ptrs := make([]*model.User, 0, len(ids)+len(logins)) ptrs := make([]*models.User, 0, len(ids)+len(logins))
if len(ids) > 0 { if len(ids) > 0 {
var users []model.User var users []models.User
if err := model.DB.Where("id IN ? AND banned = ?", ids, false).Find(&users).Error; err == nil { if err := models.DB.Where("id IN ? AND banned = ?", ids, false).Find(&users).Error; err == nil {
for i := range users { for i := range users {
u := &users[i] u := &users[i]
byID[u.ID] = u byID[u.ID] = u
@@ -241,8 +241,8 @@ func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoVie
} }
} }
if len(logins) > 0 { if len(logins) > 0 {
var users []model.User var users []models.User
if err := model.DB.Where("banned = ? AND LOWER(username) IN ?", false, logins).Find(&users).Error; err == nil { if err := models.DB.Where("banned = ? AND LOWER(username) IN ?", false, logins).Find(&users).Error; err == nil {
for i := range users { for i := range users {
u := &users[i] u := &users[i]
key := strings.ToLower(strings.TrimSpace(u.Username)) key := strings.ToLower(strings.TrimSpace(u.Username))
@@ -264,7 +264,7 @@ func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoVie
} }
// 去重 ptrs // 去重 ptrs
seenPtr := make(map[uint]struct{}, len(ptrs)) seenPtr := make(map[uint]struct{}, len(ptrs))
uniq := make([]*model.User, 0, len(ptrs)) uniq := make([]*models.User, 0, len(ptrs))
for _, u := range ptrs { for _, u := range ptrs {
if u == nil || u.ID == 0 { if u == nil || u.ID == 0 {
continue continue
@@ -279,14 +279,14 @@ func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoVie
badge.AttachBadgeSummaries(uniq, 3) badge.AttachBadgeSummaries(uniq, 3)
} else { } else {
for _, u := range uniq { for _, u := range uniq {
u.Level = model.LevelFromExp(u.Exp) u.Level = models.LevelFromExp(u.Exp)
} }
} }
out := make([]GiteaRepoView, 0, len(list)) out := make([]GiteaRepoView, 0, len(list))
for i := range list { for i := range list {
item := list[i] item := list[i]
var u *model.User var u *models.User
if item.ForumUserID != nil && *item.ForumUserID > 0 { if item.ForumUserID != nil && *item.ForumUserID > 0 {
u = byID[*item.ForumUserID] u = byID[*item.ForumUserID]
} }
@@ -297,7 +297,7 @@ func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoVie
uid := u.ID uid := u.ID
item.ForumUserID = &uid item.ForumUserID = &uid
// 回写缺失关联,便于下次列表过滤命中 // 回写缺失关联,便于下次列表过滤命中
_ = model.DB.Model(&model.GiteaRepo{}).Where("id = ?", item.ID). _ = models.DB.Model(&models.GiteaRepo{}).Where("id = ?", item.ID).
Update("forum_user_id", uid).Error Update("forum_user_id", uid).Error
} }
} }
@@ -315,7 +315,7 @@ func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoVie
Role: u.Role, Role: u.Role,
Verified: u.Verified, Verified: u.Verified,
Exp: u.Exp, Exp: u.Exp,
Level: model.LevelFromExp(u.Exp), Level: models.LevelFromExp(u.Exp),
Badges: u.Badges, Badges: u.Badges,
} }
out = append(out, item) out = append(out, item)
@@ -346,8 +346,8 @@ func (g *GiteaService) SyncRepos() (int, error) {
// 同步前先回填历史缺失关联 // 同步前先回填历史缺失关联
BackfillForumUserIDs() BackfillForumUserIDs()
var users []model.User var users []models.User
if err := model.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil { if err := models.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil {
return 0, err return 0, err
} }
@@ -378,7 +378,7 @@ func (g *GiteaService) SyncRepos() (int, error) {
if owner == "" { if owner == "" {
owner = username owner = username
} }
row := model.GiteaRepo{ row := models.GiteaRepo{
GiteaID: gr.ID, GiteaID: gr.ID,
OwnerLogin: owner, OwnerLogin: owner,
Name: gr.Name, Name: gr.Name,
@@ -393,16 +393,16 @@ func (g *GiteaService) SyncRepos() (int, error) {
ForumUserID: &uid, ForumUserID: &uid,
SyncedAt: now, SyncedAt: now,
} }
var existing model.GiteaRepo var existing models.GiteaRepo
err := model.DB.Where("gitea_id = ?", gr.ID).First(&existing).Error err := models.DB.Where("gitea_id = ?", gr.ID).First(&existing).Error
if err != nil { if err != nil {
if err := model.DB.Create(&row).Error; err != nil { if err := models.DB.Create(&row).Error; err != nil {
log.Printf("[gitea] 创建仓库失败 %s: %v", gr.FullName, err) log.Printf("[gitea] 创建仓库失败 %s: %v", gr.FullName, err)
continue continue
} }
} else { } else {
row.ID = existing.ID row.ID = existing.ID
if err := model.DB.Model(&existing).Updates(map[string]any{ if err := models.DB.Model(&existing).Updates(map[string]any{
"owner_login": row.OwnerLogin, "owner_login": row.OwnerLogin,
"name": row.Name, "name": row.Name,
"full_name": row.FullName, "full_name": row.FullName,
@@ -426,14 +426,14 @@ func (g *GiteaService) SyncRepos() (int, error) {
// 仅清理本次成功同步到的 owner 下、却未再出现的旧记录 // 仅清理本次成功同步到的 owner 下、却未再出现的旧记录
if len(syncedOwners) > 0 { if len(syncedOwners) > 0 {
var all []model.GiteaRepo var all []models.GiteaRepo
if err := model.DB.Where("private = ?", false).Find(&all).Error; err == nil { if err := models.DB.Where("private = ?", false).Find(&all).Error; err == nil {
for _, r := range all { for _, r := range all {
if _, ok := syncedOwners[strings.ToLower(r.OwnerLogin)]; !ok { if _, ok := syncedOwners[strings.ToLower(r.OwnerLogin)]; !ok {
continue continue
} }
if _, ok := seen[r.GiteaID]; !ok { if _, ok := seen[r.GiteaID]; !ok {
_ = model.DB.Delete(&r).Error _ = models.DB.Delete(&r).Error
} }
} }
} }
@@ -515,7 +515,7 @@ func (g *GiteaService) fetchUserPublicRepos(baseURL, token, username string) ([]
return all, nil return all, nil
} }
func toGiteaRepoView(r model.GiteaRepo) GiteaRepoView { func toGiteaRepoView(r models.GiteaRepo) GiteaRepoView {
return GiteaRepoView{ return GiteaRepoView{
ID: r.ID, ID: r.ID,
GiteaID: r.GiteaID, GiteaID: r.GiteaID,

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"bytes" "bytes"

View File

@@ -1,11 +1,11 @@
package service package services
import ( import (
"crypto/rand" "crypto/rand"
"errors" "errors"
"math/big" "math/big"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -34,15 +34,15 @@ func InitPostLottery(postID uint, winnerCount int) error {
if winnerCount < 1 || winnerCount > 20 { if winnerCount < 1 || winnerCount > 20 {
return errors.New("开奖人数需 1-20") return errors.New("开奖人数需 1-20")
} }
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Updates(map[string]interface{}{ return models.DB.Model(&models.Post{}).Where("id = ?", postID).Updates(map[string]interface{}{
"lottery_winner_count": winnerCount, "lottery_winner_count": winnerCount,
"lottery_status": model.PostLotteryStatusOpen, "lottery_status": models.PostLotteryStatusOpen,
}).Error }).Error
} }
// GetPostLotteryView 获取抽奖视图 // GetPostLotteryView 获取抽奖视图
func GetPostLotteryView(post *model.Post) (*PostLotteryView, error) { func GetPostLotteryView(post *models.Post) (*PostLotteryView, error) {
if post == nil || post.PostType != model.PostTypeLottery { if post == nil || post.PostType != models.PostTypeLottery {
return nil, nil return nil, nil
} }
participants, err := lotteryParticipants(post.ID, post.UserID) participants, err := lotteryParticipants(post.ID, post.UserID)
@@ -54,9 +54,9 @@ func GetPostLotteryView(post *model.Post) (*PostLotteryView, error) {
Status: post.LotteryStatus, Status: post.LotteryStatus,
ParticipantCount: len(participants), ParticipantCount: len(participants),
} }
if post.LotteryStatus == model.PostLotteryStatusDrawn { if post.LotteryStatus == models.PostLotteryStatusDrawn {
var winners []model.PostLotteryWinner var winners []models.PostLotteryWinner
model.DB.Preload("User").Where("post_id = ?", post.ID).Find(&winners) models.DB.Preload("User").Where("post_id = ?", post.ID).Find(&winners)
for _, w := range winners { for _, w := range winners {
view.Winners = append(view.Winners, PostLotteryWinnerView{ view.Winners = append(view.Winners, PostLotteryWinnerView{
UserID: w.UserID, Username: w.User.Username, Nickname: w.User.Nickname, UserID: w.UserID, Username: w.User.Username, Nickname: w.User.Nickname,
@@ -67,15 +67,15 @@ func GetPostLotteryView(post *model.Post) (*PostLotteryView, error) {
return view, nil return view, nil
} }
func lotteryParticipants(postID, authorID uint) ([]model.Comment, error) { func lotteryParticipants(postID, authorID uint) ([]models.Comment, error) {
var comments []model.Comment var comments []models.Comment
err := model.DB.Where("post_id = ? AND status = ? AND user_id <> ?", postID, model.ContentStatusPublished, authorID). err := models.DB.Where("post_id = ? AND status = ? AND user_id <> ?", postID, models.ContentStatusPublished, authorID).
Order("id ASC").Find(&comments).Error Order("id ASC").Find(&comments).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
seen := map[uint]bool{} seen := map[uint]bool{}
var unique []model.Comment var unique []models.Comment
for _, c := range comments { for _, c := range comments {
if seen[c.UserID] { if seen[c.UserID] {
continue continue
@@ -88,17 +88,17 @@ func lotteryParticipants(postID, authorID uint) ([]model.Comment, error) {
// DrawPostLottery 开奖 // DrawPostLottery 开奖
func DrawPostLottery(postID, operatorID uint, isAdmin bool) (*PostLotteryView, error) { func DrawPostLottery(postID, operatorID uint, isAdmin bool) (*PostLotteryView, error) {
var post model.Post var post models.Post
if err := model.DB.First(&post, postID).Error; err != nil { if err := models.DB.First(&post, postID).Error; err != nil {
return nil, ErrPostNotFound return nil, ErrPostNotFound
} }
if post.PostType != model.PostTypeLottery { if post.PostType != models.PostTypeLottery {
return nil, errors.New("非抽奖帖") return nil, errors.New("非抽奖帖")
} }
if !isAdmin && post.UserID != operatorID { if !isAdmin && post.UserID != operatorID {
return nil, ErrPermissionDenied return nil, ErrPermissionDenied
} }
if post.LotteryStatus == model.PostLotteryStatusDrawn { if post.LotteryStatus == models.PostLotteryStatusDrawn {
return nil, ErrLotteryAlreadyDrawn return nil, ErrLotteryAlreadyDrawn
} }
participants, err := lotteryParticipants(postID, post.UserID) participants, err := lotteryParticipants(postID, post.UserID)
@@ -113,25 +113,25 @@ func DrawPostLottery(postID, operatorID uint, isAdmin bool) (*PostLotteryView, e
return nil, ErrLotteryNotEnough return nil, ErrLotteryNotEnough
} }
picked := randomPickComments(participants, need) picked := randomPickComments(participants, need)
err = model.DB.Transaction(func(tx *gorm.DB) error { err = models.DB.Transaction(func(tx *gorm.DB) error {
for _, c := range picked { for _, c := range picked {
w := model.PostLotteryWinner{PostID: postID, UserID: c.UserID, CommentID: c.ID} w := models.PostLotteryWinner{PostID: postID, UserID: c.UserID, CommentID: c.ID}
if err := tx.Create(&w).Error; err != nil { if err := tx.Create(&w).Error; err != nil {
return err return err
} }
} }
return tx.Model(&post).Update("lottery_status", model.PostLotteryStatusDrawn).Error return tx.Model(&post).Update("lottery_status", models.PostLotteryStatusDrawn).Error
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
post.LotteryStatus = model.PostLotteryStatusDrawn post.LotteryStatus = models.PostLotteryStatusDrawn
return GetPostLotteryView(&post) return GetPostLotteryView(&post)
} }
func randomPickComments(comments []model.Comment, n int) []model.Comment { func randomPickComments(comments []models.Comment, n int) []models.Comment {
pool := append([]model.Comment{}, comments...) pool := append([]models.Comment{}, comments...)
out := make([]model.Comment, 0, n) out := make([]models.Comment, 0, n)
for i := 0; i < n && len(pool) > 0; i++ { for i := 0; i < n && len(pool) > 0; i++ {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(pool)))) idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(pool))))
if err != nil { if err != nil {
@@ -146,5 +146,5 @@ func randomPickComments(comments []model.Comment, n int) []model.Comment {
// DeleteLotteryData 删帖清理 // DeleteLotteryData 删帖清理
func DeleteLotteryData(tx *gorm.DB, postID uint) { func DeleteLotteryData(tx *gorm.DB, postID uint) {
tx.Where("post_id = ?", postID).Delete(&model.PostLotteryWinner{}) tx.Where("post_id = ?", postID).Delete(&models.PostLotteryWinner{})
} }

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"crypto/tls" "crypto/tls"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"context" "context"
@@ -12,7 +12,7 @@ import (
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
// MediaItem 管理端媒体资源条目 // MediaItem 管理端媒体资源条目
@@ -47,7 +47,7 @@ func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaL
if s == nil { if s == nil {
return nil, errors.New("上传存储未初始化") return nil, errors.New("上传存储未初始化")
} }
if model.DB == nil { if models.DB == nil {
return nil, errors.New("数据库未初始化") return nil, errors.New("数据库未初始化")
} }
if page < 1 { if page < 1 {
@@ -69,7 +69,7 @@ func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaL
// 索引为空时先同步一次,避免升级后首次打开空白 // 索引为空时先同步一次,避免升级后首次打开空白
var indexed int64 var indexed int64
_ = model.DB.Model(&model.Media{}).Count(&indexed).Error _ = models.DB.Model(&models.Media{}).Count(&indexed).Error
if indexed == 0 { if indexed == 0 {
_, _ = s.SyncMediaIndex() _, _ = s.SyncMediaIndex()
} }
@@ -84,7 +84,7 @@ func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaL
Cnt int Cnt int
} }
var rows []catCount var rows []catCount
if err := model.DB.Model(&model.Media{}). if err := models.DB.Model(&models.Media{}).
Select("category, count(*) as cnt"). Select("category, count(*) as cnt").
Group("category"). Group("category").
Scan(&rows).Error; err != nil { Scan(&rows).Error; err != nil {
@@ -94,7 +94,7 @@ func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaL
counts[r.Category] = r.Cnt counts[r.Category] = r.Cnt
} }
dbq := model.DB.Model(&model.Media{}) dbq := models.DB.Model(&models.Media{})
if category != "all" { if category != "all" {
dbq = dbq.Where("category = ?", category) dbq = dbq.Where("category = ?", category)
} }
@@ -115,7 +115,7 @@ func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaL
page = totalPages page = totalPages
} }
var records []model.Media var records []models.Media
offset := (page - 1) * size offset := (page - 1) * size
if err := dbq.Order("created_at desc, id desc").Offset(offset).Limit(size).Find(&records).Error; err != nil { if err := dbq.Order("created_at desc, id desc").Offset(offset).Limit(size).Find(&records).Error; err != nil {
return nil, err return nil, err
@@ -178,7 +178,7 @@ func (s *UploadStore) DeleteMedia(urls []string) (int, error) {
// SyncMediaIndex 扫描当前存储后端,回填/校正媒体索引;返回写入或更新条数 // SyncMediaIndex 扫描当前存储后端,回填/校正媒体索引;返回写入或更新条数
func (s *UploadStore) SyncMediaIndex() (int, error) { func (s *UploadStore) SyncMediaIndex() (int, error) {
if s == nil || model.DB == nil { if s == nil || models.DB == nil {
return 0, errors.New("存储或数据库未初始化") return 0, errors.New("存储或数据库未初始化")
} }
mode, _, _, _ := s.snapshot() mode, _, _, _ := s.snapshot()
@@ -206,19 +206,19 @@ func (s *UploadStore) SyncMediaIndex() (int, error) {
} }
// 清理当前后端下已不存在的索引(其它后端记录保留) // 清理当前后端下已不存在的索引(其它后端记录保留)
var stale []model.Media var stale []models.Media
_ = model.DB.Where("storage_type = ?", storageType).Find(&stale).Error _ = models.DB.Where("storage_type = ?", storageType).Find(&stale).Error
for _, row := range stale { for _, row := range stale {
if _, ok := seen[row.URL]; ok { if _, ok := seen[row.URL]; ok {
continue continue
} }
_ = model.DB.Delete(&model.Media{}, row.ID).Error _ = models.DB.Delete(&models.Media{}, row.ID).Error
} }
return n, nil return n, nil
} }
func (s *UploadStore) upsertMediaRecord(category, name, url string, size int64, contentType, storageType string, userID *uint) error { func (s *UploadStore) upsertMediaRecord(category, name, url string, size int64, contentType, storageType string, userID *uint) error {
if model.DB == nil || strings.TrimSpace(url) == "" { if models.DB == nil || strings.TrimSpace(url) == "" {
return nil return nil
} }
category = strings.TrimSpace(category) category = strings.TrimSpace(category)
@@ -231,8 +231,8 @@ func (s *UploadStore) upsertMediaRecord(category, name, url string, size int64,
contentType = imageContentType(strings.ToLower(filepath.Ext(name))) contentType = imageContentType(strings.ToLower(filepath.Ext(name)))
} }
var existing model.Media var existing models.Media
err := model.DB.Where("url = ?", url).First(&existing).Error err := models.DB.Where("url = ?", url).First(&existing).Error
if err == nil { if err == nil {
updates := map[string]interface{}{ updates := map[string]interface{}{
"category": category, "category": category,
@@ -244,10 +244,10 @@ func (s *UploadStore) upsertMediaRecord(category, name, url string, size int64,
if userID != nil { if userID != nil {
updates["user_id"] = *userID updates["user_id"] = *userID
} }
return model.DB.Model(&existing).Updates(updates).Error return models.DB.Model(&existing).Updates(updates).Error
} }
rec := model.Media{ rec := models.Media{
Category: category, Category: category,
Name: name, Name: name,
URL: url, URL: url,
@@ -256,11 +256,11 @@ func (s *UploadStore) upsertMediaRecord(category, name, url string, size int64,
StorageType: storageType, StorageType: storageType,
UserID: userID, UserID: userID,
} }
return model.DB.Create(&rec).Error return models.DB.Create(&rec).Error
} }
func (s *UploadStore) deleteMediaRecords(urls []string) { func (s *UploadStore) deleteMediaRecords(urls []string) {
if model.DB == nil || len(urls) == 0 { if models.DB == nil || len(urls) == 0 {
return return
} }
clean := make([]string, 0, len(urls)) clean := make([]string, 0, len(urls))
@@ -276,7 +276,7 @@ func (s *UploadStore) deleteMediaRecords(urls []string) {
if len(clean) == 0 { if len(clean) == 0 {
return return
} }
_ = model.DB.Where("url IN ?", clean).Delete(&model.Media{}).Error _ = models.DB.Where("url IN ?", clean).Delete(&models.Media{}).Error
} }
func (s *UploadStore) resolveSiblingPublicURLs(rawURL string) []string { func (s *UploadStore) resolveSiblingPublicURLs(rawURL string) []string {

View File

@@ -1,10 +1,10 @@
package service package services
import ( import (
"regexp" "regexp"
"strings" "strings"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
const maxMentionsPerContent = 10 const maxMentionsPerContent = 10
@@ -47,10 +47,10 @@ func ResolveMentionUserIDs(names []string, excludeUserID uint) []uint {
ids := make([]uint, 0, len(names)) ids := make([]uint, 0, len(names))
seen := make(map[uint]struct{}, len(names)) seen := make(map[uint]struct{}, len(names))
for _, name := range names { for _, name := range names {
var u model.User var u models.User
err := model.DB.Select("id").Where("username = ?", name).First(&u).Error err := models.DB.Select("id").Where("username = ?", name).First(&u).Error
if err != nil { if err != nil {
err = model.DB.Select("id").Where("nickname = ?", name).First(&u).Error err = models.DB.Select("id").Where("nickname = ?", name).First(&u).Error
} }
if err != nil || u.ID == 0 || u.ID == excludeUserID { if err != nil || u.ID == 0 || u.ID == excludeUserID {
continue continue

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"reflect" "reflect"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"
@@ -7,7 +7,7 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
var ( var (
@@ -34,7 +34,7 @@ type MessageSendInput struct {
} }
// Send 发送私信(用户互发或系统通知) // Send 发送私信(用户互发或系统通知)
func (s *MessageService) Send(in MessageSendInput) (*model.PrivateMessage, error) { func (s *MessageService) Send(in MessageSendInput) (*models.PrivateMessage, error) {
if in.ToUserID == 0 { if in.ToUserID == 0 {
return nil, errors.New("收件人不存在") return nil, errors.New("收件人不存在")
} }
@@ -42,8 +42,8 @@ func (s *MessageService) Send(in MessageSendInput) (*model.PrivateMessage, error
return nil, ErrCannotMessageSelf return nil, ErrCannotMessageSelf
} }
if in.FromUserID > 0 { if in.FromUserID > 0 {
var to model.User var to models.User
if err := model.DB.Select("id", "banned").First(&to, in.ToUserID).Error; err != nil { if err := models.DB.Select("id", "banned").First(&to, in.ToUserID).Error; err != nil {
return nil, errors.New("收件人不存在") return nil, errors.New("收件人不存在")
} }
if to.Banned { if to.Banned {
@@ -75,13 +75,13 @@ func (s *MessageService) Send(in MessageSendInput) (*model.PrivateMessage, error
kind := in.Kind kind := in.Kind
if kind == "" { if kind == "" {
if in.FromUserID == 0 { if in.FromUserID == 0 {
kind = model.MessageKindSystem kind = models.MessageKindSystem
} else { } else {
kind = model.MessageKindUser kind = models.MessageKindUser
} }
} }
msg := &model.PrivateMessage{ msg := &models.PrivateMessage{
FromUserID: in.FromUserID, FromUserID: in.FromUserID,
ToUserID: in.ToUserID, ToUserID: in.ToUserID,
Subject: subject, Subject: subject,
@@ -91,17 +91,17 @@ func (s *MessageService) Send(in MessageSendInput) (*model.PrivateMessage, error
RelatedReportID: in.RelatedReportID, RelatedReportID: in.RelatedReportID,
IsRead: false, IsRead: false,
} }
if err := model.DB.Create(msg).Error; err != nil { if err := models.DB.Create(msg).Error; err != nil {
return nil, err return nil, err
} }
_ = model.DB.Preload("FromUser").Preload("ToUser").First(msg, msg.ID).Error _ = models.DB.Preload("FromUser").Preload("ToUser").First(msg, msg.ID).Error
return msg, nil return msg, nil
} }
// SendSystem 系统私信(管理员/系统 → 用户) // SendSystem 系统私信(管理员/系统 → 用户)
func (s *MessageService) SendSystem(toUserID uint, subject, content, kind string, relatedPostID, relatedReportID *uint) (*model.PrivateMessage, error) { func (s *MessageService) SendSystem(toUserID uint, subject, content, kind string, relatedPostID, relatedReportID *uint) (*models.PrivateMessage, error) {
if kind == "" { if kind == "" {
kind = model.MessageKindSystem kind = models.MessageKindSystem
} }
return s.Send(MessageSendInput{ return s.Send(MessageSendInput{
FromUserID: 0, FromUserID: 0,
@@ -116,7 +116,7 @@ func (s *MessageService) SendSystem(toUserID uint, subject, content, kind string
// MarkAllRead 全部标为已读 // MarkAllRead 全部标为已读
func (s *MessageService) MarkAllRead(userID uint) error { func (s *MessageService) MarkAllRead(userID uint) error {
return model.DB.Model(&model.PrivateMessage{}). return models.DB.Model(&models.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ?", userID, false). Where("to_user_id = ? AND is_read = ?", userID, false).
Update("is_read", true).Error Update("is_read", true).Error
} }
@@ -124,7 +124,7 @@ func (s *MessageService) MarkAllRead(userID uint) error {
// UnreadCount 未读数 // UnreadCount 未读数
func (s *MessageService) UnreadCount(userID uint) (int64, error) { func (s *MessageService) UnreadCount(userID uint) (int64, error) {
var n int64 var n int64
err := model.DB.Model(&model.PrivateMessage{}). err := models.DB.Model(&models.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ?", userID, false). Where("to_user_id = ? AND is_read = ?", userID, false).
Count(&n).Error Count(&n).Error
return n, err return n, err
@@ -132,13 +132,13 @@ func (s *MessageService) UnreadCount(userID uint) (int64, error) {
// UnreadCounts 未读总数,以及私信 / 系统通知分项 // UnreadCounts 未读总数,以及私信 / 系统通知分项
func (s *MessageService) UnreadCounts(userID uint) (total, dm, notify int64, err error) { func (s *MessageService) UnreadCounts(userID uint) (total, dm, notify int64, err error) {
err = model.DB.Model(&model.PrivateMessage{}). err = models.DB.Model(&models.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ?", userID, false). Where("to_user_id = ? AND is_read = ?", userID, false).
Count(&total).Error Count(&total).Error
if err != nil { if err != nil {
return 0, 0, 0, err return 0, 0, 0, err
} }
err = model.DB.Model(&model.PrivateMessage{}). err = models.DB.Model(&models.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ? AND from_user_id = 0", userID, false). Where("to_user_id = ? AND is_read = ? AND from_user_id = 0", userID, false).
Count(&notify).Error Count(&notify).Error
if err != nil { if err != nil {
@@ -152,12 +152,12 @@ func (s *MessageService) UnreadCounts(userID uint) (total, dm, notify int64, err
} }
// ListNotifications 系统通知列表(按时间倒序,非聊天气泡) // ListNotifications 系统通知列表(按时间倒序,非聊天气泡)
func (s *MessageService) ListNotifications(userID uint, page, size int, kind string) ([]model.PrivateMessage, int64, error) { func (s *MessageService) ListNotifications(userID uint, page, size int, kind string) ([]models.PrivateMessage, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
size = s.settings.NormalizePageSize(size) size = s.settings.NormalizePageSize(size)
db := model.DB.Model(&model.PrivateMessage{}). db := models.DB.Model(&models.PrivateMessage{}).
Where("from_user_id = 0 AND to_user_id = ?", userID) Where("from_user_id = 0 AND to_user_id = ?", userID)
kind = strings.TrimSpace(kind) kind = strings.TrimSpace(kind)
if kind != "" && kind != "all" { if kind != "" && kind != "all" {
@@ -167,13 +167,13 @@ func (s *MessageService) ListNotifications(userID uint, page, size int, kind str
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var list []model.PrivateMessage var list []models.PrivateMessage
err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list).Error err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list).Error
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
if list == nil { if list == nil {
list = []model.PrivateMessage{} list = []models.PrivateMessage{}
} }
return list, total, nil return list, total, nil
} }
@@ -186,9 +186,9 @@ func (s *MessageService) MarkNotificationsRead(userID uint) error {
// MessageConversation 按对方聚合的会话摘要 // MessageConversation 按对方聚合的会话摘要
type MessageConversation struct { type MessageConversation struct {
PeerUserID uint `json:"peer_user_id"` // 0 = 系统通知 PeerUserID uint `json:"peer_user_id"` // 0 = 系统通知
PeerUser *model.User `json:"peer_user,omitempty"` PeerUser *models.User `json:"peer_user,omitempty"`
IsSystem bool `json:"is_system"` IsSystem bool `json:"is_system"`
LastMessage *model.PrivateMessage `json:"last_message,omitempty"` LastMessage *models.PrivateMessage `json:"last_message,omitempty"`
UnreadCount int64 `json:"unread_count"` UnreadCount int64 `json:"unread_count"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -220,7 +220,7 @@ func (s *MessageService) ListConversations(q ConversationListQuery) ([]MessageCo
} }
var rows []peerRow var rows []peerRow
// peer_id系统通知为 0否则为对话另一方 // peer_id系统通知为 0否则为对话另一方
err := model.DB.Raw(` err := models.DB.Raw(`
SELECT SELECT
CASE CASE
WHEN from_user_id = 0 THEN 0 WHEN from_user_id = 0 THEN 0
@@ -239,7 +239,7 @@ func (s *MessageService) ListConversations(q ConversationListQuery) ([]MessageCo
} }
var total int64 var total int64
err = model.DB.Raw(` err = models.DB.Raw(`
SELECT COUNT(*) FROM ( SELECT COUNT(*) FROM (
SELECT SELECT
CASE CASE
@@ -268,20 +268,20 @@ func (s *MessageService) ListConversations(q ConversationListQuery) ([]MessageCo
} }
} }
var lastMsgs []model.PrivateMessage var lastMsgs []models.PrivateMessage
if err := model.DB.Preload("FromUser").Preload("ToUser"). if err := models.DB.Preload("FromUser").Preload("ToUser").
Where("id IN ?", lastIDs).Find(&lastMsgs).Error; err != nil { Where("id IN ?", lastIDs).Find(&lastMsgs).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
msgByID := make(map[uint]model.PrivateMessage, len(lastMsgs)) msgByID := make(map[uint]models.PrivateMessage, len(lastMsgs))
for i := range lastMsgs { for i := range lastMsgs {
msgByID[lastMsgs[i].ID] = lastMsgs[i] msgByID[lastMsgs[i].ID] = lastMsgs[i]
} }
usersByID := make(map[uint]model.User) usersByID := make(map[uint]models.User)
if len(peerIDs) > 0 { if len(peerIDs) > 0 {
var users []model.User var users []models.User
if err := model.DB.Where("id IN ?", peerIDs).Find(&users).Error; err != nil { if err := models.DB.Where("id IN ?", peerIDs).Find(&users).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
for i := range users { for i := range users {
@@ -294,7 +294,7 @@ func (s *MessageService) ListConversations(q ConversationListQuery) ([]MessageCo
Cnt int64 Cnt int64
} }
var unreadRows []unreadRow var unreadRows []unreadRow
_ = model.DB.Raw(` _ = models.DB.Raw(`
SELECT SELECT
CASE WHEN from_user_id = 0 THEN 0 ELSE from_user_id END AS peer_id, CASE WHEN from_user_id = 0 THEN 0 ELSE from_user_id END AS peer_id,
COUNT(*) AS cnt COUNT(*) AS cnt
@@ -332,13 +332,13 @@ func (s *MessageService) ListConversations(q ConversationListQuery) ([]MessageCo
} }
// ListConversationMessages 某会话内消息(时间正序,支持 Before 向上翻页) // ListConversationMessages 某会话内消息(时间正序,支持 Before 向上翻页)
func (s *MessageService) ListConversationMessages(q ConversationMessagesQuery) ([]model.PrivateMessage, int64, error) { func (s *MessageService) ListConversationMessages(q ConversationMessagesQuery) ([]models.PrivateMessage, int64, error) {
if q.Page < 1 { if q.Page < 1 {
q.Page = 1 q.Page = 1
} }
q.Size = s.settings.NormalizePageSize(q.Size) q.Size = s.settings.NormalizePageSize(q.Size)
countDB := model.DB.Model(&model.PrivateMessage{}) countDB := models.DB.Model(&models.PrivateMessage{})
if q.PeerID == 0 { if q.PeerID == 0 {
countDB = countDB.Where("from_user_id = 0 AND to_user_id = ?", q.UserID) countDB = countDB.Where("from_user_id = 0 AND to_user_id = ?", q.UserID)
} else { } else {
@@ -353,7 +353,7 @@ func (s *MessageService) ListConversationMessages(q ConversationMessagesQuery) (
return nil, 0, err return nil, 0, err
} }
qdb := model.DB.Preload("FromUser").Preload("ToUser") qdb := models.DB.Preload("FromUser").Preload("ToUser")
if q.PeerID == 0 { if q.PeerID == 0 {
qdb = qdb.Where("from_user_id = 0 AND to_user_id = ?", q.UserID) qdb = qdb.Where("from_user_id = 0 AND to_user_id = ?", q.UserID)
} else { } else {
@@ -366,7 +366,7 @@ func (s *MessageService) ListConversationMessages(q ConversationMessagesQuery) (
qdb = qdb.Where("id < ?", q.Before) qdb = qdb.Where("id < ?", q.Before)
} }
var list []model.PrivateMessage var list []models.PrivateMessage
// 先按 id desc 取一页,再反转为正序(聊天从旧到新) // 先按 id desc 取一页,再反转为正序(聊天从旧到新)
err := qdb.Order("id desc").Limit(q.Size).Find(&list).Error err := qdb.Order("id desc").Limit(q.Size).Find(&list).Error
if err != nil { if err != nil {
@@ -380,7 +380,7 @@ func (s *MessageService) ListConversationMessages(q ConversationMessagesQuery) (
// MarkConversationRead 将会话内未读标为已读 // MarkConversationRead 将会话内未读标为已读
func (s *MessageService) MarkConversationRead(userID, peerID uint) error { func (s *MessageService) MarkConversationRead(userID, peerID uint) error {
db := model.DB.Model(&model.PrivateMessage{}). db := models.DB.Model(&models.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ?", userID, false) Where("to_user_id = ? AND is_read = ?", userID, false)
if peerID == 0 { if peerID == 0 {
db = db.Where("from_user_id = 0") db = db.Where("from_user_id = 0")

View File

@@ -1,11 +1,11 @@
package service package services
import ( import (
"fmt" "fmt"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
// NotifyService 站内消息 + 邮件提醒编排 // NotifyService 站内消息 + 邮件提醒编排
@@ -35,7 +35,7 @@ func (s *NotifyService) goNotify(fn func()) {
} }
// AsyncNotifyCommentPublished 异步:评论公开后通知被回复者或楼主 // AsyncNotifyCommentPublished 异步:评论公开后通知被回复者或楼主
func (s *NotifyService) AsyncNotifyCommentPublished(comment *model.Comment) { func (s *NotifyService) AsyncNotifyCommentPublished(comment *models.Comment) {
if s == nil || comment == nil { if s == nil || comment == nil {
return return
} }
@@ -44,7 +44,7 @@ func (s *NotifyService) AsyncNotifyCommentPublished(comment *model.Comment) {
} }
// AsyncNotifyCommentMentions 异步:评论公开后通知被 @ 的用户 // AsyncNotifyCommentMentions 异步:评论公开后通知被 @ 的用户
func (s *NotifyService) AsyncNotifyCommentMentions(comment *model.Comment) { func (s *NotifyService) AsyncNotifyCommentMentions(comment *models.Comment) {
if s == nil || comment == nil { if s == nil || comment == nil {
return return
} }
@@ -53,7 +53,7 @@ func (s *NotifyService) AsyncNotifyCommentMentions(comment *model.Comment) {
} }
// AsyncNotifyPendingPost 异步:待审帖通知管理员 // AsyncNotifyPendingPost 异步:待审帖通知管理员
func (s *NotifyService) AsyncNotifyPendingPost(post *model.Post) { func (s *NotifyService) AsyncNotifyPendingPost(post *models.Post) {
if s == nil || post == nil { if s == nil || post == nil {
return return
} }
@@ -62,7 +62,7 @@ func (s *NotifyService) AsyncNotifyPendingPost(post *model.Post) {
} }
// AsyncNotifyPendingComment 异步:待审评论通知管理员 // AsyncNotifyPendingComment 异步:待审评论通知管理员
func (s *NotifyService) AsyncNotifyPendingComment(comment *model.Comment) { func (s *NotifyService) AsyncNotifyPendingComment(comment *models.Comment) {
if s == nil || comment == nil { if s == nil || comment == nil {
return return
} }
@@ -71,8 +71,8 @@ func (s *NotifyService) AsyncNotifyPendingComment(comment *model.Comment) {
} }
// NotifyCommentPublished 评论公开后通知被回复者或楼主 // NotifyCommentPublished 评论公开后通知被回复者或楼主
func (s *NotifyService) NotifyCommentPublished(comment *model.Comment) { func (s *NotifyService) NotifyCommentPublished(comment *models.Comment) {
if s == nil || comment == nil || comment.Status != model.ContentStatusPublished { if s == nil || comment == nil || comment.Status != models.ContentStatusPublished {
return return
} }
@@ -96,14 +96,14 @@ func (s *NotifyService) NotifyCommentPublished(comment *model.Comment) {
subject := "收到新回复" subject := "收到新回复"
content := FormatReplyContent(authorName, title, displayFloor, isNested) content := FormatReplyContent(authorName, title, displayFloor, isNested)
pid := comment.PostID pid := comment.PostID
_, _ = s.messages.SendSystem(toUserID, subject, content, model.MessageKindReply, &pid, nil) _, _ = s.messages.SendSystem(toUserID, subject, content, models.MessageKindReply, &pid, nil)
s.sendReplyMail(toUserID, authorName, title, comment.PostID, displayFloor, isNested, comment.Content) s.sendReplyMail(toUserID, authorName, title, comment.PostID, displayFloor, isNested, comment.Content)
} }
// NotifyCommentMentions 评论公开后通知被 @提及的用户(跳过已收到回复通知的人) // NotifyCommentMentions 评论公开后通知被 @提及的用户(跳过已收到回复通知的人)
func (s *NotifyService) NotifyCommentMentions(comment *model.Comment) { func (s *NotifyService) NotifyCommentMentions(comment *models.Comment) {
if s == nil || comment == nil || comment.Status != model.ContentStatusPublished { if s == nil || comment == nil || comment.Status != models.ContentStatusPublished {
return return
} }
names := ExtractMentionNames(comment.Content) names := ExtractMentionNames(comment.Content)
@@ -132,13 +132,13 @@ func (s *NotifyService) NotifyCommentMentions(comment *model.Comment) {
if uid == 0 || uid == comment.UserID || uid == replyTo { if uid == 0 || uid == comment.UserID || uid == replyTo {
continue continue
} }
_, _ = s.messages.SendSystem(uid, subject, content, model.MessageKindMention, &pid, nil) _, _ = s.messages.SendSystem(uid, subject, content, models.MessageKindMention, &pid, nil)
} }
} }
// NotifyPendingPost 新帖进入待审时通知全部管理员 // NotifyPendingPost 新帖进入待审时通知全部管理员
func (s *NotifyService) NotifyPendingPost(post *model.Post) { func (s *NotifyService) NotifyPendingPost(post *models.Post) {
if s == nil || post == nil || post.Status != model.ContentStatusPending { if s == nil || post == nil || post.Status != models.ContentStatusPending {
return return
} }
title := strings.TrimSpace(post.Title) title := strings.TrimSpace(post.Title)
@@ -149,14 +149,14 @@ func (s *NotifyService) NotifyPendingPost(post *model.Post) {
subject := "新的待审核帖子" subject := "新的待审核帖子"
content := FormatPendingPostContent(authorName, title, post.ID) content := FormatPendingPostContent(authorName, title, post.ID)
pid := post.ID pid := post.ID
s.notifyAdmins(subject, content, model.MessageKindModeration, &pid, func(siteName, baseURL string) (string, string, string) { s.notifyAdmins(subject, content, models.MessageKindModeration, &pid, func(siteName, baseURL string) (string, string, string) {
return BuildModerationMail(siteName, "帖子", authorName, title, post.ID, 0, false, AbsoluteURL(baseURL, "/admin/posts")) return BuildModerationMail(siteName, "帖子", authorName, title, post.ID, 0, false, AbsoluteURL(baseURL, "/admin/posts"))
}) })
} }
// NotifyPendingComment 新评论进入待审时通知全部管理员 // NotifyPendingComment 新评论进入待审时通知全部管理员
func (s *NotifyService) NotifyPendingComment(comment *model.Comment) { func (s *NotifyService) NotifyPendingComment(comment *models.Comment) {
if s == nil || comment == nil || comment.Status != model.ContentStatusPending { if s == nil || comment == nil || comment.Status != models.ContentStatusPending {
return return
} }
post, err := s.loadPost(comment.PostID) post, err := s.loadPost(comment.PostID)
@@ -173,7 +173,7 @@ func (s *NotifyService) NotifyPendingComment(comment *model.Comment) {
isNested := comment.ReplyTo != nil && *comment.ReplyTo > 0 isNested := comment.ReplyTo != nil && *comment.ReplyTo > 0
content := FormatPendingCommentContent(authorName, title, displayFloor, isNested) content := FormatPendingCommentContent(authorName, title, displayFloor, isNested)
pid := comment.PostID pid := comment.PostID
s.notifyAdmins(subject, content, model.MessageKindModeration, &pid, func(siteName, baseURL string) (string, string, string) { s.notifyAdmins(subject, content, models.MessageKindModeration, &pid, func(siteName, baseURL string) (string, string, string) {
return BuildModerationMail(siteName, "评论", authorName, title, comment.PostID, displayFloor, isNested, AbsoluteURL(baseURL, "/admin/comments")) return BuildModerationMail(siteName, "评论", authorName, title, comment.PostID, displayFloor, isNested, AbsoluteURL(baseURL, "/admin/comments"))
}) })
} }
@@ -215,8 +215,8 @@ func (s *NotifyService) sendReplyMail(toUserID uint, authorName, postTitle strin
if s.mail == nil || !s.settings.MailReady() { if s.mail == nil || !s.settings.MailReady() {
return return
} }
var user model.User var user models.User
if err := model.DB.Select("id", "email", "nickname", "username").First(&user, toUserID).Error; err != nil { if err := models.DB.Select("id", "email", "nickname", "username").First(&user, toUserID).Error; err != nil {
return return
} }
email := strings.TrimSpace(user.Email) email := strings.TrimSpace(user.Email)
@@ -232,10 +232,10 @@ func (s *NotifyService) sendReplyMail(toUserID uint, authorName, postTitle strin
_ = s.mail.SendHTML(email, subj, text, html) _ = s.mail.SendHTML(email, subj, text, html)
} }
func (s *NotifyService) resolveReplyRecipient(comment *model.Comment, post *model.Post) (uint, error) { func (s *NotifyService) resolveReplyRecipient(comment *models.Comment, post *models.Post) (uint, error) {
if comment.ReplyTo != nil && *comment.ReplyTo > 0 { if comment.ReplyTo != nil && *comment.ReplyTo > 0 {
var target model.Comment var target models.Comment
if err := model.DB.Select("id", "user_id", "post_id"). if err := models.DB.Select("id", "user_id", "post_id").
Where("id = ? AND post_id = ?", *comment.ReplyTo, comment.PostID). Where("id = ? AND post_id = ?", *comment.ReplyTo, comment.PostID).
First(&target).Error; err != nil { First(&target).Error; err != nil {
return 0, err return 0, err
@@ -249,7 +249,7 @@ func (s *NotifyService) resolveReplyRecipient(comment *model.Comment, post *mode
} }
// resolveDisplayFloor 解析页面可见的顶层楼号(子回复沿 reply_to 上溯) // resolveDisplayFloor 解析页面可见的顶层楼号(子回复沿 reply_to 上溯)
func (s *NotifyService) resolveDisplayFloor(comment *model.Comment) int { func (s *NotifyService) resolveDisplayFloor(comment *models.Comment) int {
if comment == nil { if comment == nil {
return 0 return 0
} }
@@ -264,8 +264,8 @@ func (s *NotifyService) resolveDisplayFloor(comment *model.Comment) int {
break break
} }
seen[curID] = struct{}{} seen[curID] = struct{}{}
var ancestor model.Comment var ancestor models.Comment
if err := model.DB.Select("id", "floor", "reply_to"). if err := models.DB.Select("id", "floor", "reply_to").
Where("id = ? AND post_id = ?", curID, comment.PostID). Where("id = ? AND post_id = ?", curID, comment.PostID).
First(&ancestor).Error; err != nil { First(&ancestor).Error; err != nil {
return comment.Floor return comment.Floor
@@ -278,18 +278,18 @@ func (s *NotifyService) resolveDisplayFloor(comment *model.Comment) int {
return comment.Floor return comment.Floor
} }
func (s *NotifyService) loadPost(postID uint) (*model.Post, error) { func (s *NotifyService) loadPost(postID uint) (*models.Post, error) {
var post model.Post var post models.Post
if err := model.DB.Select("id", "user_id", "title", "status").First(&post, postID).Error; err != nil { if err := models.DB.Select("id", "user_id", "title", "status").First(&post, postID).Error; err != nil {
return nil, err return nil, err
} }
return &post, nil return &post, nil
} }
func (s *NotifyService) listAdmins() ([]model.User, error) { func (s *NotifyService) listAdmins() ([]models.User, error) {
var admins []model.User var admins []models.User
err := model.DB.Select("id", "email", "nickname", "username"). err := models.DB.Select("id", "email", "nickname", "username").
Where("role = ? AND banned = ?", model.RoleAdmin, false). Where("role = ? AND banned = ?", models.RoleAdmin, false).
Find(&admins).Error Find(&admins).Error
return admins, err return admins, err
} }
@@ -302,7 +302,7 @@ func (s *NotifyService) siteName() string {
return name return name
} }
func (s *NotifyService) commentAuthorName(comment *model.Comment) string { func (s *NotifyService) commentAuthorName(comment *models.Comment) string {
if comment.UserID > 0 { if comment.UserID > 0 {
if comment.User.ID == comment.UserID { if comment.User.ID == comment.UserID {
if n := DisplayName(&comment.User); n != "" { if n := DisplayName(&comment.User); n != "" {
@@ -321,8 +321,8 @@ func (s *NotifyService) userDisplayName(userID uint) string {
if userID == 0 { if userID == 0 {
return "用户" return "用户"
} }
var u model.User var u models.User
if err := model.DB.Select("id", "nickname", "username").First(&u, userID).Error; err != nil { if err := models.DB.Select("id", "nickname", "username").First(&u, userID).Error; err != nil {
return fmt.Sprintf("用户 #%d", userID) return fmt.Sprintf("用户 #%d", userID)
} }
if n := DisplayName(&u); n != "" { if n := DisplayName(&u); n != "" {

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"crypto/rand" "crypto/rand"
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
var ( var (
@@ -44,8 +44,8 @@ type OAuthClientInput struct {
// ListOAuthClients 列出全部 OAuth 应用 // ListOAuthClients 列出全部 OAuth 应用
func (s *ForumSettingsService) ListOAuthClients() ([]OAuthClientView, error) { func (s *ForumSettingsService) ListOAuthClients() ([]OAuthClientView, error) {
var rows []model.OAuthClient var rows []models.OAuthClient
if err := model.DB.Order("id asc").Find(&rows).Error; err != nil { if err := models.DB.Order("id asc").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
out := make([]OAuthClientView, 0, len(rows)) out := make([]OAuthClientView, 0, len(rows))
@@ -64,7 +64,7 @@ func (s *ForumSettingsService) CreateOAuthClient(in OAuthClientInput) (*OAuthCli
return nil, ErrOAuthClientInvalid return nil, ErrOAuthClientInvalid
} }
var n int64 var n int64
model.DB.Model(&model.OAuthClient{}).Where("client_id = ?", clientID).Count(&n) models.DB.Model(&models.OAuthClient{}).Where("client_id = ?", clientID).Count(&n)
if n > 0 { if n > 0 {
return nil, ErrOAuthClientExists return nil, ErrOAuthClientExists
} }
@@ -85,14 +85,14 @@ func (s *ForumSettingsService) CreateOAuthClient(in OAuthClientInput) (*OAuthCli
if in.Enabled != nil { if in.Enabled != nil {
enabled = *in.Enabled enabled = *in.Enabled
} }
row := model.OAuthClient{ row := models.OAuthClient{
ClientID: clientID, ClientID: clientID,
ClientSecretHash: hash, ClientSecretHash: hash,
Name: name, Name: name,
RedirectURIs: uris, RedirectURIs: uris,
Enabled: enabled, Enabled: enabled,
} }
if err := model.DB.Create(&row).Error; err != nil { if err := models.DB.Create(&row).Error; err != nil {
return nil, err return nil, err
} }
v := toOAuthClientView(row, plain) v := toOAuthClientView(row, plain)
@@ -101,8 +101,8 @@ func (s *ForumSettingsService) CreateOAuthClient(in OAuthClientInput) (*OAuthCli
// UpdateOAuthClient 更新应用 // UpdateOAuthClient 更新应用
func (s *ForumSettingsService) UpdateOAuthClient(id uint, in OAuthClientInput) (*OAuthClientView, error) { func (s *ForumSettingsService) UpdateOAuthClient(id uint, in OAuthClientInput) (*OAuthClientView, error) {
var row model.OAuthClient var row models.OAuthClient
if err := model.DB.First(&row, id).Error; err != nil { if err := models.DB.First(&row, id).Error; err != nil {
return nil, ErrOAuthClientNotFound return nil, ErrOAuthClientNotFound
} }
name := strings.TrimSpace(in.Name) name := strings.TrimSpace(in.Name)
@@ -137,7 +137,7 @@ func (s *ForumSettingsService) UpdateOAuthClient(id uint, in OAuthClientInput) (
row.ClientSecretHash = hash row.ClientSecretHash = hash
} }
if err := model.DB.Save(&row).Error; err != nil { if err := models.DB.Save(&row).Error; err != nil {
return nil, err return nil, err
} }
v := toOAuthClientView(row, plain) v := toOAuthClientView(row, plain)
@@ -146,7 +146,7 @@ func (s *ForumSettingsService) UpdateOAuthClient(id uint, in OAuthClientInput) (
// DeleteOAuthClient 删除应用 // DeleteOAuthClient 删除应用
func (s *ForumSettingsService) DeleteOAuthClient(id uint) error { func (s *ForumSettingsService) DeleteOAuthClient(id uint) error {
res := model.DB.Delete(&model.OAuthClient{}, id) res := models.DB.Delete(&models.OAuthClient{}, id)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -157,23 +157,23 @@ func (s *ForumSettingsService) DeleteOAuthClient(id uint) error {
} }
// FindEnabledOAuthClient 按 client_id 查找已启用应用 // FindEnabledOAuthClient 按 client_id 查找已启用应用
func FindEnabledOAuthClient(clientID string) (*model.OAuthClient, error) { func FindEnabledOAuthClient(clientID string) (*models.OAuthClient, error) {
var row model.OAuthClient var row models.OAuthClient
if err := model.DB.Where("client_id = ? AND enabled = ?", clientID, true).First(&row).Error; err != nil { if err := models.DB.Where("client_id = ? AND enabled = ?", clientID, true).First(&row).Error; err != nil {
return nil, ErrOIDCInvalidClient return nil, ErrOIDCInvalidClient
} }
return &row, nil return &row, nil
} }
// VerifyOAuthClientSecret 校验客户端密钥bcrypt 哈希) // VerifyOAuthClientSecret 校验客户端密钥bcrypt 哈希)
func VerifyOAuthClientSecret(row *model.OAuthClient, secret string) bool { func VerifyOAuthClientSecret(row *models.OAuthClient, secret string) bool {
if row == nil || secret == "" || row.ClientSecretHash == "" { if row == nil || secret == "" || row.ClientSecretHash == "" {
return false return false
} }
return CheckPassword(row.ClientSecretHash, secret) return CheckPassword(row.ClientSecretHash, secret)
} }
func toOAuthClientView(row model.OAuthClient, plainSecret string) OAuthClientView { func toOAuthClientView(row models.OAuthClient, plainSecret string) OAuthClientView {
return OAuthClientView{ return OAuthClientView{
ID: row.ID, ID: row.ID,
ClientID: row.ClientID, ClientID: row.ClientID,
@@ -198,6 +198,6 @@ func generateClientSecret() (string, error) {
// CountEnabledOAuthClients 已启用客户端数量 // CountEnabledOAuthClients 已启用客户端数量
func CountEnabledOAuthClients() int64 { func CountEnabledOAuthClients() int64 {
var n int64 var n int64
model.DB.Model(&model.OAuthClient{}).Where("enabled = ?", true).Count(&n) models.DB.Model(&models.OAuthClient{}).Where("enabled = ?", true).Count(&n)
return n return n
} }

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"crypto/rand" "crypto/rand"
@@ -20,7 +20,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
const ( const (
@@ -242,8 +242,8 @@ func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string,
if err := s.ValidateAuthorize(req); err != nil { if err := s.ValidateAuthorize(req); err != nil {
return "", err return "", err
} }
var user model.User var user models.User
if err := model.DB.First(&user, userID).Error; err != nil { if err := models.DB.First(&user, userID).Error; err != nil {
return "", ErrOIDCInvalidRequest return "", ErrOIDCInvalidRequest
} }
if user.Banned { if user.Banned {
@@ -258,7 +258,7 @@ func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string,
if req.CodeChallenge != "" && method == "" { if req.CodeChallenge != "" && method == "" {
method = "PLAIN" method = "PLAIN"
} }
rec := &model.OAuthAuthCode{ rec := &models.OAuthAuthCode{
Code: code, Code: code,
ClientID: req.ClientID, ClientID: req.ClientID,
UserID: user.ID, UserID: user.ID,
@@ -269,7 +269,7 @@ func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string,
CodeChallengeMethod: method, CodeChallengeMethod: method,
ExpiresAt: time.Now().Add(oidcAuthCodeTTL), ExpiresAt: time.Now().Add(oidcAuthCodeTTL),
} }
if err := model.DB.Create(rec).Error; err != nil { if err := models.DB.Create(rec).Error; err != nil {
return "", err return "", err
} }
@@ -320,14 +320,14 @@ func (s *OIDCService) ExchangeCode(req TokenRequest) (*TokenResponse, error) {
return nil, ErrOIDCInvalidClient return nil, ErrOIDCInvalidClient
} }
var rec model.OAuthAuthCode var rec models.OAuthAuthCode
if err := model.DB.Where("code = ?", req.Code).First(&rec).Error; err != nil { if err := models.DB.Where("code = ?", req.Code).First(&rec).Error; err != nil {
return nil, ErrOIDCInvalidGrant return nil, ErrOIDCInvalidGrant
} }
if rec.Used || time.Now().After(rec.ExpiresAt) { if rec.Used || time.Now().After(rec.ExpiresAt) {
// 重放:作废同用户同客户端未过期码 // 重放:作废同用户同客户端未过期码
if rec.Used { if rec.Used {
_ = model.DB.Model(&model.OAuthAuthCode{}). _ = models.DB.Model(&models.OAuthAuthCode{}).
Where("client_id = ? AND user_id = ? AND used = ? AND expires_at > ?", Where("client_id = ? AND user_id = ? AND used = ? AND expires_at > ?",
rec.ClientID, rec.UserID, false, time.Now()). rec.ClientID, rec.UserID, false, time.Now()).
Update("used", true).Error Update("used", true).Error
@@ -342,10 +342,10 @@ func (s *OIDCService) ExchangeCode(req TokenRequest) (*TokenResponse, error) {
} }
rec.Used = true rec.Used = true
_ = model.DB.Save(&rec).Error _ = models.DB.Save(&rec).Error
var user model.User var user models.User
if err := model.DB.First(&user, rec.UserID).Error; err != nil || user.Banned { if err := models.DB.First(&user, rec.UserID).Error; err != nil || user.Banned {
return nil, ErrOIDCInvalidGrant return nil, ErrOIDCInvalidGrant
} }
@@ -408,7 +408,7 @@ type oidcIDClaims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
func (s *OIDCService) signAccessToken(user *model.User, scope, clientID string) (string, error) { func (s *OIDCService) signAccessToken(user *models.User, scope, clientID string) (string, error) {
now := time.Now() now := time.Now()
issuer := s.Issuer() issuer := s.Issuer()
claims := oidcAccessClaims{ claims := oidcAccessClaims{
@@ -429,7 +429,7 @@ func (s *OIDCService) signAccessToken(user *model.User, scope, clientID string)
return t.SignedString(s.privateKey) return t.SignedString(s.privateKey)
} }
func (s *OIDCService) signIDToken(user *model.User, scope, clientID, nonce string) (string, error) { func (s *OIDCService) signIDToken(user *models.User, scope, clientID, nonce string) (string, error) {
now := time.Now() now := time.Now()
issuer := s.Issuer() issuer := s.Issuer()
claims := oidcIDClaims{ claims := oidcIDClaims{
@@ -462,13 +462,13 @@ func (s *OIDCService) signIDToken(user *model.User, scope, clientID, nonce strin
return t.SignedString(s.privateKey) return t.SignedString(s.privateKey)
} }
func (s *OIDCService) userGroups(user *model.User) []string { func (s *OIDCService) userGroups(user *models.User) []string {
rt := s.runtime() rt := s.runtime()
groups := make([]string, 0, 2) groups := make([]string, 0, 2)
if rt.UserGroup != "" { if rt.UserGroup != "" {
groups = append(groups, rt.UserGroup) groups = append(groups, rt.UserGroup)
} }
if user.Role == model.RoleAdmin && rt.AdminGroup != "" { if user.Role == models.RoleAdmin && rt.AdminGroup != "" {
groups = append(groups, rt.AdminGroup) groups = append(groups, rt.AdminGroup)
} }
return groups return groups
@@ -484,8 +484,8 @@ func (s *OIDCService) UserInfo(accessToken string) (map[string]any, error) {
if err != nil { if err != nil {
return nil, ErrOIDCInvalidToken return nil, ErrOIDCInvalidToken
} }
var user model.User var user models.User
if err := model.DB.First(&user, uint(uid)).Error; err != nil || user.Banned { if err := models.DB.First(&user, uint(uid)).Error; err != nil || user.Banned {
return nil, ErrOIDCInvalidToken return nil, ErrOIDCInvalidToken
} }
rt := s.runtime() rt := s.runtime()
@@ -536,8 +536,8 @@ func (s *OIDCService) ResolveLogoutRedirect(postLogoutRedirectURI, state string)
if uri == "" { if uri == "" {
return "/", nil return "/", nil
} }
var clients []model.OAuthClient var clients []models.OAuthClient
if err := model.DB.Where("enabled = ?", true).Find(&clients).Error; err != nil { if err := models.DB.Where("enabled = ?", true).Find(&clients).Error; err != nil {
return "", err return "", err
} }
allowed := false allowed := false

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"crypto/rand" "crypto/rand"
@@ -7,7 +7,7 @@ import (
"math/big" "math/big"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -31,13 +31,13 @@ func todayLocal() string {
// AdjustPointsTx 在已有事务内调整积分并写流水;返回变动后余额 // AdjustPointsTx 在已有事务内调整积分并写流水;返回变动后余额
func AdjustPointsTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { func AdjustPointsTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
if delta == 0 { if delta == 0 {
var u model.User var u models.User
if err := tx.Select("points").First(&u, userID).Error; err != nil { if err := tx.Select("points").First(&u, userID).Error; err != nil {
return 0, err return 0, err
} }
return u.Points, nil return u.Points, nil
} }
var user model.User var user models.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
return 0, err return 0, err
} }
@@ -48,7 +48,7 @@ func AdjustPointsTx(tx *gorm.DB, userID uint, delta int, reason, refType string,
if err := tx.Model(&user).Update("points", newBal).Error; err != nil { if err := tx.Model(&user).Update("points", newBal).Error; err != nil {
return 0, err return 0, err
} }
led := model.PointLedger{ led := models.PointLedger{
UserID: userID, UserID: userID,
Delta: delta, Delta: delta,
Balance: newBal, Balance: newBal,
@@ -66,7 +66,7 @@ func AdjustPointsTx(tx *gorm.DB, userID uint, delta int, reason, refType string,
// AdjustPoints 独立事务调整积分 // AdjustPoints 独立事务调整积分
func (s *PointsService) AdjustPoints(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { func (s *PointsService) AdjustPoints(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
var bal int var bal int
err := model.DB.Transaction(func(tx *gorm.DB) error { err := models.DB.Transaction(func(tx *gorm.DB) error {
var e error var e error
bal, e = AdjustPointsTx(tx, userID, delta, reason, refType, refID, note) bal, e = AdjustPointsTx(tx, userID, delta, reason, refType, refID, note)
return e return e
@@ -79,7 +79,7 @@ func (s *PointsService) AdminAdjust(userID uint, delta int, note string) (int, e
if delta == 0 { if delta == 0 {
return 0, ErrInvalidPointsDelta return 0, ErrInvalidPointsDelta
} }
return s.AdjustPoints(userID, delta, model.PointReasonAdminAdjust, "admin", 0, note) return s.AdjustPoints(userID, delta, models.PointReasonAdminAdjust, "admin", 0, note)
} }
// CheckInStatus 今日签到状态 // CheckInStatus 今日签到状态
@@ -93,8 +93,8 @@ type CheckInStatus struct {
func (s *PointsService) GetCheckInStatus(userID uint) (CheckInStatus, error) { func (s *PointsService) GetCheckInStatus(userID uint) (CheckInStatus, error) {
day := todayLocal() day := todayLocal()
st := CheckInStatus{Day: day} st := CheckInStatus{Day: day}
var row model.CheckIn var row models.CheckIn
err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error err := models.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error
if err != nil { if err != nil {
return st, err return st, err
} }
@@ -112,8 +112,8 @@ func (s *PointsService) GetCheckInStatus(userID uint) (CheckInStatus, error) {
func (s *PointsService) computeNextStreak(userID uint, today string) int { func (s *PointsService) computeNextStreak(userID uint, today string) int {
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
var prev model.CheckIn var prev models.CheckIn
model.DB.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev) models.DB.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev)
if prev.ID > 0 { if prev.ID > 0 {
return prev.Streak + 1 return prev.Streak + 1
} }
@@ -136,8 +136,8 @@ func checkInReward(streak int) int {
func (s *PointsService) CheckIn(userID uint) (CheckInStatus, error) { func (s *PointsService) CheckIn(userID uint) (CheckInStatus, error) {
day := todayLocal() day := todayLocal()
var out CheckInStatus var out CheckInStatus
err := model.DB.Transaction(func(tx *gorm.DB) error { err := models.DB.Transaction(func(tx *gorm.DB) error {
var existing model.CheckIn var existing models.CheckIn
if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil { if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil {
return err return err
} }
@@ -145,18 +145,18 @@ func (s *PointsService) CheckIn(userID uint) (CheckInStatus, error) {
return ErrAlreadyCheckedIn return ErrAlreadyCheckedIn
} }
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
var prev model.CheckIn var prev models.CheckIn
_ = tx.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev).Error _ = tx.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev).Error
streak := 1 streak := 1
if prev.ID > 0 { if prev.ID > 0 {
streak = prev.Streak + 1 streak = prev.Streak + 1
} }
pts := checkInReward(streak) pts := checkInReward(streak)
row := model.CheckIn{UserID: userID, Day: day, Points: pts, Streak: streak} row := models.CheckIn{UserID: userID, Day: day, Points: pts, Streak: streak}
if err := tx.Create(&row).Error; err != nil { if err := tx.Create(&row).Error; err != nil {
return err return err
} }
if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonCheckIn, "check_in", row.ID, fmt.Sprintf("连续签到 %d 天", streak)); err != nil { if _, err := AdjustPointsTx(tx, userID, pts, models.PointReasonCheckIn, "check_in", row.ID, fmt.Sprintf("连续签到 %d 天", streak)); err != nil {
return err return err
} }
out = CheckInStatus{CheckedIn: true, Streak: streak, TodayPoints: pts, Day: day} out = CheckInStatus{CheckedIn: true, Streak: streak, TodayPoints: pts, Day: day}
@@ -191,8 +191,8 @@ type LotteryStatus struct {
func (s *PointsService) GetLotteryStatus(userID uint) (LotteryStatus, error) { func (s *PointsService) GetLotteryStatus(userID uint) (LotteryStatus, error) {
day := todayLocal() day := todayLocal()
st := LotteryStatus{Day: day, Pool: defaultLotteryPool, Cost: 0} st := LotteryStatus{Day: day, Pool: defaultLotteryPool, Cost: 0}
var row model.LotteryDraw var row models.LotteryDraw
if err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error; err != nil { if err := models.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error; err != nil {
return st, err return st, err
} }
if row.ID > 0 { if row.ID > 0 {
@@ -228,8 +228,8 @@ func pickLottery(pool []LotteryPrize) (int, error) {
func (s *PointsService) DrawLottery(userID uint) (LotteryStatus, error) { func (s *PointsService) DrawLottery(userID uint) (LotteryStatus, error) {
day := todayLocal() day := todayLocal()
var out LotteryStatus var out LotteryStatus
err := model.DB.Transaction(func(tx *gorm.DB) error { err := models.DB.Transaction(func(tx *gorm.DB) error {
var existing model.LotteryDraw var existing models.LotteryDraw
if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil { if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil {
return err return err
} }
@@ -240,12 +240,12 @@ func (s *PointsService) DrawLottery(userID uint) (LotteryStatus, error) {
if err != nil { if err != nil {
return err return err
} }
row := model.LotteryDraw{UserID: userID, Day: day, Points: pts} row := models.LotteryDraw{UserID: userID, Day: day, Points: pts}
if err := tx.Create(&row).Error; err != nil { if err := tx.Create(&row).Error; err != nil {
return err return err
} }
if pts > 0 { if pts > 0 {
if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonLottery, "lottery", row.ID, "每日抽奖"); err != nil { if _, err := AdjustPointsTx(tx, userID, pts, models.PointReasonLottery, "lottery", row.ID, "每日抽奖"); err != nil {
return err return err
} }
} }
@@ -256,7 +256,7 @@ func (s *PointsService) DrawLottery(userID uint) (LotteryStatus, error) {
} }
// ListLedger 积分流水 // ListLedger 积分流水
func (s *PointsService) ListLedger(userID uint, page, size int) ([]model.PointLedger, int64, error) { func (s *PointsService) ListLedger(userID uint, page, size int) ([]models.PointLedger, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -264,9 +264,9 @@ func (s *PointsService) ListLedger(userID uint, page, size int) ([]model.PointLe
size = 20 size = 20
} }
var total int64 var total int64
model.DB.Model(&model.PointLedger{}).Where("user_id = ?", userID).Count(&total) models.DB.Model(&models.PointLedger{}).Where("user_id = ?", userID).Count(&total)
var rows []model.PointLedger var rows []models.PointLedger
err := model.DB.Where("user_id = ?", userID).Order("id desc"). err := models.DB.Where("user_id = ?", userID).Order("id desc").
Offset((page - 1) * size).Limit(size).Find(&rows).Error Offset((page - 1) * size).Limit(size).Find(&rows).Error
return rows, total, err return rows, total, err
} }

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"encoding/json" "encoding/json"
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -54,7 +54,7 @@ func CreatePollForPost(tx *gorm.DB, postID uint, multi bool, maxChoices int, end
} else if maxChoices < 1 || maxChoices > len(options) { } else if maxChoices < 1 || maxChoices > len(options) {
maxChoices = len(options) maxChoices = len(options)
} }
poll := model.Poll{PostID: postID, Multi: multi, MaxChoices: maxChoices, EndsAt: endsAt} poll := models.Poll{PostID: postID, Multi: multi, MaxChoices: maxChoices, EndsAt: endsAt}
if err := tx.Create(&poll).Error; err != nil { if err := tx.Create(&poll).Error; err != nil {
return err return err
} }
@@ -66,7 +66,7 @@ func CreatePollForPost(tx *gorm.DB, postID uint, multi bool, maxChoices int, end
if len([]rune(text)) > 64 { if len([]rune(text)) > 64 {
return errors.New("投票选项最多 64 字") return errors.New("投票选项最多 64 字")
} }
row := model.PollOption{PostID: postID, Text: text, SortOrder: i} row := models.PollOption{PostID: postID, Text: text, SortOrder: i}
if err := tx.Create(&row).Error; err != nil { if err := tx.Create(&row).Error; err != nil {
return err return err
} }
@@ -131,8 +131,8 @@ func parsePollEndsAt(raw string) (*time.Time, error) {
// closePollIfExpired 若已过截止时间则自动关闭投票 // closePollIfExpired 若已过截止时间则自动关闭投票
func closePollIfExpired(postID uint) error { func closePollIfExpired(postID uint) error {
var poll model.Poll var poll models.Poll
if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil { if err := models.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
return err return err
} }
if poll.Closed || poll.EndsAt == nil { if poll.Closed || poll.EndsAt == nil {
@@ -141,7 +141,7 @@ func closePollIfExpired(postID uint) error {
if time.Now().Before(*poll.EndsAt) { if time.Now().Before(*poll.EndsAt) {
return nil return nil
} }
res := model.DB.Model(&poll).Where("post_id = ? AND closed = ?", postID, false).Update("closed", true) res := models.DB.Model(&poll).Where("post_id = ? AND closed = ?", postID, false).Update("closed", true)
return res.Error return res.Error
} }
@@ -150,12 +150,12 @@ func GetPollView(postID, viewerID uint) (*PollView, error) {
if err := closePollIfExpired(postID); err != nil { if err := closePollIfExpired(postID); err != nil {
return nil, err return nil, err
} }
var poll model.Poll var poll models.Poll
if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil { if err := models.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
return nil, err return nil, err
} }
var opts []model.PollOption var opts []models.PollOption
if err := model.DB.Where("post_id = ?", postID).Order("sort_order ASC, id ASC").Find(&opts).Error; err != nil { if err := models.DB.Where("post_id = ?", postID).Order("sort_order ASC, id ASC").Find(&opts).Error; err != nil {
return nil, err return nil, err
} }
total := 0 total := 0
@@ -165,8 +165,8 @@ func GetPollView(postID, viewerID uint) (*PollView, error) {
showResults := poll.Closed showResults := poll.Closed
var myIDs []uint var myIDs []uint
if viewerID > 0 { if viewerID > 0 {
var votes []model.PollVote var votes []models.PollVote
model.DB.Where("post_id = ? AND user_id = ?", postID, viewerID).Find(&votes) models.DB.Where("post_id = ? AND user_id = ?", postID, viewerID).Find(&votes)
for _, v := range votes { for _, v := range votes {
myIDs = append(myIDs, v.OptionID) myIDs = append(myIDs, v.OptionID)
} }
@@ -196,15 +196,15 @@ func VotePoll(postID, userID uint, optionIDs []uint) error {
if err := closePollIfExpired(postID); err != nil { if err := closePollIfExpired(postID); err != nil {
return err return err
} }
var poll model.Poll var poll models.Poll
if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil { if err := models.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
return err return err
} }
if poll.Closed { if poll.Closed {
return ErrPollClosed return ErrPollClosed
} }
var existing int64 var existing int64
model.DB.Model(&model.PollVote{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&existing) models.DB.Model(&models.PollVote{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&existing)
if existing > 0 { if existing > 0 {
return ErrPollAlreadyVoted return ErrPollAlreadyVoted
} }
@@ -223,18 +223,18 @@ func VotePoll(postID, userID uint, optionIDs []uint) error {
return ErrPollInvalidVote return ErrPollInvalidVote
} }
seen[oid] = true seen[oid] = true
var opt model.PollOption var opt models.PollOption
if err := model.DB.Where("id = ? AND post_id = ?", oid, postID).First(&opt).Error; err != nil { if err := models.DB.Where("id = ? AND post_id = ?", oid, postID).First(&opt).Error; err != nil {
return ErrPollInvalidVote return ErrPollInvalidVote
} }
} }
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
for _, oid := range optionIDs { for _, oid := range optionIDs {
v := model.PollVote{PostID: postID, OptionID: oid, UserID: userID} v := models.PollVote{PostID: postID, OptionID: oid, UserID: userID}
if err := tx.Create(&v).Error; err != nil { if err := tx.Create(&v).Error; err != nil {
return err return err
} }
if err := tx.Model(&model.PollOption{}).Where("id = ?", oid). if err := tx.Model(&models.PollOption{}).Where("id = ?", oid).
UpdateColumn("vote_count", gorm.Expr("vote_count + 1")).Error; err != nil { UpdateColumn("vote_count", gorm.Expr("vote_count + 1")).Error; err != nil {
return err return err
} }
@@ -248,7 +248,7 @@ func ClosePoll(postID, userID uint, isAdmin bool, postAuthorID uint) error {
if !isAdmin && userID != postAuthorID { if !isAdmin && userID != postAuthorID {
return ErrPermissionDenied return ErrPermissionDenied
} }
res := model.DB.Model(&model.Poll{}).Where("post_id = ?", postID).Update("closed", true) res := models.DB.Model(&models.Poll{}).Where("post_id = ?", postID).Update("closed", true)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -261,20 +261,20 @@ func ClosePoll(postID, userID uint, isAdmin bool, postAuthorID uint) error {
// LockPollOptions 编辑时锁定选项(已发布帖不允许改选项文案) // LockPollOptions 编辑时锁定选项(已发布帖不允许改选项文案)
func LockPollOptions(postID uint) bool { func LockPollOptions(postID uint) bool {
var n int64 var n int64
model.DB.Model(&model.PollVote{}).Where("post_id = ?", postID).Count(&n) models.DB.Model(&models.PollVote{}).Where("post_id = ?", postID).Count(&n)
return n > 0 return n > 0
} }
// EnsurePollExists 检查投票帖是否有 poll 记录 // EnsurePollExists 检查投票帖是否有 poll 记录
func EnsurePollExists(postID uint) bool { func EnsurePollExists(postID uint) bool {
var n int64 var n int64
model.DB.Model(&model.Poll{}).Where("post_id = ?", postID).Count(&n) models.DB.Model(&models.Poll{}).Where("post_id = ?", postID).Count(&n)
return n > 0 return n > 0
} }
// DeletePollData 删帖时清理投票数据 // DeletePollData 删帖时清理投票数据
func DeletePollData(tx *gorm.DB, postID uint) { func DeletePollData(tx *gorm.DB, postID uint) {
tx.Where("post_id = ?", postID).Delete(&model.PollVote{}) tx.Where("post_id = ?", postID).Delete(&models.PollVote{})
tx.Where("post_id = ?", postID).Delete(&model.PollOption{}) tx.Where("post_id = ?", postID).Delete(&models.PollOption{})
tx.Where("post_id = ?", postID).Delete(&model.Poll{}) tx.Where("post_id = ?", postID).Delete(&models.Poll{})
} }

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -21,21 +21,21 @@ func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *Po
func normalizePostType(raw string) string { func normalizePostType(raw string) string {
switch strings.TrimSpace(raw) { switch strings.TrimSpace(raw) {
case model.PostTypeQuestion: case models.PostTypeQuestion:
return model.PostTypeQuestion return models.PostTypeQuestion
case model.PostTypePoll: case models.PostTypePoll:
return model.PostTypePoll return models.PostTypePoll
case model.PostTypeBounty: case models.PostTypeBounty:
return model.PostTypeBounty return models.PostTypeBounty
case model.PostTypeLottery: case models.PostTypeLottery:
return model.PostTypeLottery return models.PostTypeLottery
default: default:
return model.PostTypeNormal return models.PostTypeNormal
} }
} }
func isSpecialPostType(t string) bool { func isSpecialPostType(t string) bool {
return t == model.PostTypePoll || t == model.PostTypeBounty || t == model.PostTypeLottery return t == models.PostTypePoll || t == models.PostTypeBounty || t == models.PostTypeLottery
} }
type PostListQuery struct { type PostListQuery struct {
@@ -55,16 +55,16 @@ type PostListQuery struct {
// PostListItem 帖子列表项(含评论数等扩展字段) // PostListItem 帖子列表项(含评论数等扩展字段)
type PostListItem struct { type PostListItem struct {
model.Post models.Post
CommentCount int `json:"comment_count"` CommentCount int `json:"comment_count"`
LastReplyAt *time.Time `json:"last_reply_at,omitempty"` LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
LastReplyUser *model.User `json:"last_reply_user,omitempty"` LastReplyUser *models.User `json:"last_reply_user,omitempty"`
LastReplyGuestNick string `json:"last_reply_guest_nick,omitempty"` LastReplyGuestNick string `json:"last_reply_guest_nick,omitempty"`
} }
type lastReplyInfo struct { type lastReplyInfo struct {
At *time.Time At *time.Time
User *model.User User *models.User
GuestNick string GuestNick string
} }
@@ -102,8 +102,8 @@ func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
Count int Count int
} }
var rows []row var rows []row
model.DB.Model(&model.Comment{}).Select("post_id, count(*) as count"). models.DB.Model(&models.Comment{}).Select("post_id, count(*) as count").
Where("post_id IN ? AND status = ?", postIDs, model.ContentStatusPublished). Where("post_id IN ? AND status = ?", postIDs, models.ContentStatusPublished).
Group("post_id").Scan(&rows) Group("post_id").Scan(&rows)
m := make(map[uint]int) m := make(map[uint]int)
for _, r := range rows { for _, r := range rows {
@@ -122,9 +122,9 @@ func (s *PostService) lastReplyInfoMap(postIDs []uint) map[uint]lastReplyInfo {
MaxID uint MaxID uint
} }
var idRows []idRow var idRows []idRow
model.DB.Model(&model.Comment{}). models.DB.Model(&models.Comment{}).
Select("post_id, MAX(id) as max_id"). Select("post_id, MAX(id) as max_id").
Where("post_id IN ? AND status = ?", postIDs, model.ContentStatusPublished). Where("post_id IN ? AND status = ?", postIDs, models.ContentStatusPublished).
Group("post_id"). Group("post_id").
Scan(&idRows) Scan(&idRows)
if len(idRows) == 0 { if len(idRows) == 0 {
@@ -134,8 +134,8 @@ func (s *PostService) lastReplyInfoMap(postIDs []uint) map[uint]lastReplyInfo {
for i, r := range idRows { for i, r := range idRows {
commentIDs[i] = r.MaxID commentIDs[i] = r.MaxID
} }
var comments []model.Comment var comments []models.Comment
if err := model.DB.Preload("User").Where("id IN ?", commentIDs).Find(&comments).Error; err != nil { if err := models.DB.Preload("User").Where("id IN ?", commentIDs).Find(&comments).Error; err != nil {
return m return m
} }
for i := range comments { for i := range comments {
@@ -162,16 +162,16 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
limit = 10 limit = 10
} }
since := time.Now().Add(-7 * 24 * time.Hour) since := time.Now().Add(-7 * 24 * time.Hour)
var posts []model.Post var posts []models.Post
err := model.DB.Preload("User").Preload("Board"). err := models.DB.Preload("User").Preload("Board").
Where("status = ?", model.ContentStatusPublished). Where("status = ?", models.ContentStatusPublished).
Where(`EXISTS ( Where(`EXISTS (
SELECT 1 FROM comments SELECT 1 FROM comments
WHERE comments.post_id = posts.id WHERE comments.post_id = posts.id
AND comments.deleted_at IS NULL AND comments.deleted_at IS NULL
AND comments.status = ? AND comments.status = ?
AND comments.created_at >= ? AND comments.created_at >= ?
)`, model.ContentStatusPublished, since). )`, models.ContentStatusPublished, since).
Order(`( Order(`(
SELECT MAX(created_at) FROM comments SELECT MAX(created_at) FROM comments
WHERE comments.post_id = posts.id WHERE comments.post_id = posts.id
@@ -214,9 +214,9 @@ func (s *PostService) PopularTags(limit int) ([]TagCount, error) {
limit = 40 limit = 40
} }
var rows []struct{ Tags string } var rows []struct{ Tags string }
if err := model.DB.Model(&model.Post{}). if err := models.DB.Model(&models.Post{}).
Select("tags"). Select("tags").
Where("status = ? AND tags <> '' AND tags IS NOT NULL", model.ContentStatusPublished). Where("status = ? AND tags <> '' AND tags IS NOT NULL", models.ContentStatusPublished).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -258,21 +258,21 @@ func (s *PostService) PopularTags(limit int) ([]TagCount, error) {
func (s *PostService) CommentCount(postID uint) int { func (s *PostService) CommentCount(postID uint) int {
var count int64 var count int64
model.DB.Model(&model.Comment{}). models.DB.Model(&models.Comment{}).
Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished). Where("post_id = ? AND status = ?", postID, models.ContentStatusPublished).
Count(&count) Count(&count)
return int(count) return int(count)
} }
// CanViewPost 是否可查看该帖pending/rejected 仅作者与管理员) // CanViewPost 是否可查看该帖pending/rejected 仅作者与管理员)
func CanViewPost(post *model.Post, viewerID uint, isAdmin bool) bool { func CanViewPost(post *models.Post, viewerID uint, isAdmin bool) bool {
if post == nil { if post == nil {
return false return false
} }
if isAdmin || post.Status == model.ContentStatusPublished || post.Status == "" { if isAdmin || post.Status == models.ContentStatusPublished || post.Status == "" {
return true return true
} }
if post.Status == model.ContentStatusPending || post.Status == model.ContentStatusRejected { if post.Status == models.ContentStatusPending || post.Status == models.ContentStatusRejected {
return viewerID > 0 && post.UserID == viewerID return viewerID > 0 && post.UserID == viewerID
} }
return false return false
@@ -281,7 +281,7 @@ func CanViewPost(post *model.Post, viewerID uint, isAdmin bool) bool {
func applyPostVisibility(db *gorm.DB, q PostListQuery) *gorm.DB { func applyPostVisibility(db *gorm.DB, q PostListQuery) *gorm.DB {
if q.ViewerIsAdmin { if q.ViewerIsAdmin {
switch q.Status { switch q.Status {
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected: case models.ContentStatusPending, models.ContentStatusPublished, models.ContentStatusRejected:
return db.Where("status = ?", q.Status) return db.Where("status = ?", q.Status)
case "all", "": case "all", "":
return db return db
@@ -292,15 +292,15 @@ func applyPostVisibility(db *gorm.DB, q PostListQuery) *gorm.DB {
if q.ViewerID > 0 { if q.ViewerID > 0 {
return db.Where( return db.Where(
"status = ? OR (status IN ? AND user_id = ?)", "status = ? OR (status IN ? AND user_id = ?)",
model.ContentStatusPublished, models.ContentStatusPublished,
[]string{model.ContentStatusPending, model.ContentStatusRejected}, []string{models.ContentStatusPending, models.ContentStatusRejected},
q.ViewerID, q.ViewerID,
) )
} }
return db.Where("status = ?", model.ContentStatusPublished) return db.Where("status = ?", models.ContentStatusPublished)
} }
func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) { func (s *PostService) List(q PostListQuery) ([]models.Post, int64, error) {
if q.Page < 1 { if q.Page < 1 {
q.Page = 1 q.Page = 1
} }
@@ -317,11 +317,11 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
if uid, ok := resolveAuthorUserID(author); ok { if uid, ok := resolveAuthorUserID(author); ok {
q.UserID = uid q.UserID = uid
} else { } else {
return []model.Post{}, 0, nil return []models.Post{}, 0, nil
} }
} }
} }
db := model.DB.Model(&model.Post{}).Preload("User").Preload("Board") db := models.DB.Model(&models.Post{}).Preload("User").Preload("Board")
db = applyPostVisibility(db, q) db = applyPostVisibility(db, q)
if q.BoardID > 0 { if q.BoardID > 0 {
db = db.Where("board_id = ?", q.BoardID) db = db.Where("board_id = ?", q.BoardID)
@@ -345,7 +345,7 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
} }
var total int64 var total int64
db.Count(&total) db.Count(&total)
var posts []model.Post var posts []models.Post
db = db.Order("pinned desc") db = db.Order("pinned desc")
if q.BoardID > 0 { if q.BoardID > 0 {
db = db.Order("board_pinned desc") db = db.Order("board_pinned desc")
@@ -396,19 +396,19 @@ func resolveAuthorUserID(author string) (uint, bool) {
if author == "" { if author == "" {
return 0, false return 0, false
} }
var u model.User var u models.User
if err := model.DB.Select("id").Where("username = ?", author).First(&u).Error; err == nil { if err := models.DB.Select("id").Where("username = ?", author).First(&u).Error; err == nil {
return u.ID, true return u.ID, true
} }
if err := model.DB.Select("id").Where("nickname = ?", author).First(&u).Error; err == nil { if err := models.DB.Select("id").Where("nickname = ?", author).First(&u).Error; err == nil {
return u.ID, true return u.ID, true
} }
return 0, false return 0, false
} }
func (s *PostService) FindByID(id uint) (*model.Post, error) { func (s *PostService) FindByID(id uint) (*models.Post, error) {
var post model.Post var post models.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error err := models.DB.Preload("User").Preload("Board").First(&post, id).Error
if err != nil { if err != nil {
return nil, ErrPostNotFound return nil, ErrPostNotFound
} }
@@ -416,11 +416,11 @@ func (s *PostService) FindByID(id uint) (*model.Post, error) {
} }
func (s *PostService) RecordView(id uint) { func (s *PostService) RecordView(id uint) {
model.DB.Model(&model.Post{}).Where("id = ?", id). models.DB.Model(&models.Post{}).Where("id = ?", id).
UpdateColumn("view_count", gorm.Expr("view_count + 1")) UpdateColumn("view_count", gorm.Expr("view_count + 1"))
} }
func (s *PostService) GetByID(id uint) (*model.Post, error) { func (s *PostService) GetByID(id uint) (*models.Post, error) {
post, err := s.FindByID(id) post, err := s.FindByID(id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -429,7 +429,7 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
return post, nil return post, nil
} }
func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, skipModeration bool) (*model.Post, error) { func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, skipModeration bool) (*models.Post, error) {
title = s.filter.Filter(strings.TrimSpace(title)) title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(SanitizePostHTML(content)) content = s.filter.Filter(SanitizePostHTML(content))
tags = s.filter.Filter(strings.TrimSpace(tags)) tags = s.filter.Filter(strings.TrimSpace(tags))
@@ -449,11 +449,11 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
if _, err := NewBoardService().GetByID(boardID); err != nil { if _, err := NewBoardService().GetByID(boardID); err != nil {
return nil, err return nil, err
} }
status := model.ContentStatusPending status := models.ContentStatusPending
if skipModeration { if skipModeration {
status = model.ContentStatusPublished status = models.ContentStatusPublished
} }
post := &model.Post{ post := &models.Post{
BoardID: boardID, BoardID: boardID,
UserID: userID, UserID: userID,
Title: title, Title: title,
@@ -464,10 +464,10 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
QuestionResolved: false, QuestionResolved: false,
Status: status, Status: status,
} }
if err := model.DB.Create(post).Error; err != nil { if err := models.DB.Create(post).Error; err != nil {
return nil, err return nil, err
} }
if status == model.ContentStatusPublished { if status == models.ContentStatusPublished {
AddExp(userID, 10) AddExp(userID, 10)
} }
return post, nil return post, nil
@@ -476,8 +476,8 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
// Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。 // Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。
// postType 为空时保持原类型;改为非 question 时清除已解决标记。 // postType 为空时保持原类型;改为非 question 时清除已解决标记。
func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool, title, content, tags, postType string, boardID uint) error { func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool, title, content, tags, postType string, boardID uint) error {
var post model.Post var post models.Post
if err := model.DB.First(&post, postID).Error; err != nil { if err := models.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound return ErrPostNotFound
} }
if !isAdmin && post.UserID != userID { if !isAdmin && post.UserID != userID {
@@ -517,11 +517,11 @@ func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool,
return errors.New("不能改为特殊帖子类型") return errors.New("不能改为特殊帖子类型")
} }
nextResolved := post.QuestionResolved nextResolved := post.QuestionResolved
if nextType != model.PostTypeQuestion { if nextType != models.PostTypeQuestion {
nextResolved = false nextResolved = false
} }
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
rev := model.PostRevision{ rev := models.PostRevision{
PostID: postID, EditorID: userID, PostID: postID, EditorID: userID,
Title: post.Title, Content: post.Content, Tags: post.Tags, Title: post.Title, Content: post.Content, Tags: post.Tags,
} }
@@ -539,7 +539,7 @@ func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool,
} }
// 非免审用户修改后重新进入审核 // 非免审用户修改后重新进入审核
if !skipModeration { if !skipModeration {
updates["status"] = model.ContentStatusPending updates["status"] = models.ContentStatusPending
} }
return tx.Model(&post).Updates(updates).Error return tx.Model(&post).Updates(updates).Error
}) })
@@ -548,16 +548,16 @@ func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool,
// SetStatus 设置帖子审核状态 // SetStatus 设置帖子审核状态
func (s *PostService) SetStatus(postID uint, status string) error { func (s *PostService) SetStatus(postID uint, status string) error {
switch status { switch status {
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected: case models.ContentStatusPending, models.ContentStatusPublished, models.ContentStatusRejected:
default: default:
return errors.New("无效的审核状态") return errors.New("无效的审核状态")
} }
var post model.Post var post models.Post
if err := model.DB.Select("id", "user_id", "status").First(&post, postID).Error; err != nil { if err := models.DB.Select("id", "user_id", "status").First(&post, postID).Error; err != nil {
return ErrPostNotFound return ErrPostNotFound
} }
prev := post.Status prev := post.Status
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("status", status) res := models.DB.Model(&models.Post{}).Where("id = ?", postID).Update("status", status)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -565,7 +565,7 @@ func (s *PostService) SetStatus(postID uint, status string) error {
return ErrPostNotFound return ErrPostNotFound
} }
// 首次变为已发布时加经验 // 首次变为已发布时加经验
if status == model.ContentStatusPublished && prev != model.ContentStatusPublished { if status == models.ContentStatusPublished && prev != models.ContentStatusPublished {
AddExp(post.UserID, 10) AddExp(post.UserID, 10)
} }
return nil return nil
@@ -574,24 +574,24 @@ func (s *PostService) SetStatus(postID uint, status string) error {
// PendingPostCount 待审帖数量 // PendingPostCount 待审帖数量
func (s *PostService) PendingPostCount() (int64, error) { func (s *PostService) PendingPostCount() (int64, error) {
var n int64 var n int64
err := model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPending).Count(&n).Error err := models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPending).Count(&n).Error
return n, err return n, err
} }
// CanEdit 判断当前用户是否可编辑帖子 // CanEdit 判断当前用户是否可编辑帖子
func (s *PostService) CanEdit(post *model.Post, isAdmin bool) bool { func (s *PostService) CanEdit(post *models.Post, isAdmin bool) bool {
return s.checkEditable(post, isAdmin) == nil return s.checkEditable(post, isAdmin) == nil
} }
// EditBlockReason 返回不可编辑的原因(可编辑时返回空字符串) // EditBlockReason 返回不可编辑的原因(可编辑时返回空字符串)
func (s *PostService) EditBlockReason(post *model.Post, isAdmin bool) string { func (s *PostService) EditBlockReason(post *models.Post, isAdmin bool) string {
if err := s.checkEditable(post, isAdmin); err != nil { if err := s.checkEditable(post, isAdmin); err != nil {
return err.Error() return err.Error()
} }
return "" return ""
} }
func (s *PostService) checkEditable(post *model.Post, isAdmin bool) error { func (s *PostService) checkEditable(post *models.Post, isAdmin bool) error {
if isAdmin { if isAdmin {
return nil return nil
} }
@@ -606,7 +606,7 @@ func (s *PostService) checkEditable(post *model.Post, isAdmin bool) error {
} }
// CanUserEdit 判断指定用户是否可编辑帖子 // CanUserEdit 判断指定用户是否可编辑帖子
func (s *PostService) CanUserEdit(post *model.Post, userID uint, isAdmin bool) bool { func (s *PostService) CanUserEdit(post *models.Post, userID uint, isAdmin bool) bool {
if userID == 0 { if userID == 0 {
return false return false
} }
@@ -617,7 +617,7 @@ func (s *PostService) CanUserEdit(post *model.Post, userID uint, isAdmin bool) b
} }
// UserEditBlockReason 返回用户不可编辑的原因 // UserEditBlockReason 返回用户不可编辑的原因
func (s *PostService) UserEditBlockReason(post *model.Post, userID uint, isAdmin bool) string { func (s *PostService) UserEditBlockReason(post *models.Post, userID uint, isAdmin bool) string {
if userID == 0 { if userID == 0 {
return "请先登录" return "请先登录"
} }
@@ -628,7 +628,7 @@ func (s *PostService) UserEditBlockReason(post *model.Post, userID uint, isAdmin
} }
func (s *PostService) SetEditLocked(postID uint, locked bool) error { func (s *PostService) SetEditLocked(postID uint, locked bool) error {
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("edit_locked", locked) res := models.DB.Model(&models.Post{}).Where("id = ?", postID).Update("edit_locked", locked)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -640,7 +640,7 @@ func (s *PostService) SetEditLocked(postID uint, locked bool) error {
// SetCommentsLocked 锁定/解锁讨论(禁止新评论) // SetCommentsLocked 锁定/解锁讨论(禁止新评论)
func (s *PostService) SetCommentsLocked(postID uint, locked bool) error { func (s *PostService) SetCommentsLocked(postID uint, locked bool) error {
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("comments_locked", locked) res := models.DB.Model(&models.Post{}).Where("id = ?", postID).Update("comments_locked", locked)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -650,22 +650,22 @@ func (s *PostService) SetCommentsLocked(postID uint, locked bool) error {
return nil return nil
} }
func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) { func (s *PostService) ListRevisions(postID uint) ([]models.PostRevision, error) {
var revs []model.PostRevision var revs []models.PostRevision
err := model.DB.Preload("Editor").Where("post_id = ?", postID). err := models.DB.Preload("Editor").Where("post_id = ?", postID).
Order("id desc").Find(&revs).Error Order("id desc").Find(&revs).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
if revs == nil { if revs == nil {
revs = []model.PostRevision{} revs = []models.PostRevision{}
} }
return revs, nil return revs, nil
} }
func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, error) { func (s *PostService) GetRevision(postID, revID uint) (*models.PostRevision, error) {
var rev model.PostRevision var rev models.PostRevision
err := model.DB.Preload("Editor"). err := models.DB.Preload("Editor").
Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error
if err != nil { if err != nil {
return nil, ErrRevisionNotFound return nil, ErrRevisionNotFound
@@ -678,17 +678,17 @@ func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
if !isAdmin { if !isAdmin {
return ErrPermissionDenied return ErrPermissionDenied
} }
var post model.Post var post models.Post
if err := model.DB.First(&post, postID).Error; err != nil { if err := models.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound return ErrPostNotFound
} }
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
if err := RefundBountyIfOpen(tx, &post); err != nil { if err := RefundBountyIfOpen(tx, &post); err != nil {
return err return err
} }
DeletePollData(tx, postID) DeletePollData(tx, postID)
DeleteLotteryData(tx, postID) DeleteLotteryData(tx, postID)
if err := tx.Where("post_id = ?", postID).Delete(&model.Comment{}).Error; err != nil { if err := tx.Where("post_id = ?", postID).Delete(&models.Comment{}).Error; err != nil {
return err return err
} }
return tx.Delete(&post).Error return tx.Delete(&post).Error
@@ -707,7 +707,7 @@ func (s *PostService) ListTrash(page, size int, keyword string) ([]TrashPostItem
page = 1 page = 1
} }
size = s.settings.NormalizePageSize(size) size = s.settings.NormalizePageSize(size)
db := model.DB.Unscoped().Model(&model.Post{}). db := models.DB.Unscoped().Model(&models.Post{}).
Where("deleted_at IS NOT NULL"). Where("deleted_at IS NOT NULL").
Preload("User").Preload("Board") Preload("User").Preload("Board")
if keyword != "" { if keyword != "" {
@@ -722,7 +722,7 @@ func (s *PostService) ListTrash(page, size int, keyword string) ([]TrashPostItem
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var posts []model.Post var posts []models.Post
if err := db.Order("deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&posts).Error; err != nil { if err := db.Order("deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&posts).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -739,7 +739,7 @@ func (s *PostService) ListTrash(page, size int, keyword string) ([]TrashPostItem
Cnt int Cnt int
} }
var rows []row var rows []row
_ = model.DB.Unscoped().Model(&model.Comment{}). _ = models.DB.Unscoped().Model(&models.Comment{}).
Select("post_id, COUNT(*) as cnt"). Select("post_id, COUNT(*) as cnt").
Where("post_id IN ?", ids). Where("post_id IN ?", ids).
Group("post_id").Scan(&rows) Group("post_id").Scan(&rows)
@@ -760,15 +760,15 @@ func (s *PostService) ListTrash(page, size int, keyword string) ([]TrashPostItem
// Restore 从回收站恢复帖子及评论 // Restore 从回收站恢复帖子及评论
func (s *PostService) Restore(postID uint) error { func (s *PostService) Restore(postID uint) error {
var post model.Post var post models.Post
if err := model.DB.Unscoped().First(&post, postID).Error; err != nil { if err := models.DB.Unscoped().First(&post, postID).Error; err != nil {
return ErrPostNotFound return ErrPostNotFound
} }
if !post.DeletedAt.Valid { if !post.DeletedAt.Valid {
return errors.New("帖子未被删除") return errors.New("帖子未被删除")
} }
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Model(&model.Comment{}). if err := tx.Unscoped().Model(&models.Comment{}).
Where("post_id = ? AND deleted_at IS NOT NULL", postID). Where("post_id = ? AND deleted_at IS NOT NULL", postID).
Update("deleted_at", nil).Error; err != nil { Update("deleted_at", nil).Error; err != nil {
return err return err
@@ -779,33 +779,33 @@ func (s *PostService) Restore(postID uint) error {
// Purge 永久删除回收站中的帖子(含评论、点赞、收藏、修订) // Purge 永久删除回收站中的帖子(含评论、点赞、收藏、修订)
func (s *PostService) Purge(postID uint) error { func (s *PostService) Purge(postID uint) error {
var post model.Post var post models.Post
if err := model.DB.Unscoped().First(&post, postID).Error; err != nil { if err := models.DB.Unscoped().First(&post, postID).Error; err != nil {
return ErrPostNotFound return ErrPostNotFound
} }
if !post.DeletedAt.Valid { if !post.DeletedAt.Valid {
return errors.New("仅可彻底删除回收站中的帖子,请先删除帖子") return errors.New("仅可彻底删除回收站中的帖子,请先删除帖子")
} }
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
var commentIDs []uint var commentIDs []uint
if err := tx.Unscoped().Model(&model.Comment{}).Where("post_id = ?", postID).Pluck("id", &commentIDs).Error; err != nil { if err := tx.Unscoped().Model(&models.Comment{}).Where("post_id = ?", postID).Pluck("id", &commentIDs).Error; err != nil {
return err return err
} }
if len(commentIDs) > 0 { if len(commentIDs) > 0 {
if err := tx.Where("comment_id IN ?", commentIDs).Delete(&model.CommentRevision{}).Error; err != nil { if err := tx.Where("comment_id IN ?", commentIDs).Delete(&models.CommentRevision{}).Error; err != nil {
return err return err
} }
} }
if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&model.Comment{}).Error; err != nil { if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&models.Comment{}).Error; err != nil {
return err return err
} }
if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&model.PostLike{}).Error; err != nil { if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&models.PostLike{}).Error; err != nil {
return err return err
} }
if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&model.PostFavorite{}).Error; err != nil { if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&models.PostFavorite{}).Error; err != nil {
return err return err
} }
if err := tx.Where("post_id = ?", postID).Delete(&model.PostRevision{}).Error; err != nil { if err := tx.Where("post_id = ?", postID).Delete(&models.PostRevision{}).Error; err != nil {
return err return err
} }
return tx.Unscoped().Delete(&post).Error return tx.Unscoped().Delete(&post).Error
@@ -813,52 +813,52 @@ func (s *PostService) Purge(postID uint) error {
} }
func (s *PostService) SetPinned(postID uint, pinned bool) error { func (s *PostService) SetPinned(postID uint, pinned bool) error {
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("pinned", pinned).Error return models.DB.Model(&models.Post{}).Where("id = ?", postID).Update("pinned", pinned).Error
} }
func (s *PostService) SetBoardPinned(postID uint, boardPinned bool) error { func (s *PostService) SetBoardPinned(postID uint, boardPinned bool) error {
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("board_pinned", boardPinned).Error return models.DB.Model(&models.Post{}).Where("id = ?", postID).Update("board_pinned", boardPinned).Error
} }
func (s *PostService) SetFeatured(postID uint, featured bool) error { func (s *PostService) SetFeatured(postID uint, featured bool) error {
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("featured", featured).Error return models.DB.Model(&models.Post{}).Where("id = ?", postID).Update("featured", featured).Error
} }
// SetQuestionResolved 标记问答帖已解决 / 未解决(作者或管理员) // SetQuestionResolved 标记问答帖已解决 / 未解决(作者或管理员)
func (s *PostService) SetQuestionResolved(userID, postID uint, isAdmin bool, resolved bool) error { func (s *PostService) SetQuestionResolved(userID, postID uint, isAdmin bool, resolved bool) error {
var post model.Post var post models.Post
if err := model.DB.First(&post, postID).Error; err != nil { if err := models.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound return ErrPostNotFound
} }
if !isAdmin && post.UserID != userID { if !isAdmin && post.UserID != userID {
return ErrPermissionDenied return ErrPermissionDenied
} }
if post.PostType != model.PostTypeQuestion { if post.PostType != models.PostTypeQuestion {
return errors.New("仅问答帖可标记解决状态") return errors.New("仅问答帖可标记解决状态")
} }
return model.DB.Model(&post).Update("question_resolved", resolved).Error return models.DB.Model(&post).Update("question_resolved", resolved).Error
} }
func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) { func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
var post model.Post var post models.Post
if err := model.DB.Select("id", "user_id").First(&post, postID).Error; err != nil { if err := models.DB.Select("id", "user_id").First(&post, postID).Error; err != nil {
return false, ErrPostNotFound return false, ErrPostNotFound
} }
var like model.PostLike var like models.PostLike
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like) result := models.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)
if result.Error != nil { if result.Error != nil {
return false, result.Error return false, result.Error
} }
if result.RowsAffected > 0 { if result.RowsAffected > 0 {
model.DB.Delete(&like) models.DB.Delete(&like)
model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count - 1")) models.DB.Model(&models.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count - 1"))
return false, nil return false, nil
} }
like = model.PostLike{PostID: postID, UserID: userID} like = models.PostLike{PostID: postID, UserID: userID}
if err := model.DB.Create(&like).Error; err != nil { if err := models.DB.Create(&like).Error; err != nil {
return false, err return false, err
} }
model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count + 1")) models.DB.Model(&models.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count + 1"))
// 他人点赞给作者加经验;自赞不计 // 他人点赞给作者加经验;自赞不计
if userID != post.UserID { if userID != post.UserID {
AddExp(post.UserID, 1) AddExp(post.UserID, 1)
@@ -871,24 +871,24 @@ func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
func (s *PostService) IsLiked(userID, postID uint) bool { func (s *PostService) IsLiked(userID, postID uint) bool {
var count int64 var count int64
model.DB.Model(&model.PostLike{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count) models.DB.Model(&models.PostLike{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0 return count > 0
} }
func (s *PostService) ToggleFavorite(userID, postID uint) (faved bool, err error) { func (s *PostService) ToggleFavorite(userID, postID uint) (faved bool, err error) {
var fav model.PostFavorite var fav models.PostFavorite
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&fav) result := models.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&fav)
if result.Error != nil { if result.Error != nil {
return false, result.Error return false, result.Error
} }
if result.RowsAffected > 0 { if result.RowsAffected > 0 {
if err := model.DB.Delete(&fav).Error; err != nil { if err := models.DB.Delete(&fav).Error; err != nil {
return false, err return false, err
} }
return false, nil return false, nil
} }
fav = model.PostFavorite{PostID: postID, UserID: userID} fav = models.PostFavorite{PostID: postID, UserID: userID}
if err := model.DB.Create(&fav).Error; err != nil { if err := models.DB.Create(&fav).Error; err != nil {
return false, err return false, err
} }
return true, nil return true, nil
@@ -896,11 +896,11 @@ func (s *PostService) ToggleFavorite(userID, postID uint) (faved bool, err error
func (s *PostService) IsFavorited(userID, postID uint) bool { func (s *PostService) IsFavorited(userID, postID uint) bool {
var count int64 var count int64
model.DB.Model(&model.PostFavorite{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count) models.DB.Model(&models.PostFavorite{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0 return count > 0
} }
func (s *PostService) ListFavorites(userID uint, page, size int) ([]model.PostFavorite, int64, error) { func (s *PostService) ListFavorites(userID uint, page, size int) ([]models.PostFavorite, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -908,17 +908,17 @@ func (s *PostService) ListFavorites(userID uint, page, size int) ([]model.PostFa
size = 20 size = 20
} }
// 仅统计可查看的收藏(已公开,或本人未公开帖) // 仅统计可查看的收藏(已公开,或本人未公开帖)
base := model.DB.Model(&model.PostFavorite{}). base := models.DB.Model(&models.PostFavorite{}).
Joins("JOIN posts ON posts.id = post_favorites.post_id AND posts.deleted_at IS NULL"). Joins("JOIN posts ON posts.id = post_favorites.post_id AND posts.deleted_at IS NULL").
Where("post_favorites.user_id = ?", userID). Where("post_favorites.user_id = ?", userID).
Where("posts.status = ? OR posts.user_id = ?", model.ContentStatusPublished, userID) Where("posts.status = ? OR posts.user_id = ?", models.ContentStatusPublished, userID)
var total int64 var total int64
base.Count(&total) base.Count(&total)
var favs []model.PostFavorite var favs []models.PostFavorite
err := model.DB.Preload("Post.User").Preload("Post.Board"). err := models.DB.Preload("Post.User").Preload("Post.Board").
Joins("JOIN posts ON posts.id = post_favorites.post_id AND posts.deleted_at IS NULL"). Joins("JOIN posts ON posts.id = post_favorites.post_id AND posts.deleted_at IS NULL").
Where("post_favorites.user_id = ?", userID). Where("post_favorites.user_id = ?", userID).
Where("posts.status = ? OR posts.user_id = ?", model.ContentStatusPublished, userID). Where("posts.status = ? OR posts.user_id = ?", models.ContentStatusPublished, userID).
Order("post_favorites.id desc"). Order("post_favorites.id desc").
Offset((page - 1) * size).Limit(size).Find(&favs).Error Offset((page - 1) * size).Limit(size).Find(&favs).Error
return favs, total, err return favs, total, err
@@ -937,9 +937,9 @@ func (s *PostService) ListSitemap(limit int) ([]SitemapPost, error) {
limit = 5000 limit = 5000
} }
var rows []SitemapPost var rows []SitemapPost
err := model.DB.Model(&model.Post{}). err := models.DB.Model(&models.Post{}).
Select("id, created_at, updated_at"). Select("id, created_at, updated_at").
Where("status = ?", model.ContentStatusPublished). Where("status = ?", models.ContentStatusPublished).
Order("updated_at desc, id desc"). Order("updated_at desc, id desc").
Limit(limit). Limit(limit).
Find(&rows).Error Find(&rows).Error

View File

@@ -1,10 +1,10 @@
package service package services
import ( import (
"errors" "errors"
"strconv" "strconv"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -16,30 +16,30 @@ type PostCreateExtras struct {
} }
// FinalizeSpecialPostCreate 创建帖后初始化投票/悬赏/抽奖 // FinalizeSpecialPostCreate 创建帖后初始化投票/悬赏/抽奖
func FinalizeSpecialPostCreate(post *model.Post, userID uint, extras PostCreateExtras) error { func FinalizeSpecialPostCreate(post *models.Post, userID uint, extras PostCreateExtras) error {
if post == nil { if post == nil {
return errors.New("帖子不存在") return errors.New("帖子不存在")
} }
return model.DB.Transaction(func(tx *gorm.DB) error { return models.DB.Transaction(func(tx *gorm.DB) error {
switch post.PostType { switch post.PostType {
case model.PostTypePoll: case models.PostTypePoll:
opts, multi, maxChoices, endsAt, err := ParsePollOptionsJSON(extras.PollOptionsJSON) opts, multi, maxChoices, endsAt, err := ParsePollOptionsJSON(extras.PollOptionsJSON)
if err != nil { if err != nil {
return err return err
} }
return CreatePollForPost(tx, post.ID, multi, maxChoices, endsAt, opts) return CreatePollForPost(tx, post.ID, multi, maxChoices, endsAt, opts)
case model.PostTypeBounty: case models.PostTypeBounty:
if extras.BountyPoints < 1 { if extras.BountyPoints < 1 {
return ErrBountyInvalidPoint return ErrBountyInvalidPoint
} }
if err := tx.Model(post).Updates(map[string]interface{}{ if err := tx.Model(post).Updates(map[string]interface{}{
"bounty_points": extras.BountyPoints, "bounty_points": extras.BountyPoints,
"bounty_status": model.BountyStatusOpen, "bounty_status": models.BountyStatusOpen,
}).Error; err != nil { }).Error; err != nil {
return err return err
} }
return EscrowBounty(tx, userID, post.ID, extras.BountyPoints) return EscrowBounty(tx, userID, post.ID, extras.BountyPoints)
case model.PostTypeLottery: case models.PostTypeLottery:
count := extras.LotteryWinnerCount count := extras.LotteryWinnerCount
if count < 1 { if count < 1 {
count = 1 count = 1
@@ -49,7 +49,7 @@ func FinalizeSpecialPostCreate(post *model.Post, userID uint, extras PostCreateE
} }
return tx.Model(post).Updates(map[string]interface{}{ return tx.Model(post).Updates(map[string]interface{}{
"lottery_winner_count": count, "lottery_winner_count": count,
"lottery_status": model.PostLotteryStatusOpen, "lottery_status": models.PostLotteryStatusOpen,
}).Error }).Error
default: default:
return nil return nil

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"sync" "sync"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"
@@ -7,7 +7,7 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -38,11 +38,11 @@ func NewReportService(
func normalizeReportReason(reason string) (string, error) { func normalizeReportReason(reason string) (string, error) {
switch strings.TrimSpace(reason) { switch strings.TrimSpace(reason) {
case model.ReportReasonSpam, case models.ReportReasonSpam,
model.ReportReasonAbuse, models.ReportReasonAbuse,
model.ReportReasonIllegal, models.ReportReasonIllegal,
model.ReportReasonIrrelevant, models.ReportReasonIrrelevant,
model.ReportReasonOther: models.ReportReasonOther:
return reason, nil return reason, nil
default: default:
return "", errors.New("请选择有效的举报原因") return "", errors.New("请选择有效的举报原因")
@@ -51,15 +51,15 @@ func normalizeReportReason(reason string) (string, error) {
func ReportReasonLabel(reason string) string { func ReportReasonLabel(reason string) string {
switch reason { switch reason {
case model.ReportReasonSpam: case models.ReportReasonSpam:
return "垃圾广告" return "垃圾广告"
case model.ReportReasonAbuse: case models.ReportReasonAbuse:
return "人身攻击 / 辱骂" return "人身攻击 / 辱骂"
case model.ReportReasonIllegal: case models.ReportReasonIllegal:
return "违法违规" return "违法违规"
case model.ReportReasonIrrelevant: case models.ReportReasonIrrelevant:
return "内容无关 / 灌水" return "内容无关 / 灌水"
case model.ReportReasonOther: case models.ReportReasonOther:
return "其他" return "其他"
default: default:
return reason return reason
@@ -67,7 +67,7 @@ func ReportReasonLabel(reason string) string {
} }
// Create 用户举报帖子 // Create 用户举报帖子
func (s *ReportService) Create(reporterID, postID uint, reason, detail string) (*model.PostReport, error) { func (s *ReportService) Create(reporterID, postID uint, reason, detail string) (*models.PostReport, error) {
reason, err := normalizeReportReason(reason) reason, err := normalizeReportReason(reason)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -80,8 +80,8 @@ func (s *ReportService) Create(reporterID, postID uint, reason, detail string) (
detail = s.filter.Filter(detail) detail = s.filter.Filter(detail)
} }
var post model.Post var post models.Post
if err := model.DB.Select("id", "user_id", "title").First(&post, postID).Error; err != nil { if err := models.DB.Select("id", "user_id", "title").First(&post, postID).Error; err != nil {
return nil, ErrPostNotFound return nil, ErrPostNotFound
} }
if post.UserID == reporterID { if post.UserID == reporterID {
@@ -89,29 +89,29 @@ func (s *ReportService) Create(reporterID, postID uint, reason, detail string) (
} }
var existing int64 var existing int64
model.DB.Model(&model.PostReport{}). models.DB.Model(&models.PostReport{}).
Where("post_id = ? AND reporter_id = ? AND status = ?", postID, reporterID, model.ReportStatusPending). Where("post_id = ? AND reporter_id = ? AND status = ?", postID, reporterID, models.ReportStatusPending).
Count(&existing) Count(&existing)
if existing > 0 { if existing > 0 {
return nil, ErrReportAlreadyExists return nil, ErrReportAlreadyExists
} }
rep := &model.PostReport{ rep := &models.PostReport{
PostID: postID, PostID: postID,
ReporterID: reporterID, ReporterID: reporterID,
Reason: reason, Reason: reason,
Detail: detail, Detail: detail,
Status: model.ReportStatusPending, Status: models.ReportStatusPending,
} }
if err := model.DB.Create(rep).Error; err != nil { if err := models.DB.Create(rep).Error; err != nil {
return nil, err return nil, err
} }
_ = model.DB.Preload("Post").Preload("Reporter").First(rep, rep.ID).Error _ = models.DB.Preload("Post").Preload("Reporter").First(rep, rep.ID).Error
return rep, nil return rep, nil
} }
// CreateCommentReport 用户举报评论 // CreateCommentReport 用户举报评论
func (s *ReportService) CreateCommentReport(reporterID, commentID uint, reason, detail string) (*model.PostReport, error) { func (s *ReportService) CreateCommentReport(reporterID, commentID uint, reason, detail string) (*models.PostReport, error) {
reason, err := normalizeReportReason(reason) reason, err := normalizeReportReason(reason)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -133,26 +133,26 @@ func (s *ReportService) CreateCommentReport(reporterID, commentID uint, reason,
} }
var existing int64 var existing int64
model.DB.Model(&model.PostReport{}). models.DB.Model(&models.PostReport{}).
Where("comment_id = ? AND reporter_id = ? AND status = ?", commentID, reporterID, model.ReportStatusPending). Where("comment_id = ? AND reporter_id = ? AND status = ?", commentID, reporterID, models.ReportStatusPending).
Count(&existing) Count(&existing)
if existing > 0 { if existing > 0 {
return nil, ErrReportAlreadyExists return nil, ErrReportAlreadyExists
} }
cid := commentID cid := commentID
rep := &model.PostReport{ rep := &models.PostReport{
PostID: comment.PostID, PostID: comment.PostID,
CommentID: &cid, CommentID: &cid,
ReporterID: reporterID, ReporterID: reporterID,
Reason: reason, Reason: reason,
Detail: detail, Detail: detail,
Status: model.ReportStatusPending, Status: models.ReportStatusPending,
} }
if err := model.DB.Create(rep).Error; err != nil { if err := models.DB.Create(rep).Error; err != nil {
return nil, err return nil, err
} }
_ = model.DB.Preload("Post").Preload("Comment").Preload("Reporter").First(rep, rep.ID).Error _ = models.DB.Preload("Post").Preload("Comment").Preload("Reporter").First(rep, rep.ID).Error
return rep, nil return rep, nil
} }
@@ -163,13 +163,13 @@ type ReportListQuery struct {
} }
// ListAdmin 管理员举报列表 // ListAdmin 管理员举报列表
func (s *ReportService) ListAdmin(q ReportListQuery) ([]model.PostReport, int64, error) { func (s *ReportService) ListAdmin(q ReportListQuery) ([]models.PostReport, int64, error) {
if q.Page < 1 { if q.Page < 1 {
q.Page = 1 q.Page = 1
} }
q.Size = s.settings.NormalizePageSize(q.Size) q.Size = s.settings.NormalizePageSize(q.Size)
db := model.DB.Model(&model.PostReport{}) db := models.DB.Model(&models.PostReport{})
if q.Status != "" && q.Status != "all" { if q.Status != "" && q.Status != "all" {
db = db.Where("status = ?", q.Status) db = db.Where("status = ?", q.Status)
} }
@@ -179,7 +179,7 @@ func (s *ReportService) ListAdmin(q ReportListQuery) ([]model.PostReport, int64,
return nil, 0, err return nil, 0, err
} }
var list []model.PostReport var list []models.PostReport
err := db.Preload("Post", func(tx *gorm.DB) *gorm.DB { err := db.Preload("Post", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped() return tx.Unscoped()
}).Preload("Post.User").Preload("Comment", func(tx *gorm.DB) *gorm.DB { }).Preload("Post.User").Preload("Comment", func(tx *gorm.DB) *gorm.DB {
@@ -195,8 +195,8 @@ func (s *ReportService) ListAdmin(q ReportListQuery) ([]model.PostReport, int64,
// PendingCount 待处理举报数 // PendingCount 待处理举报数
func (s *ReportService) PendingCount() (int64, error) { func (s *ReportService) PendingCount() (int64, error) {
var n int64 var n int64
err := model.DB.Model(&model.PostReport{}). err := models.DB.Model(&models.PostReport{}).
Where("status = ?", model.ReportStatusPending). Where("status = ?", models.ReportStatusPending).
Count(&n).Error Count(&n).Error
return n, err return n, err
} }
@@ -210,16 +210,16 @@ type HandleReportInput struct {
} }
// Handle 处理举报 // Handle 处理举报
func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error) { func (s *ReportService) Handle(in HandleReportInput) (*models.PostReport, error) {
var rep model.PostReport var rep models.PostReport
if err := model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB { if err := models.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped() return tx.Unscoped()
}).Preload("Comment", func(tx *gorm.DB) *gorm.DB { }).Preload("Comment", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped() return tx.Unscoped()
}).First(&rep, in.ReportID).Error; err != nil { }).First(&rep, in.ReportID).Error; err != nil {
return nil, ErrReportNotFound return nil, ErrReportNotFound
} }
if rep.Status != model.ReportStatusPending { if rep.Status != models.ReportStatusPending {
return nil, errors.New("该举报已处理") return nil, errors.New("该举报已处理")
} }
@@ -251,9 +251,9 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
switch in.Action { switch in.Action {
case "dismiss": case "dismiss":
rep.Status = model.ReportStatusDismissed rep.Status = models.ReportStatusDismissed
case "resolve": case "resolve":
rep.Status = model.ReportStatusResolved rep.Status = models.ReportStatusResolved
case "reject_post": case "reject_post":
if isCommentReport { if isCommentReport {
return nil, errors.New("评论举报请使用「拒绝该评论」") return nil, errors.New("评论举报请使用「拒绝该评论」")
@@ -265,10 +265,10 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
if utf8.RuneCountInString(reason) > 1000 { if utf8.RuneCountInString(reason) > 1000 {
return nil, errors.New("拒绝原因过长") return nil, errors.New("拒绝原因过长")
} }
if err := s.posts.SetStatus(postID, model.ContentStatusRejected); err != nil { if err := s.posts.SetStatus(postID, models.ContentStatusRejected); err != nil {
return nil, err return nil, err
} }
rep.Status = model.ReportStatusResolved rep.Status = models.ReportStatusResolved
if note == "" { if note == "" {
rep.HandleNote = "已拒绝该帖并通知作者" rep.HandleNote = "已拒绝该帖并通知作者"
} }
@@ -279,7 +279,7 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
authorID, authorID,
fmt.Sprintf("帖子《%s》未通过审核", postTitle), fmt.Sprintf("帖子《%s》未通过审核", postTitle),
FormatRejectContent(postTitle, postID, reason), FormatRejectContent(postTitle, postID, reason),
model.MessageKindReject, models.MessageKindReject,
&pid, &pid,
&rid, &rid,
) )
@@ -295,10 +295,10 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
if utf8.RuneCountInString(reason) > 1000 { if utf8.RuneCountInString(reason) > 1000 {
return nil, errors.New("拒绝原因过长") return nil, errors.New("拒绝原因过长")
} }
if err := s.comments.SetStatus(*rep.CommentID, model.ContentStatusRejected); err != nil { if err := s.comments.SetStatus(*rep.CommentID, models.ContentStatusRejected); err != nil {
return nil, err return nil, err
} }
rep.Status = model.ReportStatusResolved rep.Status = models.ReportStatusResolved
if note == "" { if note == "" {
rep.HandleNote = "已拒绝该评论并通知作者" rep.HandleNote = "已拒绝该评论并通知作者"
} }
@@ -310,7 +310,7 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
commentAuthorID, commentAuthorID,
fmt.Sprintf("评论未通过审核 · 《%s》", postTitle), fmt.Sprintf("评论未通过审核 · 《%s》", postTitle),
body, body,
model.MessageKindReject, models.MessageKindReject,
&pid, &pid,
&rid, &rid,
) )
@@ -319,13 +319,13 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
return nil, errors.New("无效的处理操作") return nil, errors.New("无效的处理操作")
} }
if err := model.DB.Save(&rep).Error; err != nil { if err := models.DB.Save(&rep).Error; err != nil {
return nil, err return nil, err
} }
// 通知举报人处理结果 // 通知举报人处理结果
resultText := "已忽略" resultText := "已忽略"
if rep.Status == model.ReportStatusResolved { if rep.Status == models.ReportStatusResolved {
switch in.Action { switch in.Action {
case "reject_post": case "reject_post":
resultText = "已核实并下架该帖" resultText = "已核实并下架该帖"
@@ -349,12 +349,12 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
rep.ReporterID, rep.ReporterID,
"举报处理结果通知", "举报处理结果通知",
content, content,
model.MessageKindReportResult, models.MessageKindReportResult,
&pid, &pid,
&rid, &rid,
) )
_ = model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB { _ = models.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped() return tx.Unscoped()
}).Preload("Comment", func(tx *gorm.DB) *gorm.DB { }).Preload("Comment", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped() return tx.Unscoped()

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"sync" "sync"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"strings" "strings"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"net/url" "net/url"
@@ -7,7 +7,7 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
var ( var (
@@ -89,7 +89,7 @@ func FirstImageURL(htmlContent string) string {
} }
// DisplayName 用户展示名 // DisplayName 用户展示名
func DisplayName(u *model.User) string { func DisplayName(u *models.User) string {
if u == nil { if u == nil {
return "" return ""
} }

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"encoding/json" "encoding/json"
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"sync" "sync"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
// 论坛设置键名 // 论坛设置键名
@@ -403,72 +403,72 @@ func NewForumSettingsService() *ForumSettingsService {
func (s *ForumSettingsService) ensureDefaults() { func (s *ForumSettingsService) ensureDefaults() {
for _, def := range forumSettingDefs { for _, def := range forumSettingDefs {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", def.key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", def.key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal}) models.DB.Create(&models.ForumSetting{Key: def.key, Value: def.defaultVal})
} }
} }
for key, val := range feedSettingDefaults { for key, val := range feedSettingDefaults {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) models.DB.Create(&models.ForumSetting{Key: key, Value: val})
} }
} }
for key, val := range asideSettingDefaults { for key, val := range asideSettingDefaults {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) models.DB.Create(&models.ForumSetting{Key: key, Value: val})
} }
} }
for key, val := range mailSettingDefaults { for key, val := range mailSettingDefaults {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) models.DB.Create(&models.ForumSetting{Key: key, Value: val})
} }
} }
for key, val := range oidcSettingDefaults { for key, val := range oidcSettingDefaults {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) models.DB.Create(&models.ForumSetting{Key: key, Value: val})
} }
} }
for key, val := range giteaSettingDefaults { for key, val := range giteaSettingDefaults {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) models.DB.Create(&models.ForumSetting{Key: key, Value: val})
} }
} }
for key, val := range storageSettingDefaults { for key, val := range storageSettingDefaults {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) models.DB.Create(&models.ForumSetting{Key: key, Value: val})
} }
} }
for key, val := range siteBrandingDefaults { for key, val := range siteBrandingDefaults {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) models.DB.Create(&models.ForumSetting{Key: key, Value: val})
} }
} }
for key, val := range friendLinkSettingDefaults { for key, val := range friendLinkSettingDefaults {
var count int64 var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 { if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) models.DB.Create(&models.ForumSetting{Key: key, Value: val})
} }
} }
} }
func (s *ForumSettingsService) getString(key, fallback string) string { func (s *ForumSettingsService) getString(key, fallback string) string {
var setting model.ForumSetting var setting models.ForumSetting
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil { if err := models.DB.First(&setting, "`key` = ?", key).Error; err != nil {
return fallback return fallback
} }
return setting.Value return setting.Value
@@ -477,12 +477,12 @@ func (s *ForumSettingsService) getString(key, fallback string) string {
func (s *ForumSettingsService) setString(key, value string) error { func (s *ForumSettingsService) setString(key, value string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
return model.DB.Save(&model.ForumSetting{Key: key, Value: value}).Error return models.DB.Save(&models.ForumSetting{Key: key, Value: value}).Error
} }
func (s *ForumSettingsService) getInt(key string, fallback int) int { func (s *ForumSettingsService) getInt(key string, fallback int) int {
var setting model.ForumSetting var setting models.ForumSetting
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil { if err := models.DB.First(&setting, "`key` = ?", key).Error; err != nil {
return fallback return fallback
} }
v, err := strconv.Atoi(setting.Value) v, err := strconv.Atoi(setting.Value)
@@ -505,7 +505,7 @@ func (s *ForumSettingsService) setInt(key string, value int) error {
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
return model.DB.Save(&model.ForumSetting{Key: key, Value: strconv.Itoa(value)}).Error return models.DB.Save(&models.ForumSetting{Key: key, Value: strconv.Itoa(value)}).Error
} }
return ErrInvalidSetting return ErrInvalidSetting
} }
@@ -1042,7 +1042,7 @@ func (s *ForumSettingsService) GiteaSyncConfig() GiteaSyncConfig {
} }
cfg.Ready = cfg.Enabled && base != "" && cfg.HasToken cfg.Ready = cfg.Enabled && base != "" && cfg.HasToken
var n int64 var n int64
model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false).Count(&n) models.DB.Model(&models.GiteaRepo{}).Where("private = ?", false).Count(&n)
cfg.RepoCount = n cfg.RepoCount = n
return cfg return cfg
} }

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"strings" "strings"

View File

@@ -1,10 +1,10 @@
package service package services
import ( import (
"errors" "errors"
"strings" "strings"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
var ( var (
@@ -32,8 +32,8 @@ type SitePageSummary struct {
} }
func (s *SitePageService) ListPublished() ([]SitePageSummary, error) { func (s *SitePageService) ListPublished() ([]SitePageSummary, error) {
var rows []model.SitePage var rows []models.SitePage
err := model.DB.Where("published = ?", true). err := models.DB.Where("published = ?", true).
Order("sort_order ASC, id ASC"). Order("sort_order ASC, id ASC").
Find(&rows).Error Find(&rows).Error
if err != nil { if err != nil {
@@ -49,22 +49,22 @@ func (s *SitePageService) ListPublished() ([]SitePageSummary, error) {
return out, nil return out, nil
} }
func (s *SitePageService) ListAll() ([]model.SitePage, error) { func (s *SitePageService) ListAll() ([]models.SitePage, error) {
var rows []model.SitePage var rows []models.SitePage
err := model.DB.Order("sort_order ASC, id ASC").Find(&rows).Error err := models.DB.Order("sort_order ASC, id ASC").Find(&rows).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return rows, nil return rows, nil
} }
func (s *SitePageService) GetBySlug(slug string, allowUnpublished bool) (*model.SitePage, error) { func (s *SitePageService) GetBySlug(slug string, allowUnpublished bool) (*models.SitePage, error) {
slug, ok := NormalizePageSlug(slug) slug, ok := NormalizePageSlug(slug)
if !ok { if !ok {
return nil, ErrSitePageNotFound return nil, ErrSitePageNotFound
} }
var page model.SitePage var page models.SitePage
q := model.DB.Where("slug = ?", slug) q := models.DB.Where("slug = ?", slug)
if !allowUnpublished { if !allowUnpublished {
q = q.Where("published = ?", true) q = q.Where("published = ?", true)
} }
@@ -75,9 +75,9 @@ func (s *SitePageService) GetBySlug(slug string, allowUnpublished bool) (*model.
return &page, nil return &page, nil
} }
func (s *SitePageService) GetByID(id uint) (*model.SitePage, error) { func (s *SitePageService) GetByID(id uint) (*models.SitePage, error) {
var page model.SitePage var page models.SitePage
if err := model.DB.First(&page, id).Error; err != nil { if err := models.DB.First(&page, id).Error; err != nil {
return nil, ErrSitePageNotFound return nil, ErrSitePageNotFound
} }
page.Content = SanitizePostHTML(UnwrapContentGateTags(page.Content)) page.Content = SanitizePostHTML(UnwrapContentGateTags(page.Content))
@@ -94,17 +94,17 @@ type SitePageInput struct {
ShowInNav bool `json:"show_in_nav"` ShowInNav bool `json:"show_in_nav"`
} }
func (s *SitePageService) Create(in SitePageInput) (*model.SitePage, error) { func (s *SitePageService) Create(in SitePageInput) (*models.SitePage, error) {
page, err := s.normalizeInput(in) page, err := s.normalizeInput(in)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var exists int64 var exists int64
model.DB.Model(&model.SitePage{}).Where("slug = ?", page.Slug).Count(&exists) models.DB.Model(&models.SitePage{}).Where("slug = ?", page.Slug).Count(&exists)
if exists > 0 { if exists > 0 {
return nil, ErrSitePageSlugUsed return nil, ErrSitePageSlugUsed
} }
if err := model.DB.Create(page).Error; err != nil { if err := models.DB.Create(page).Error; err != nil {
return nil, err return nil, err
} }
return page, nil return page, nil
@@ -120,11 +120,11 @@ func (s *SitePageService) Update(id uint, in SitePageInput) error {
return err return err
} }
var exists int64 var exists int64
model.DB.Model(&model.SitePage{}).Where("slug = ? AND id <> ?", next.Slug, id).Count(&exists) models.DB.Model(&models.SitePage{}).Where("slug = ? AND id <> ?", next.Slug, id).Count(&exists)
if exists > 0 { if exists > 0 {
return ErrSitePageSlugUsed return ErrSitePageSlugUsed
} }
return model.DB.Model(page).Updates(map[string]interface{}{ return models.DB.Model(page).Updates(map[string]interface{}{
"title": next.Title, "title": next.Title,
"slug": next.Slug, "slug": next.Slug,
"content": next.Content, "content": next.Content,
@@ -136,7 +136,7 @@ func (s *SitePageService) Update(id uint, in SitePageInput) error {
} }
func (s *SitePageService) Delete(id uint) error { func (s *SitePageService) Delete(id uint) error {
res := model.DB.Delete(&model.SitePage{}, id) res := models.DB.Delete(&models.SitePage{}, id)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -152,20 +152,20 @@ func (s *SitePageService) SetPublished(id uint, published bool) error {
if err != nil { if err != nil {
return err return err
} }
return model.DB.Model(page).Update("published", published).Error return models.DB.Model(page).Update("published", published).Error
} }
func (s *SitePageService) ListSitemap(limit int) ([]model.SitePage, error) { func (s *SitePageService) ListSitemap(limit int) ([]models.SitePage, error) {
if limit <= 0 { if limit <= 0 {
limit = 500 limit = 500
} }
var rows []model.SitePage var rows []models.SitePage
err := model.DB.Where("published = ?", true). err := models.DB.Where("published = ?", true).
Order("updated_at DESC").Limit(limit).Find(&rows).Error Order("updated_at DESC").Limit(limit).Find(&rows).Error
return rows, err return rows, err
} }
func (s *SitePageService) normalizeInput(in SitePageInput) (*model.SitePage, error) { func (s *SitePageService) normalizeInput(in SitePageInput) (*models.SitePage, error) {
title := s.filter.Filter(strings.TrimSpace(in.Title)) title := s.filter.Filter(strings.TrimSpace(in.Title))
slug, ok := NormalizePageSlug(in.Slug) slug, ok := NormalizePageSlug(in.Slug)
if !ok { if !ok {
@@ -179,7 +179,7 @@ func (s *SitePageService) normalizeInput(in SitePageInput) (*model.SitePage, err
if content == "" { if content == "" {
return nil, errors.New("正文不能为空") return nil, errors.New("正文不能为空")
} }
return &model.SitePage{ return &models.SitePage{
Title: title, Slug: slug, Content: content, Title: title, Slug: slug, Content: content,
Published: in.Published, SortOrder: in.SortOrder, Published: in.Published, SortOrder: in.SortOrder,
ShowInFooter: in.ShowInFooter, ShowInNav: in.ShowInNav, ShowInFooter: in.ShowInFooter, ShowInNav: in.ShowInNav,

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"crypto/sha256" "crypto/sha256"
@@ -10,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -110,8 +110,8 @@ func ListUnlockedKeys(userID, postID uint) (map[string]bool, error) {
if userID == 0 || postID == 0 { if userID == 0 || postID == 0 {
return out, nil return out, nil
} }
var rows []model.PostContentUnlock var rows []models.PostContentUnlock
if err := model.DB.Select("block_key").Where("user_id = ? AND post_id = ?", userID, postID).Find(&rows).Error; err != nil { if err := models.DB.Select("block_key").Where("user_id = ? AND post_id = ?", userID, postID).Find(&rows).Error; err != nil {
return out, err return out, err
} }
for _, r := range rows { for _, r := range rows {
@@ -131,8 +131,8 @@ type UnlockResult struct {
// UnlockPointsBlock 积分解锁付费块 // UnlockPointsBlock 积分解锁付费块
func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, error) { func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, error) {
var post model.Post var post models.Post
if err := model.DB.First(&post, postID).Error; err != nil { if err := models.DB.First(&post, postID).Error; err != nil {
return nil, errors.New("帖子不存在") return nil, errors.New("帖子不存在")
} }
block, ok := FindPointsBlock(post.Content, blockKey) block, ok := FindPointsBlock(post.Content, blockKey)
@@ -143,26 +143,26 @@ func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, e
// 作者自己免费解锁记录(无分成) // 作者自己免费解锁记录(无分成)
if readerID == post.UserID { if readerID == post.UserID {
var n int64 var n int64
model.DB.Model(&model.PostContentUnlock{}).Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Count(&n) models.DB.Model(&models.PostContentUnlock{}).Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Count(&n)
if n == 0 { if n == 0 {
_ = model.DB.Create(&model.PostContentUnlock{ _ = models.DB.Create(&models.PostContentUnlock{
UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: 0, UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: 0,
}).Error }).Error
} }
return &UnlockResult{BlockKey: blockKey, Cost: 0, AuthorShare: 0, InnerHTML: block.Inner}, nil return &UnlockResult{BlockKey: blockKey, Cost: 0, AuthorShare: 0, InnerHTML: block.Inner}, nil
} }
var existing model.PostContentUnlock var existing models.PostContentUnlock
model.DB.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&existing) models.DB.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&existing)
if existing.ID > 0 { if existing.ID > 0 {
return nil, ErrAlreadyUnlocked return nil, ErrAlreadyUnlocked
} }
var reader, author model.User var reader, author models.User
if err := model.DB.First(&reader, readerID).Error; err != nil { if err := models.DB.First(&reader, readerID).Error; err != nil {
return nil, err return nil, err
} }
if err := model.DB.First(&author, post.UserID).Error; err != nil { if err := models.DB.First(&author, post.UserID).Error; err != nil {
return nil, err return nil, err
} }
@@ -174,8 +174,8 @@ func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, e
cost := block.Cost cost := block.Cost
authorShare := cost * CreatorSharePercent / 100 authorShare := cost * CreatorSharePercent / 100
var bal int var bal int
err := model.DB.Transaction(func(tx *gorm.DB) error { err := models.DB.Transaction(func(tx *gorm.DB) error {
var again model.PostContentUnlock var again models.PostContentUnlock
if err := tx.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&again).Error; err != nil { if err := tx.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&again).Error; err != nil {
return err return err
} }
@@ -183,20 +183,20 @@ func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, e
return ErrAlreadyUnlocked return ErrAlreadyUnlocked
} }
var e error var e error
bal, e = AdjustPointsTx(tx, readerID, -cost, model.PointReasonUnlockSpend, "post_unlock", postID, "解锁付费内容") bal, e = AdjustPointsTx(tx, readerID, -cost, models.PointReasonUnlockSpend, "post_unlock", postID, "解锁付费内容")
if e != nil { if e != nil {
return e return e
} }
if authorShare > 0 { if authorShare > 0 {
if _, e = AdjustPointsTx(tx, author.ID, authorShare, model.PointReasonCreatorIncome, "post_unlock", postID, "创作分成"); e != nil { if _, e = AdjustPointsTx(tx, author.ID, authorShare, models.PointReasonCreatorIncome, "post_unlock", postID, "创作分成"); e != nil {
return e return e
} }
if e = tx.Model(&model.User{}).Where("id = ?", author.ID). if e = tx.Model(&models.User{}).Where("id = ?", author.ID).
UpdateColumn("creator_income_total", gorm.Expr("creator_income_total + ?", authorShare)).Error; e != nil { UpdateColumn("creator_income_total", gorm.Expr("creator_income_total + ?", authorShare)).Error; e != nil {
return e return e
} }
} }
return tx.Create(&model.PostContentUnlock{ return tx.Create(&models.PostContentUnlock{
UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: cost, UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: cost,
}).Error }).Error
}) })
@@ -213,7 +213,7 @@ func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, e
}, nil }, nil
} }
func suspiciousUnlockPair(reader, author *model.User) bool { func suspiciousUnlockPair(reader, author *models.User) bool {
if reader == nil || author == nil { if reader == nil || author == nil {
return false return false
} }

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"mime/multipart" "mime/multipart"

View File

@@ -1,4 +1,4 @@
package service package services
import ( import (
"errors" "errors"
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
) )
type UserService struct { type UserService struct {
@@ -21,9 +21,9 @@ func NewUserService(filter *SensitiveFilter, settings *ForumSettingsService) *Us
} }
// GetByID 获取用户信息 // GetByID 获取用户信息
func (s *UserService) GetByID(id uint) (*model.User, error) { func (s *UserService) GetByID(id uint) (*models.User, error) {
var user model.User var user models.User
if err := model.DB.First(&user, id).Error; err != nil { if err := models.DB.First(&user, id).Error; err != nil {
return nil, err return nil, err
} }
return &user, nil return &user, nil
@@ -43,17 +43,17 @@ func (s *UserService) ActivityStats(userID uint) (UserActivityStats, error) {
if userID == 0 { if userID == 0 {
return st, errors.New("无效用户") return st, errors.New("无效用户")
} }
if err := model.DB.Model(&model.Post{}).Where("user_id = ?", userID).Count(&st.PostCount).Error; err != nil { if err := models.DB.Model(&models.Post{}).Where("user_id = ?", userID).Count(&st.PostCount).Error; err != nil {
return st, err return st, err
} }
if err := model.DB.Model(&model.Comment{}).Where("user_id = ?", userID).Count(&st.CommentCount).Error; err != nil { if err := models.DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&st.CommentCount).Error; err != nil {
return st, err return st, err
} }
if err := model.DB.Model(&model.PostFavorite{}).Where("user_id = ?", userID).Count(&st.FavoriteCount).Error; err != nil { if err := models.DB.Model(&models.PostFavorite{}).Where("user_id = ?", userID).Count(&st.FavoriteCount).Error; err != nil {
return st, err return st, err
} }
var likeSum int64 var likeSum int64
if err := model.DB.Model(&model.Post{}). if err := models.DB.Model(&models.Post{}).
Select("COALESCE(SUM(like_count), 0)"). Select("COALESCE(SUM(like_count), 0)").
Where("user_id = ?", userID). Where("user_id = ?", userID).
Scan(&likeSum).Error; err != nil { Scan(&likeSum).Error; err != nil {
@@ -64,19 +64,19 @@ func (s *UserService) ActivityStats(userID uint) (UserActivityStats, error) {
} }
// GetByUsername 按用户名查询 // GetByUsername 按用户名查询
func (s *UserService) GetByUsername(username string) (*model.User, error) { func (s *UserService) GetByUsername(username string) (*models.User, error) {
var user model.User var user models.User
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil { if err := models.DB.Where("username = ?", username).First(&user).Error; err != nil {
return nil, err return nil, err
} }
return &user, nil return &user, nil
} }
// GetByEmail 按邮箱查询 // GetByEmail 按邮箱查询
func (s *UserService) GetByEmail(email string) (*model.User, error) { func (s *UserService) GetByEmail(email string) (*models.User, error) {
email = NormalizeEmail(email) email = NormalizeEmail(email)
var user model.User var user models.User
if err := model.DB.Where("email = ?", email).First(&user).Error; err != nil { if err := models.DB.Where("email = ?", email).First(&user).Error; err != nil {
return nil, err return nil, err
} }
return &user, nil return &user, nil
@@ -95,21 +95,21 @@ func (s *UserService) ResetPasswordByEmail(email, newPass string) error {
if err != nil { if err != nil {
return err return err
} }
return model.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("password", hash).Error return models.DB.Model(&models.User{}).Where("id = ?", user.ID).Update("password", hash).Error
} }
// SearchUsersBrief 公开用户搜索(@补全):匹配用户名/昵称,不含邮箱 // SearchUsersBrief 公开用户搜索(@补全):匹配用户名/昵称,不含邮箱
func (s *UserService) SearchUsersBrief(keyword string, limit int) ([]model.User, error) { func (s *UserService) SearchUsersBrief(keyword string, limit int) ([]models.User, error) {
keyword = strings.TrimSpace(keyword) keyword = strings.TrimSpace(keyword)
if keyword == "" { if keyword == "" {
return []model.User{}, nil return []models.User{}, nil
} }
if limit <= 0 || limit > 20 { if limit <= 0 || limit > 20 {
limit = 8 limit = 8
} }
like := "%" + keyword + "%" like := "%" + keyword + "%"
var users []model.User var users []models.User
err := model.DB.Select("id", "username", "nickname", "avatar", "role", "verified"). err := models.DB.Select("id", "username", "nickname", "avatar", "role", "verified").
Where("username LIKE ? OR nickname LIKE ?", like, like). Where("username LIKE ? OR nickname LIKE ?", like, like).
Order("username ASC"). Order("username ASC").
Limit(limit). Limit(limit).
@@ -118,7 +118,7 @@ func (s *UserService) SearchUsersBrief(keyword string, limit int) ([]model.User,
return nil, err return nil, err
} }
if users == nil { if users == nil {
users = []model.User{} users = []models.User{}
} }
return users, nil return users, nil
} }
@@ -136,8 +136,8 @@ func (s *UserService) ListRecentRegistered(limit int) ([]RecentUserItem, error)
if limit < 1 { if limit < 1 {
limit = 8 limit = 8
} }
var users []model.User var users []models.User
err := model.DB.Select("id", "username", "nickname", "avatar", "created_at"). err := models.DB.Select("id", "username", "nickname", "avatar", "created_at").
Where("banned = ?", false). Where("banned = ?", false).
Order("created_at DESC, id DESC"). Order("created_at DESC, id DESC").
Limit(limit). Limit(limit).
@@ -168,7 +168,7 @@ func (s *UserService) UpdateNickname(userID uint, nickname string) error {
return errors.New("昵称不能为空") return errors.New("昵称不能为空")
} }
nickname = s.filter.Filter(nickname) nickname = s.filter.Filter(nickname)
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("nickname", nickname).Error return models.DB.Model(&models.User{}).Where("id = ?", userID).Update("nickname", nickname).Error
} }
// UpdateSignature 修改个人签名 // UpdateSignature 修改个人签名
@@ -184,7 +184,7 @@ func (s *UserService) UpdateSignature(userID uint, signature string) error {
if signature != "" { if signature != "" {
signature = s.filter.Filter(signature) signature = s.filter.Filter(signature)
} }
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("signature", signature).Error return models.DB.Model(&models.User{}).Where("id = ?", userID).Update("signature", signature).Error
} }
// UpdatePassword 修改密码 // UpdatePassword 修改密码
@@ -192,8 +192,8 @@ func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil { if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {
return err return err
} }
var user model.User var user models.User
if err := model.DB.First(&user, userID).Error; err != nil { if err := models.DB.First(&user, userID).Error; err != nil {
return err return err
} }
if !CheckPassword(user.Password, oldPass) { if !CheckPassword(user.Password, oldPass) {
@@ -203,20 +203,20 @@ func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error
if err != nil { if err != nil {
return err return err
} }
return model.DB.Model(&user).Update("password", hash).Error return models.DB.Model(&user).Update("password", hash).Error
} }
// UploadAvatar 上传头像;成功后删除用户旧头像文件,避免磁盘/对象存储堆积 // UploadAvatar 上传头像;成功后删除用户旧头像文件,避免磁盘/对象存储堆积
func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, store *UploadStore) (string, error) { func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, store *UploadStore) (string, error) {
var user model.User var user models.User
if err := model.DB.Select("id", "avatar").First(&user, userID).Error; err != nil { if err := models.DB.Select("id", "avatar").First(&user, userID).Error; err != nil {
return "", err return "", err
} }
url, err := SaveUploadedImage(store, file, UploadCategoryAvatars, fmt.Sprintf("%d", userID)) url, err := SaveUploadedImage(store, file, UploadCategoryAvatars, fmt.Sprintf("%d", userID))
if err != nil { if err != nil {
return "", err return "", err
} }
if err := model.DB.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error; err != nil { if err := models.DB.Model(&models.User{}).Where("id = ?", userID).Update("avatar", url).Error; err != nil {
return "", err return "", err
} }
if old := strings.TrimSpace(user.Avatar); old != "" && old != url { if old := strings.TrimSpace(user.Avatar); old != "" && old != url {
@@ -234,7 +234,7 @@ type UserListQuery struct {
Filter string // all | verified | banned | admin Filter string // all | verified | banned | admin
} }
func (s *UserService) ListUsers(q UserListQuery) ([]model.User, int64, error) { func (s *UserService) ListUsers(q UserListQuery) ([]models.User, int64, error) {
if q.Page < 1 { if q.Page < 1 {
q.Page = 1 q.Page = 1
} }
@@ -245,7 +245,7 @@ func (s *UserService) ListUsers(q UserListQuery) ([]model.User, int64, error) {
q.Size = 100 q.Size = 100
} }
db := model.DB.Model(&model.User{}) db := models.DB.Model(&models.User{})
kw := strings.TrimSpace(q.Keyword) kw := strings.TrimSpace(q.Keyword)
if kw != "" { if kw != "" {
like := "%" + kw + "%" like := "%" + kw + "%"
@@ -257,18 +257,18 @@ func (s *UserService) ListUsers(q UserListQuery) ([]model.User, int64, error) {
} }
switch strings.TrimSpace(q.Filter) { switch strings.TrimSpace(q.Filter) {
case "verified": case "verified":
db = db.Where("verified = ? AND role <> ?", true, model.RoleAdmin) db = db.Where("verified = ? AND role <> ?", true, models.RoleAdmin)
case "banned": case "banned":
db = db.Where("banned = ?", true) db = db.Where("banned = ?", true)
case "admin": case "admin":
db = db.Where("role = ?", model.RoleAdmin) db = db.Where("role = ?", models.RoleAdmin)
} }
var total int64 var total int64
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var users []model.User var users []models.User
offset := (q.Page - 1) * q.Size offset := (q.Page - 1) * q.Size
err := db.Order("id desc").Offset(offset).Limit(q.Size).Find(&users).Error err := db.Order("id desc").Offset(offset).Limit(q.Size).Find(&users).Error
return users, total, err return users, total, err
@@ -276,11 +276,11 @@ func (s *UserService) ListUsers(q UserListQuery) ([]model.User, int64, error) {
// BanUser 禁言用户 // BanUser 禁言用户
func (s *UserService) BanUser(userID uint, banned bool) error { func (s *UserService) BanUser(userID uint, banned bool) error {
var user model.User var user models.User
if err := model.DB.First(&user, userID).Error; err != nil { if err := models.DB.First(&user, userID).Error; err != nil {
return errors.New("用户不存在") return errors.New("用户不存在")
} }
if user.Role == model.RoleAdmin { if user.Role == models.RoleAdmin {
return errors.New("不能禁言管理员账号") return errors.New("不能禁言管理员账号")
} }
now := time.Now() now := time.Now()
@@ -288,7 +288,7 @@ func (s *UserService) BanUser(userID uint, banned bool) error {
if banned { if banned {
updates["banned_at"] = &now updates["banned_at"] = &now
} }
return model.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error return models.DB.Model(&models.User{}).Where("id = ?", userID).Updates(updates).Error
} }
// SitemapUser 站点地图用的轻量用户字段 // SitemapUser 站点地图用的轻量用户字段
@@ -303,7 +303,7 @@ func (s *UserService) ListSitemap(limit int) ([]SitemapUser, error) {
limit = 5000 limit = 5000
} }
var rows []SitemapUser var rows []SitemapUser
err := model.DB.Model(&model.User{}). err := models.DB.Model(&models.User{}).
Select("id, updated_at"). Select("id, updated_at").
Where("banned = ?", false). Where("banned = ?", false).
Order("updated_at desc, id desc"). Order("updated_at desc, id desc").