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

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

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

View File

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

View File

@@ -21,9 +21,14 @@ alwaysApply: true
对齐 [Gitea](https://github.com/go-gitea/gitea) 职责划分:
- `routers/web` — HTML 页面路由
- `routers/api` — JSON API原 `handler/`
- `routers/setup.go` — 路由总装
- `templates/` — 模板
- `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)。

View File

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

18
.gitignore vendored
View File

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

View File

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

12
LICENSE
View File

@@ -1,6 +1,4 @@
MIT License
Copyright (c) 2026 freefire
Copyright (c) 2026 The Jiang13 Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
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
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
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
SOFTWARE.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

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

102
README.md
View File

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

View File

@@ -1,9 +1,10 @@
# Jiang13 Forum - Windows build script (replaces GNU Make)
# Usage: .\build.ps1
# .\build.ps1 -Target build-windows
# Branch rebuild/gitea-ssr: Go templates SSR + web_src (no React SPA)
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'
)
@@ -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 = '') {
Ensure-Dir $BuildDir
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) {
'help' {
Write-Host '.\build.ps1 build current platform'
Write-Host '.\build.ps1 -Target frontend SPA frontend only (legacy)'
Write-Host '.\build.ps1 -Target web-src SSR progressive assets'
Write-Host '.\build.ps1 build current platform (web_src + go)'
Write-Host '.\build.ps1 -Target web-src SSR progressive assets only'
Write-Host '.\build.ps1 -Target build-windows'
Write-Host '.\build.ps1 -Target build-linux'
Write-Host '.\build.ps1 -Target build-all'
Write-Host '.\build.ps1 -Target run backend (SSR on :3000)'
Write-Host '.\build.ps1 -Target dev backend + Vite SPA对照'
Write-Host '.\build.ps1 -Target run SSR on :3000'
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 clean'
Write-Host '.\build.ps1 -Target docker build Docker image'
Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build'
Write-Host '.\build.ps1 -Target compose-down docker compose down'
Write-Host '.\build.ps1 -Target docker'
Write-Host '.\build.ps1 -Target compose-up'
Write-Host '.\build.ps1 -Target compose-down'
Write-Host ''
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 }
'tidy' { go mod tidy }
'clean' {
@@ -105,54 +91,33 @@ switch ($Target) {
}
'run' {
Ensure-Dir $DevDataDir
go run $MainPkg --data $DevDataDir
Build-WebSrc
go run $MainPkg --work-path . --data $DevDataDir
}
'dev' {
$root = (Get-Location).Path
Ensure-Dir $DevDataDir
Write-Host ''
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
Write-Host '[dev] 提示 : 请访问 5173 端口Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
Start-Process powershell -ArgumentList @(
'-NoExit', '-Command',
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --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-WebSrc
Write-Host '[dev] SSR: http://localhost:3000 (SPA 对照请 checkout main)' -ForegroundColor Green
go run $MainPkg --work-path . --data $DevDataDir
}
'build' {
Build-WebSrc
Build-Frontend
Build-Go -OutFile $AppName
}
'build-windows' {
Build-WebSrc
Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
}
'build-linux' {
Write-Host '[build-linux] will build web_src + SPA then go:embed' -ForegroundColor Yellow
Build-WebSrc
Build-Frontend
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
}
'build-darwin' {
Build-WebSrc
Build-Frontend
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
}
'build-all' {
Build-WebSrc
Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
@@ -166,12 +131,10 @@ switch ($Target) {
}
'compose-up' {
docker compose up -d --build
if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' }
Write-Host '[ok] compose started' -ForegroundColor Green
if ($LASTEXITCODE -ne 0) { throw 'compose up failed' }
}
'compose-down' {
docker compose down
if ($LASTEXITCODE -ne 0) { throw 'docker compose down failed' }
Write-Host '[ok] compose stopped' -ForegroundColor Green
if ($LASTEXITCODE -ne 0) { throw 'compose down failed' }
}
}

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
> **读者**:实现数据库与领域层的 AI
> **前置**[01-product.md](01-product.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但**字段语义应对齐**。
@@ -72,7 +72,7 @@ erDiagram
**非落库展示字段**`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
@@ -278,7 +278,7 @@ Metric`tenure_days` | `likes_received` | `creator_income`
## 4. 等级Exp → Level
源:[`model/level.go`](../../model/level.go)
源:[`model/level.go`](../../models/level.go)
| Level | 最低 Exp |
|-------|----------|
@@ -299,7 +299,7 @@ Metric`tenure_days` | `likes_received` | `creator_income`
## 5. 内置自动徽章seed
源:[`model/db.go`](../../model/db.go) `seedDefaultBadges`
源:[`model/db.go`](../../models/db.go) `seedDefaultBadges`
| code | 名称 | metric | threshold |
|------|------|--------|-----------|
@@ -317,7 +317,7 @@ Metric`tenure_days` | `likes_received` | `creator_income`
## 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 论坛限制
@@ -429,7 +429,7 @@ Metric`tenure_days` | `likes_received` | `creator_income`
## 7. 升级兼容补丁(现网 InitDB
[`model/db.go`](../../model/db.go) 在 AutoMigrate 后:
[`model/db.go`](../../models/db.go) 在 AutoMigrate 后:
-`status` 的帖/评 → `published`
-`post_type``normal`
@@ -449,4 +449,4 @@ Metric`tenure_days` | `likes_received` | `creator_income`
<points-only data-cost="10">...</points-only>
```
积分解锁 `block_key` = `sha256(innerHTML)[:16]`hex见 [`service/unlock.go`](../../service/unlock.go)。
积分解锁 `block_key` = `sha256(innerHTML)[:16]`hex见 [`service/unlock.go`](../../services/unlock.go)。

View File

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

View File

@@ -2,13 +2,13 @@
> **读者**:实现领域逻辑的 AI最易「看起来像但算错」
> **前置**[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. 注册与引导
源:[`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` |
| 游客评论 | 通常直接或按实现进入审核;勿假设与登录用户完全相同 |
源:[`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 |
| 帖子被点赞 | +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 到门槛值。
@@ -76,7 +76,7 @@ stateDiagram-v2
## 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 出口顺序(详情)
@@ -148,7 +148,7 @@ stateDiagram-v2
## 7. 签到与每日抽奖
源:[`service/points.go`](../../service/points.go)
源:[`service/points.go`](../../services/points.go)
### 签到
@@ -225,4 +225,4 @@ stateDiagram-v2
| `board_pinned` | **不**抬升 | 抬升 |
| `featured` | 标记展示,不一定改变排序 | 同左 |
具体 SQL/排序实现见 [`service/post.go`](../../service/post.go) ListItems。
具体 SQL/排序实现见 [`service/post.go`](../../services/post.go) ListItems。

View File

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

View File

@@ -2,7 +2,7 @@
> **读者**:部署与运维、以及实现配置层的 AI
> **前置**[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** |
| 爬虫 HTML | User-Agent 命中时 [`seo_bot.go`](../../handler/seo_bot.go) 返回简易 HTML |
| SPA 壳注入 | `embed_static`(仅 `main` 分支) 注入 title / branding JSON**无帖文 DOM** |
| 爬虫 HTML | User-Agent 命中时 [`seo_bot.go`](../../routers/api/seo_bot.go) 返回简易 HTML |
| robots.txt / sitemap.xml | 动态生成 |
重构验收:用普通浏览器「查看网页源代码」应能看到帖文正文,而不仅是空 div + script。
@@ -110,7 +110,7 @@ data/
- `/board/2.html`
- `/page/about.html`
路由应同时接受无后缀与有后缀形式。解析逻辑见 [`service/permalink.go`](../../service/permalink.go)。
路由应同时接受无后缀与有后缀形式。解析逻辑见 [`service/permalink.go`](../../services/permalink.go)。
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,16 @@
package seo
// PageMeta 页面级 SEO / 社交预览元数据SSR 与爬虫 HTML 共用)
type PageMeta struct {
Title string // 完整 <title>
Description string
Keywords string // meta keywords
Canonical string
OGType string // 默认 website
OGImage string
SiteName string // og:site_name
Locale string // og:locale默认 zh_CN
Robots string // 如 noindex,nofollow
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
Status int // HTTP 状态码0 视为 200
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package handler
package api
import (
"encoding/base64"
@@ -8,8 +8,8 @@ import (
"strings"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/service"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/services"
)
// OIDCDiscovery OpenID Provider 元数据
@@ -47,7 +47,7 @@ func (h *Handlers) OIDCAuthorize(c *gin.Context) {
return
}
req := service.AuthorizeRequest{
req := services.AuthorizeRequest{
ClientID: c.Query("client_id"),
RedirectURI: c.Query("redirect_uri"),
ResponseType: c.Query("response_type"),
@@ -60,7 +60,7 @@ func (h *Handlers) OIDCAuthorize(c *gin.Context) {
if err := h.OIDC.ValidateAuthorize(req); err != nil {
// 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())
return
}
@@ -77,7 +77,7 @@ func (h *Handlers) OIDCAuthorize(c *gin.Context) {
callback, err := h.OIDC.IssueAuthCode(uid, req)
if err != nil {
if errors.Is(err, service.ErrOIDCUserBanned) {
if errors.Is(err, services.ErrOIDCUserBanned) {
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
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"),
Code: c.PostForm("code"),
RedirectURI: c.PostForm("redirect_uri"),
@@ -133,12 +133,12 @@ func (h *Handlers) OIDCToken(c *gin.Context) {
status := http.StatusBadRequest
code := "invalid_grant"
switch {
case errors.Is(err, service.ErrOIDCInvalidClient):
case errors.Is(err, services.ErrOIDCInvalidClient):
status = http.StatusUnauthorized
code = "invalid_client"
case errors.Is(err, service.ErrOIDCInvalidRequest):
case errors.Is(err, services.ErrOIDCInvalidRequest):
code = "invalid_request"
case errors.Is(err, service.ErrOIDCPKCEFailed):
case errors.Is(err, services.ErrOIDCPKCEFailed):
code = "invalid_grant"
}
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")
}
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
c.SetCookie(auth.CookieName, "", -1, "/", "", false, true)
if h.OIDC == nil {
c.Redirect(http.StatusFound, "/")

View File

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

View File

@@ -1,4 +1,4 @@
package handler
package api
import (
"encoding/json"
@@ -11,9 +11,9 @@ import (
"time"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/embed_static"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
"git.iioio.com/freefire/jiang13-forum/modules/seo"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
)
var (
@@ -60,7 +60,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
now := time.Now().UTC()
permalink := h.Settings.Permalink()
urls := []service.SitemapURL{
urls := []services.SitemapURL{
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
{Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", 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 {
for _, board := range boards {
urls = append(urls, service.SitemapURL{
Loc: base + service.QueryBoardHome(board.ID, permalink),
urls = append(urls, services.SitemapURL{
Loc: base + services.QueryBoardHome(board.ID, permalink),
LastMod: board.UpdatedAt.UTC(),
ChangeFreq: "daily",
Priority: "0.7",
@@ -83,7 +83,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
if lm.IsZero() {
lm = p.CreatedAt
}
urls = append(urls, service.SitemapURL{
urls = append(urls, services.SitemapURL{
Loc: base + permalink.PostPath(p.ID),
LastMod: lm.UTC(),
ChangeFreq: "weekly",
@@ -94,7 +94,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil {
for _, u := range users {
urls = append(urls, service.SitemapURL{
urls = append(urls, services.SitemapURL{
Loc: base + permalink.UserPath(u.ID),
LastMod: u.UpdatedAt.UTC(),
ChangeFreq: "weekly",
@@ -109,7 +109,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
if lm.IsZero() {
lm = p.CreatedAt
}
urls = append(urls, service.SitemapURL{
urls = append(urls, services.SitemapURL{
Loc: base + permalink.PagePath(p.Slug),
LastMod: lm.UTC(),
ChangeFreq: "monthly",
@@ -160,14 +160,14 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
if siteName == "" {
siteName = "姜十三论坛"
}
defaultImage := service.AbsoluteURL(base, brand.DefaultShareImage())
defaultImage := services.AbsoluteURL(base, brand.DefaultShareImage())
siteKeywords := brand.MetaKeywords()
permalink := h.Settings.Permalink()
// 旧版 /?board=id → 规范板块路径
if path == "/" || path == "" {
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 != "" {
// 保留 sort/keyword 等 query去掉 board
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 {
c.Header("Vary", "User-Agent")
}
@@ -201,11 +201,11 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
if desc == "" {
desc = brand.MetaDescription()
}
meta := attachSiteSEO(&embed_static.SPAPageMeta{
meta := attachSiteSEO(&seo.PageMeta{
Title: pageTitle(board.Name, siteName),
Description: service.TruncateRunes(desc, seoDescMax),
Keywords: service.JoinSEOKeywords(board.Name, siteKeywords),
Canonical: service.AbsoluteURL(base, bm.Canonical),
Description: services.TruncateRunes(desc, seoDescMax),
Keywords: services.JoinSEOKeywords(board.Name, siteKeywords),
Canonical: services.AbsoluteURL(base, bm.Canonical),
OGType: "website",
OGImage: defaultImage,
}, 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)))
return
}
embed_static.ServeSPAWithMeta(c, meta)
servePendingSSR(c, meta.Title, `<p>板块页 SSR 迁移中,请先从 <a href="/">首页</a> 浏览。</p>`)
return
}
@@ -224,16 +224,16 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
return
}
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)
return
}
postKeywords := service.JoinSEOKeywords(post.Board.Name, siteKeywords)
postKeywords := services.JoinSEOKeywords(post.Board.Name, siteKeywords)
if isBot {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post)))
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
}
@@ -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)))
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
}
@@ -267,12 +267,12 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
return
}
desc := service.ExcerptFromHTML(page.Content, seoDescMax)
meta := attachSiteSEO(&embed_static.SPAPageMeta{
desc := services.ExcerptFromHTML(page.Content, seoDescMax)
meta := attachSiteSEO(&seo.PageMeta{
Title: pageTitle(page.Title, siteName),
Description: desc,
Keywords: service.JoinSEOKeywords(page.Title, siteKeywords),
Canonical: service.AbsoluteURL(base, pg.Canonical),
Keywords: services.JoinSEOKeywords(page.Title, siteKeywords),
Canonical: services.AbsoluteURL(base, pg.Canonical),
OGType: "article",
OGImage: defaultImage,
}, 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)))
return
}
embed_static.ServeSPAWithMeta(c, meta)
servePendingSSR(c, meta.Title, page.Content)
return
}
@@ -291,13 +291,23 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
return
}
// 其余已知路由:SPA + head meta首页对爬虫额外返回可读正文
// 其余已知路由:爬虫可读首页;用户走占位页(首页本身已由 routers/web SSR
meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
if isBot && (path == "/" || path == "") {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand)))
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) {
@@ -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)))
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 {
return attachSiteSEO(&embed_static.SPAPageMeta{
func notFoundPageMeta(base, siteName, keywords, path string) *seo.PageMeta {
return attachSiteSEO(&seo.PageMeta{
Title: pageTitle("页面不存在", siteName),
Description: "您访问的页面不存在或已删除",
Canonical: service.AbsoluteURL(base, path),
Canonical: services.AbsoluteURL(base, path),
OGType: "website",
Robots: "noindex,follow",
Status: http.StatusNotFound,
@@ -321,7 +334,7 @@ func notFoundPageMeta(base, siteName, keywords, path string) *embed_static.SPAPa
}
// 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 {
return nil
}
@@ -341,7 +354,7 @@ func isKnownPublicPath(path string) bool {
if seoPostEditRe.MatchString(path) {
return true
}
permalink := service.PermalinkConfig{}
permalink := services.PermalinkConfig{}
if permalink.MatchBoardPath(path).OK {
return true
}
@@ -351,15 +364,15 @@ func isKnownPublicPath(path string) bool {
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()
homeDesc := service.TruncateRunes(brand.MetaDescription(), seoDescMax)
homeDesc := services.TruncateRunes(brand.MetaDescription(), seoDescMax)
siteKeywords := brand.MetaKeywords()
meta := attachSiteSEO(&embed_static.SPAPageMeta{
meta := attachSiteSEO(&seo.PageMeta{
Title: siteTitle,
Description: homeDesc,
Keywords: siteKeywords,
Canonical: service.AbsoluteURL(base, pathWithQuery(c)),
Canonical: services.AbsoluteURL(base, pathWithQuery(c)),
OGType: "website",
OGImage: defaultImage,
}, siteName, siteKeywords)
@@ -379,9 +392,9 @@ func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.S
desc = brand.MetaDescription()
}
meta.Title = pageTitle(board.Name, siteName)
meta.Description = service.TruncateRunes(desc, seoDescMax)
meta.Canonical = service.AbsoluteURL(base, service.QueryBoardHome(board.ID, h.Settings.Permalink()))
meta.Keywords = service.JoinSEOKeywords(board.Name, siteKeywords)
meta.Description = services.TruncateRunes(desc, seoDescMax)
meta.Canonical = services.AbsoluteURL(base, services.QueryBoardHome(board.ID, h.Settings.Permalink()))
meta.Keywords = services.JoinSEOKeywords(board.Name, siteKeywords)
return meta
}
// 无效板块 id仍显示首页但可标记 noindex
@@ -393,38 +406,38 @@ func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.S
"@type": "WebSite",
"name": siteName,
"description": meta.Description,
"url": service.AbsoluteURL(base, "/"),
"url": services.AbsoluteURL(base, "/"),
})
}
if path == "/projects" {
meta.Title = pageTitle("项目", siteName)
meta.Description = service.TruncateRunes(siteName+" 的公开项目列表", seoDescMax)
meta.Keywords = service.JoinSEOKeywords("项目", siteKeywords)
meta.Description = services.TruncateRunes(siteName+" 的公开项目列表", seoDescMax)
meta.Keywords = services.JoinSEOKeywords("项目", siteKeywords)
}
if path == "/links" {
meta.Title = pageTitle("友情链接", siteName)
meta.Description = service.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax)
meta.Keywords = service.JoinSEOKeywords("友情链接", siteKeywords)
meta.Description = services.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax)
meta.Keywords = services.JoinSEOKeywords("友情链接", siteKeywords)
}
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()
content := service.RedactGatedPostHTML(post.Content)
content := services.RedactGatedPostHTML(post.Content)
plain := post.ContentPlain
if plain == "" {
plain = service.StripHTMLForSearch(content)
plain = services.StripHTMLForSearch(content)
}
desc := service.TruncateRunes(plain, seoDescMax)
author := service.DisplayName(&post.User)
canonical := service.AbsoluteURL(base, permalink.PostPath(post.ID))
ogImage := service.AbsoluteURL(base, service.FirstImageURL(content))
desc := services.TruncateRunes(plain, seoDescMax)
author := services.DisplayName(&post.User)
canonical := services.AbsoluteURL(base, permalink.PostPath(post.ID))
ogImage := services.AbsoluteURL(base, services.FirstImageURL(content))
if ogImage == "" {
ogImage = service.AbsoluteURL(base, post.User.Avatar)
ogImage = services.AbsoluteURL(base, post.User.Avatar)
}
if ogImage == "" {
ogImage = defaultImage
@@ -442,7 +455,7 @@ func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model
"author": map[string]any{
"@type": "Person",
"name": author,
"url": service.AbsoluteURL(base, permalink.UserPath(post.UserID)),
"url": services.AbsoluteURL(base, permalink.UserPath(post.UserID)),
},
"interactionStatistic": map[string]any{
"@type": "InteractionCounter",
@@ -456,12 +469,12 @@ func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model
if ogImage != "" {
jsonld["image"] = []string{ogImage}
}
body := service.TruncateRunes(plain, seoPrerenderMax)
body := services.TruncateRunes(plain, seoPrerenderMax)
if body != "" {
jsonld["articleBody"] = body
}
return &embed_static.SPAPageMeta{
return &seo.PageMeta{
Title: pageTitle(post.Title, siteName),
Description: desc,
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()
name := service.DisplayName(user)
name := services.DisplayName(user)
desc := strings.TrimSpace(user.Signature)
if desc == "" {
desc = name + " 的主页"
}
desc = service.TruncateRunes(desc, seoDescMax)
canonical := service.AbsoluteURL(base, permalink.UserPath(user.ID))
ogImage := service.AbsoluteURL(base, user.Avatar)
desc = services.TruncateRunes(desc, seoDescMax)
canonical := services.AbsoluteURL(base, permalink.UserPath(user.ID))
ogImage := services.AbsoluteURL(base, user.Avatar)
if ogImage == "" {
ogImage = defaultImage
}
@@ -500,7 +513,7 @@ func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *model
jsonld["mainEntity"].(map[string]any)["image"] = ogImage
}
return &embed_static.SPAPageMeta{
return &seo.PageMeta{
Title: pageTitle(name+" 的主页", siteName),
Description: desc,
Canonical: canonical,
@@ -538,13 +551,13 @@ func pathWithQuery(c *gin.Context) string {
if path == "" {
path = "/"
}
permalink := service.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board
permalink := services.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board
if q := c.Request.URL.RawQuery; q != "" {
if path == "/" {
board := c.Query("board")
if board != "" {
_ = permalink
return service.LegacyQueryBoardHome(uint(parseUintOrZero(board)))
return services.LegacyQueryBoardHome(uint(parseUintOrZero(board)))
}
return "/"
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package router
package routers
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
@@ -9,12 +8,11 @@ import (
"path/filepath"
"git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/embed_static"
"git.iioio.com/freefire/jiang13-forum/handler"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
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"
"git.iioio.com/freefire/jiang13-forum/service"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
@@ -24,7 +22,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery())
r.Use(gin.Logger())
// SSR 静态资源(独立前缀,避免与 SPA /assets/* 冲突)
// SSR 静态资源
if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil {
ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub)))
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)
})
}
// 生产:内嵌 React SPA未迁移路径仍走 SPAdevVite 可对照旧前台
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")
if cfg.DevMode {
fmt.Fprintf(os.Stderr, "[dev] SSR 请访问 http://localhost:%d (对照 SPA 请 checkout main\n", cfg.Port)
}
filter := service.NewSensitiveFilter()
_ = service.WriteDefaultFilterWords(cfg.FilterWordsPath())
filter := services.NewSensitiveFilter()
_ = services.WriteDefaultFilterWords(cfg.FilterWordsPath())
filter.LoadFromFile(cfg.FilterWordsPath())
settingsSvc := service.NewForumSettingsService()
// SPA 入口 HTML 注入标题与品牌 JSON避免刷新时先闪默认文案
embed_static.SetSPADocumentTitle(func() string {
return settingsSvc.SiteBranding().DocumentTitle()
})
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()
settingsSvc := services.NewForumSettingsService()
authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := services.NewUserService(filter, settingsSvc)
boardSvc := services.NewBoardService()
boardSvc.EnsureDefaultBoard()
postSvc := service.NewPostService(filter, settingsSvc)
commentSvc := service.NewCommentService(filter, settingsSvc)
messageSvc := service.NewMessageService(filter, settingsSvc)
reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
limiter := service.NewRateLimiter(settingsSvc)
captchaSvc := service.NewCaptchaService()
mailSvc := service.NewMailService(settingsSvc)
emailCodeSvc := service.NewEmailCodeService(mailSvc)
notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc)
friendLinkApplySvc := service.NewFriendLinkApplyService(settingsSvc, messageSvc)
oidcSvc, err := service.NewOIDCService(cfg, settingsSvc)
postSvc := services.NewPostService(filter, settingsSvc)
commentSvc := services.NewCommentService(filter, settingsSvc)
messageSvc := services.NewMessageService(filter, settingsSvc)
reportSvc := services.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
backupSvc := services.NewBackupService(cfg.DBPath(), cfg.DataDir)
limiter := services.NewRateLimiter(settingsSvc)
captchaSvc := services.NewCaptchaService()
mailSvc := services.NewMailService(settingsSvc)
emailCodeSvc := services.NewEmailCodeService(mailSvc)
notifySvc := services.NewNotifyService(messageSvc, mailSvc, settingsSvc)
friendLinkApplySvc := services.NewFriendLinkApplyService(settingsSvc, messageSvc)
oidcSvc, err := services.NewOIDCService(cfg, settingsSvc)
if err != nil {
return nil, err
}
giteaSvc := service.NewGiteaService(settingsSvc)
giteaSvc := services.NewGiteaService(settingsSvc)
giteaSvc.StartBackgroundSync()
uploadStore := service.NewUploadStore(cfg.DataDir, settingsSvc)
uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc)
if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil {
// 配置不完整时保持本地磁盘,避免进程无法启动;管理员可在后台修正后热切换
fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err)
_ = uploadStore.Apply(service.StorageConfig{Type: "local"})
_ = uploadStore.Apply(services.StorageConfig{Type: "local"})
}
// 后台同步存量文件到媒体索引,避免列表依赖实时扫盘
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,
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
Backup: backupSvc,
Filter: filter, Limiter: limiter, Settings: settingsSvc,
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
OIDC: oidcSvc, Gitea: giteaSvc,
Points: service.NewPointsService(), Badge: service.NewBadgeService(),
SitePage: service.NewSitePageService(filter),
Points: services.NewPointsService(), Badge: services.NewBadgeService(),
SitePage: services.NewSitePageService(filter),
FriendLinkApply: friendLinkApplySvc,
}
authMW := middleware.NewAuthMiddleware(authSvc)
authMW := auth.NewAuthMiddleware(authSvc)
// Gitea 式 SSR 公开页(优先于 SPA
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("/captcha", h.APICaptcha)
pubAPI.GET("/register/config", h.APIRegisterConfig)
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
pubAPI.POST("/password-reset/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
pubAPI.POST("/password-reset", middleware.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
pubAPI.POST("/register/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
pubAPI.POST("/password-reset/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
pubAPI.POST("/password-reset", auth.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
pubAPI.GET("/posts", h.APIPosts)
pubAPI.GET("/posts/hot", h.APIHotPosts)
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("/posts/:id", h.APIPostDetail)
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.POST("/register", middleware.RateLimitMiddleware(limiter, "register"), h.APIRegister)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, "login"), h.APILogin)
pubAPI.POST("/register", auth.RateLimitMiddleware(limiter, "register"), h.APIRegister)
pubAPI.POST("/login", auth.RateLimitMiddleware(limiter, "login"), h.APILogin)
}
// 需登录 API
@@ -178,7 +159,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/profile/password", h.APIUpdatePassword)
api.POST("/profile/avatar", h.APIUploadAvatar)
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.DELETE("/posts/:id", h.APIDeletePost)
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/refund", h.APIBountyRefund)
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/notifications", h.APIMessageNotifications)
api.POST("/messages/notifications/read", h.APIMarkNotificationsRead)
api.GET("/messages/conversations", h.APIMessageConversations)
api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
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("/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.PUT("/comments/:id", h.APIUpdateComment)
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.GET("/me/lottery", h.APIMeLotteryGet)
api.POST("/me/lottery", h.APIMeLotteryDraw)
api.POST("/posts/:id/unlock", middleware.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
api.POST("/friend-links/apply", middleware.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink)
api.POST("/friend-links/logo", middleware.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo)
api.POST("/posts/:id/unlock", auth.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
api.POST("/friend-links/apply", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink)
api.POST("/friend-links/logo", auth.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo)
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)
}
@@ -288,41 +269,27 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
}
// 后台管理页面由 React SPA 渲染JSON API 见上方 /api/admin
// dev 模式下前端由 Vite 提供,后台页面路由不在此注册
if !cfg.DevMode {
admin := r.Group("/admin")
// 管理后台 HTMLSSR 尚未迁移;勿用 /*filepath与 /admin/login 冲突
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>`
adminPending := func(c *gin.Context) {
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) {
c.Redirect(http.StatusFound, "/login")
})
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)
}
}
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
adminAuth.GET("/dashboard", adminPending)
adminAuth.GET("/:page", adminPending)
}
}
// 未迁移路径:生产仍回落 React SPA/ 与 /board/:id 已由 routers/web 接管
if cfg.DevMode {
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"})
})
}
// 未迁移公开路径:爬虫可读 HTML / 用户占位(首页与板块已由 routers/web 接管
r.NoRoute(h.ServePublicSPA)
return r, nil
}

View File

@@ -1,4 +1,4 @@
package web
package web
import (
"net/http"
@@ -6,18 +6,18 @@ import (
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/models"
"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"
)
// Deps 页面路由依赖(复用现有 service避免 Phase 1 大搬家)
type Deps struct {
Settings *service.ForumSettingsService
Board *service.BoardService
Post *service.PostService
Settings *services.ForumSettingsService
Board *services.BoardService
Post *services.PostService
}
// BoardView 侧栏板块
@@ -61,7 +61,7 @@ type HomePageData struct {
}
// 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.GET("/", deps.Home)
g.GET("/board/:id", deps.Home)
@@ -99,21 +99,21 @@ func (d Deps) Home(c *gin.Context) {
}
var uid uint
if v, ok := c.Get(middleware.CtxUserID); ok {
if v, ok := c.Get(auth.CtxUserID); ok {
uid, _ = v.(uint)
}
isAdmin := false
if v, ok := c.Get(middleware.CtxRole); ok {
if v, ok := c.Get(auth.CtxRole); ok {
switch r := v.(type) {
case model.Role:
isAdmin = r == model.RoleAdmin
case models.Role:
isAdmin = r == models.RoleAdmin
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,
Page: page,
Size: size,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
package service
package services
import (
"errors"
"testing"
"github.com/glebarez/sqlite"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm"
)
@@ -15,26 +15,26 @@ func setupBountyTestDB(t *testing.T) *gorm.DB {
if err != nil {
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)
}
prev := model.DB
model.DB = db
t.Cleanup(func() { model.DB = prev })
prev := models.DB
models.DB = db
t.Cleanup(func() { models.DB = prev })
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()
post := model.Post{
post := models.Post{
UserID: authorID,
BoardID: 1,
Title: "悬赏测试",
Content: "内容",
PostType: model.PostTypeBounty,
PostType: models.PostTypeBounty,
BountyPoints: points,
BountyStatus: model.BountyStatusOpen,
Status: model.ContentStatusPublished,
BountyStatus: models.BountyStatusOpen,
Status: models.ContentStatusPublished,
}
if err := db.Create(&post).Error; err != nil {
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) {
t.Helper()
u := model.User{
u := models.User{
ID: id,
Username: "user" + string(rune('0'+id)),
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) {
t.Helper()
c := model.Comment{
c := models.Comment{
PostID: postID,
UserID: userID,
Floor: floor,
@@ -79,25 +79,25 @@ func TestCountEligibleBountyReplies(t *testing.T) {
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)
if err != nil || n != 0 {
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)
if err != nil || n != 1 {
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)
if err != nil || n != 1 {
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)
if err != nil || n != 2 {
t.Fatalf("游客回复应计入,得到 %d", n)
@@ -113,7 +113,7 @@ func TestCanRefundBounty(t *testing.T) {
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)
if can || reason != bountyRefundBlockReason {
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, 2, 0)
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)
if !errors.Is(err, ErrBountyRefundBlocked) {
@@ -146,14 +146,14 @@ func TestRefundBountyAllowedWithoutReplies(t *testing.T) {
if err := RefundBounty(post.ID, 1, false); err != nil {
t.Fatalf("无回复时楼主应可退回err=%v", err)
}
var updated model.Post
var updated models.Post
if err := db.First(&updated, post.ID).Error; err != nil {
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)
}
var author model.User
var author models.User
if err := db.First(&author, 1).Error; err != nil {
t.Fatal(err)
}
@@ -167,7 +167,7 @@ func TestRefundBountyAdminBypassWithReplies(t *testing.T) {
seedUser(t, db, 1, 0)
seedUser(t, db, 2, 0)
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 {
t.Fatalf("管理员应可强制退回err=%v", err)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
package service
package services
import (
"sync"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/models"
)
const reciprocalCheckConcurrency = 3
@@ -45,7 +45,7 @@ func runReciprocalCheck(applyID uint, gen uint64, pageURL, ourSiteURL string) {
}
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_check_note": note,
"reciprocal_checked_at": now,
@@ -54,7 +54,7 @@ func runReciprocalCheck(applyID uint, gen uint64, pageURL, ourSiteURL string) {
// ResetReciprocalCheckState 重置为检测中,供重新检测使用
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_check_note": "",
"reciprocal_checked_at": nil,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package service
package services
import (
"encoding/json"
@@ -8,7 +8,7 @@ import (
"strings"
"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() {
for _, def := range forumSettingDefs {
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 {
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 {
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 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range asideSettingDefaults {
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 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range mailSettingDefaults {
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 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range oidcSettingDefaults {
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 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range giteaSettingDefaults {
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 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range storageSettingDefaults {
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 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range siteBrandingDefaults {
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 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range friendLinkSettingDefaults {
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 {
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 {
var setting model.ForumSetting
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
var setting models.ForumSetting
if err := models.DB.First(&setting, "`key` = ?", key).Error; err != nil {
return fallback
}
return setting.Value
@@ -477,12 +477,12 @@ func (s *ForumSettingsService) getString(key, fallback string) string {
func (s *ForumSettingsService) setString(key, value string) error {
s.mu.Lock()
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 {
var setting model.ForumSetting
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
var setting models.ForumSetting
if err := models.DB.First(&setting, "`key` = ?", key).Error; err != nil {
return fallback
}
v, err := strconv.Atoi(setting.Value)
@@ -505,7 +505,7 @@ func (s *ForumSettingsService) setInt(key string, value int) error {
}
s.mu.Lock()
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
}
@@ -1042,7 +1042,7 @@ func (s *ForumSettingsService) GiteaSyncConfig() GiteaSyncConfig {
}
cfg.Ready = cfg.Enabled && base != "" && cfg.HasToken
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
return cfg
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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