fix: 完成 Gitea 目录改组收尾(import、构建与 LICENSE)
同步包路径与路由,去掉 SPA 构建步骤,对齐 Gitea 式 LICENSE,并更新规格/规则与占位 SSR。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 检查清单
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
|
|||||||
@@ -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
18
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -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 SQLite,CGO_ENABLED=0)────────────────────────
|
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_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
12
LICENSE
@@ -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.
|
||||||
|
|||||||
50
Makefile
50
Makefile
@@ -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 :5173,Ctrl+C 同时退出;数据目录与 dist 二进制一致)
|
## 同 run(SPA 对照请 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
102
README.md
@@ -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)
|
[](LICENSE)
|
||||||
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](frontend/package.json)
|
[](docs/rebuild-spec/08-gitea-ssr-architecture.md)
|
||||||
[](#)
|
[](#)
|
||||||
|
|
||||||
[在线演示](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/template(embed)
|
||||||
└── 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 文本格式)— 自由使用、修改与分发。
|
||||||
|
|||||||
71
build.ps1
71
build.ps1
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`]((仅 main)frontend/src/App.tsx)、[`router/router.go`](../../routers/setup.go)、[`README.md`](../../README.md)
|
||||||
|
|
||||||
用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。
|
用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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`]((仅 main)frontend/src/api/client.ts)、[`frontend/src/api/types.ts`]((仅 main)frontend/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`]((仅 main)frontend/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)。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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`]((仅 main)frontend/src/App.tsx)、[`frontend/src/pages/`]((仅 main)frontend/src/pages/)、[`frontend/src/components/`]((仅 main)frontend/src/components/)、[`frontend/src/layouts/`]((仅 main)frontend/src/layouts/)
|
||||||
|
|
||||||
视觉可重设;**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。
|
视觉可重设;**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
|
|
||||||
### 3.2 编辑器能力(应对齐)
|
### 3.2 编辑器能力(应对齐)
|
||||||
|
|
||||||
源:[`ArticleEditor.tsx`](../../frontend/src/components/ArticleEditor.tsx) 与 `editor/` 扩展
|
源:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx) 与 `editor/` 扩展
|
||||||
|
|
||||||
- 标题 h2–h6(无 h1,避免与帖标题冲突)
|
- 标题 h2–h6(无 h1,避免与帖标题冲突)
|
||||||
- 粗体/斜体/删除线等基础标记
|
- 粗体/斜体/删除线等基础标记
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
- 图片上传 + 图片组布局 + 浮动/清除浮动
|
- 图片上传 + 图片组布局 + 浮动/清除浮动
|
||||||
- 表情 / 贴纸选择器(多套:bilibili/douyin/tieba/weibo 等静态资源)
|
- 表情 / 贴纸选择器(多套:bilibili/douyin/tieba/weibo 等静态资源)
|
||||||
- **登录可见** / **回复可见** / **积分可见**(价格 1–9999)节点
|
- **登录可见** / **回复可见** / **积分可见**(价格 1–9999)节点
|
||||||
- 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`](../../frontend/src/utils/markdownContent.ts))
|
- 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`]((仅 main)frontend/src/utils/markdownContent.ts))
|
||||||
- Tab 缩进
|
- Tab 缩进
|
||||||
|
|
||||||
未保存离开:`UnsavedChangesDialog`。
|
未保存离开:`UnsavedChangesDialog`。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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` | 可保留语义,实现可换成更好的会话方案 |
|
||||||
|
|
||||||
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(SSR)HTML;SEO meta 与正文同源。技术选型自定(Next.js / Nuxt / Remix / 其它均可)。
|
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(SSR)HTML;SEO 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package model
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package model
|
package models
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package model
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package model
|
package models
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package model
|
package models
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
|||||||
@@ -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
16
modules/seo/pagemeta.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, "/")
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
159
routers/setup.go
159
routers/setup.go
@@ -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(未迁移路径仍走 SPA);dev:Vite 可对照旧前台
|
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)
|
// 管理后台 HTML:SSR 尚未迁移;勿用 /*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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
|||||||
@@ -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 token;clientIP 写入上次登录记录
|
// Login 用户登录,返回 JWT token;clientIP 写入上次登录记录
|
||||||
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,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|||||||
@@ -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("等级须在 1–10")
|
return errors.New("等级须在 1–10")
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import "os"
|
import "os"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|||||||
@@ -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{})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|||||||
@@ -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(¬ify).Error
|
Count(¬ify).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")
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{})
|
||||||
}
|
}
|
||||||
|
|||||||
262
services/post.go
262
services/post.go
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|||||||
@@ -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 ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package service
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
Reference in New Issue
Block a user