Compare commits
107 Commits
be1241d8c9
...
rebuild/gi
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba010f3ea | |||
| 6e7b46a2e9 | |||
| e0683ef262 | |||
| 3bd0b990a1 | |||
| 58b94df50c | |||
| 4a21d362d0 | |||
| 1f777eebb1 | |||
| 7f048bd9b5 | |||
| f0358e0f71 | |||
| 118f967540 | |||
| 4ffcaecba2 | |||
| 9e403a7178 | |||
| 0c5fe8a1b4 | |||
| 02a5ad0c30 | |||
| 5fb3b2a165 | |||
| ae448e1ccc | |||
| e06c54870c | |||
| b5aee606fb | |||
| 303755b47a | |||
| 92b68b9eef | |||
| c8533b6184 | |||
| 4f93397ef3 | |||
| 7f21ffb14e | |||
| f10ba99012 | |||
| c32c81d1ca | |||
| 145c7a3e1f | |||
| 54f5de07a4 | |||
| 5e623f67fb | |||
| 2d09729976 | |||
| 83994e2707 | |||
| 802f86605c | |||
| c4faba81b3 | |||
| 959a2c3a2e | |||
| f76d3e3bf8 | |||
| 2a46c01c85 | |||
| d4b29f5b7c | |||
| 4dc196b055 | |||
| 7bc50bfb80 | |||
| 204e7fdb32 | |||
| 40ec20b3df | |||
| 126e3bae98 | |||
| fde5f628ec | |||
| 3f50316ad0 | |||
| 9fe299a45f | |||
| 1414c71dec | |||
| 4dc3bbb13f | |||
| 8cfe31da61 | |||
| 7965768f3d | |||
| 012cac23de | |||
| d929bf96aa | |||
| cce9fb9ed8 | |||
| 9eefe24a33 | |||
| f559fc4c4b | |||
| 55b1831da2 | |||
| c6df703b96 | |||
| 7cc574f9a1 | |||
| 2208af7070 | |||
| df19752a1e | |||
| c07d1fd2bd | |||
| 4f85a86077 | |||
| f0650a9c39 | |||
| 4f42a2faff | |||
| 536707f812 | |||
| 1550ccb693 | |||
| 4042395e3e | |||
| 383440ed5b | |||
| 94f6a9666b | |||
| 10185178e2 | |||
| 0f83183620 | |||
| bb8d415eb7 | |||
| e6e7ed73e3 | |||
| 6b0a1d4281 | |||
| b075495540 | |||
| c05cc472cf | |||
| 322ac14055 | |||
| e8b28a37e4 | |||
| cc51c49272 | |||
| 5b2d9a4ea4 | |||
| e50047e1c8 | |||
| 860dffd547 | |||
| 2a34699f64 | |||
| b24f6c23ea | |||
| 3aca5c42c5 | |||
| 2c06eaccb2 | |||
| 61b47363e9 | |||
| dd9e185847 | |||
| 81f5d451bc | |||
| 48db333272 | |||
| 060b7707cb | |||
| 822eef96be | |||
| 9487c8ab02 | |||
| 962bb15298 | |||
| 55c256654b | |||
| e038d4f9e9 | |||
| f527c91c27 | |||
| 773639fd7c | |||
| 9767b54192 | |||
| 0eae3f8c47 | |||
| 57172eb053 | |||
| e4d1dd139e | |||
| b451703642 | |||
| 1d273066b0 | |||
| d0555de28e | |||
| 9230f7272d | |||
| b27289bbea | |||
| 99ae0ccdaa | |||
| e1c1708715 |
38
.cursor/rules/build-scripts.mdc
Normal file
38
.cursor/rules/build-scripts.mdc
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
description: 编译与构建脚本约定(Windows build.bat/ps1、Makefile、跨平台同步)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 编译与构建脚本
|
||||
|
||||
本分支(`rebuild/gitea-ssr`):Go 单二进制 + `go:embed` 的 **templates** 与 **public/assets**(由 `web_src` 构建);产物在 `dist/`。
|
||||
对照 SPA 仅在 `main` 分支。
|
||||
|
||||
## 运行编译(优先用封装命令,不要猜命令)
|
||||
|
||||
| 平台 | 命令 |
|
||||
|------|------|
|
||||
| Windows | `build.bat` 或 `build.bat -Target <target>` |
|
||||
| Linux / macOS | `make` 或 `make <target>` |
|
||||
|
||||
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE,不兼容本项目 Makefile)。
|
||||
|
||||
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
||||
|
||||
常用 target:`build`(默认)、`dev`、`run`、`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. **构建顺序**:先 `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 检查清单
|
||||
|
||||
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
|
||||
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
|
||||
- [ ] `build.bat -Target help` 说明(help 输出在 ps1 内)
|
||||
- [ ] README「快速开始」如需对外暴露再更新
|
||||
37
.cursor/rules/rebuild-gitea-ssr.mdc
Normal file
37
.cursor/rules/rebuild-gitea-ssr.mdc
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Gitea 式 SSR 重构总则(rebuild/gitea-ssr 分支)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Gitea 式 SSR 重构
|
||||
|
||||
## 分支
|
||||
|
||||
- 功能开发在 **`rebuild/gitea-ssr`**;**不要**把破坏性 SSR 替换推到 `main`。
|
||||
- `main` 保留 React SPA,用作对照(checkout / worktree)。
|
||||
|
||||
## 渲染
|
||||
|
||||
- 公开页(首页、板块、帖详情、用户页等)必须用 **Go `html/template` 服务端渲染完整 HTML**。
|
||||
- 禁止为已迁移公开页恢复 React SPA 空壳;禁止「用户 SPA + 爬虫专用 HTML」双轨作为长期方案。
|
||||
- JSON `/api` 仅用于交互增强与管理后台,**不得**作为公开页首屏唯一数据来源。
|
||||
|
||||
## 架构参照
|
||||
|
||||
对齐 [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
|
||||
- `models/`、`services/` — 数据与业务
|
||||
- `modules/auth`、`modules/webrender`、`modules/seo` — 横切
|
||||
|
||||
本分支**已删除** `frontend/` 与 `embed_static/`;勿再恢复 SPA 生产回落。
|
||||
|
||||
细节见 [`docs/rebuild-spec/08-gitea-ssr-architecture.md`](docs/rebuild-spec/08-gitea-ssr-architecture.md)。
|
||||
|
||||
## 产品规格
|
||||
|
||||
实现功能前按 [`docs/rebuild-spec/README.md`](docs/rebuild-spec/README.md) 阅读顺序核对;业务规则以 `05-business-rules.md` 为准。
|
||||
20
.cursor/rules/rebuild-spec-source.mdc
Normal file
20
.cursor/rules/rebuild-spec-source.mdc
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: 产品规格为唯一业务事实来源
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 规格文档对照
|
||||
|
||||
重构实现时以 [`docs/rebuild-spec/`](docs/rebuild-spec/) 为准,不另发明业务语义。
|
||||
|
||||
| 需求类型 | 查阅 |
|
||||
|----------|------|
|
||||
| 要不要做某功能 | `02-features.md` |
|
||||
| 表字段 / 枚举 / settings 键 | `03-data-model.md` |
|
||||
| HTTP 路径与 JSON 形状 | `04-api.md` |
|
||||
| 审核 / 积分 / 门控 / 悬赏等 | `05-business-rules.md` |
|
||||
| 路由与交互信息架构 | `06-pages-ux.md` |
|
||||
| 配置与部署 | `07-config-ops.md` |
|
||||
| SSR 目录与分支 | `08-gitea-ssr-architecture.md` |
|
||||
|
||||
规格与代码冲突时:以**当前分支代码**行为为准,并应回写修正规格。
|
||||
14
.cursor/rules/rebuild-templates.mdc
Normal file
14
.cursor/rules/rebuild-templates.mdc
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Go HTML 模板约定(SSR)
|
||||
globs: templates/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 模板约定
|
||||
|
||||
- 目录对齐 Gitea:`templates/base/`、`home/`、`post/`、`shared/`、`status/`、`auth/`,根级 `install.tmpl` / `post-install.tmpl`。
|
||||
- 页面入口用固定 `{{define "home"}}` / `{{define "post"}}` 等显式 `{{template "base/head"}}`…;**禁止** `{{template .Name}}`(标准库不支持动态名)。
|
||||
- **默认转义**;用户 HTML 须先消毒 + 门控后再 `{{safeHTML ...}}`。
|
||||
- 静态资源 `/ssr-assets/...`。
|
||||
- 浏览器写操作走 `routers/web` 表单 POST + CSRF(`webctx`),不依赖 JSON `/api`。
|
||||
- 中文文案可写在模板;站点名等从 PageChrome / settings 传入。
|
||||
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
# Git 与编辑器
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.trae
|
||||
|
||||
# 运行时数据与本地配置
|
||||
data/
|
||||
app.ini
|
||||
tmp-cookie.txt
|
||||
|
||||
# 编译产物与依赖缓存
|
||||
dist/
|
||||
web_src/node_modules/
|
||||
public/assets/
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
# 文档与截图(不参与镜像构建)
|
||||
docs/
|
||||
|
||||
# 计划文件
|
||||
*.plan.md
|
||||
17
.gitattributes
vendored
Normal file
17
.gitattributes
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# 跨平台行尾:仓库内统一 LF,避免 Windows autocrlf 造成「假 modified」
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows 批处理在 cmd 下需要 CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# 二进制
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.exe binary
|
||||
56
.gitea/ISSUE_TEMPLATE/bug_report.yaml
Normal file
56
.gitea/ISSUE_TEMPLATE/bug_report.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
name: 缺陷报告
|
||||
about: 报告一个可复现的问题
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢反馈!请尽量提供复现步骤,方便我们定位问题。
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 简要说明发生了什么
|
||||
placeholder: 评论回复换行后,页面上显示为一行…
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
placeholder: |
|
||||
1. 打开帖子详情页
|
||||
2. 在评论框输入多行文字
|
||||
3. 点击发送
|
||||
4. 观察到换行丢失
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际行为
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 版本 / 提交
|
||||
placeholder: main @ abc1234 或 v1.0.0
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境
|
||||
placeholder: Windows 11 / Chrome 125 / Go 1.26
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或日志
|
||||
description: 可粘贴图片或相关日志
|
||||
44
.gitea/ISSUE_TEMPLATE/feature_request.yaml
Normal file
44
.gitea/ISSUE_TEMPLATE/feature_request.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: 功能建议
|
||||
about: 提出新功能或改进现有体验
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
欢迎提出想法!请先搜索已有 Issue,避免重复。
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 要解决什么问题?
|
||||
description: 从用户场景出发描述痛点
|
||||
placeholder: 管理员在帖子详情页缺少某某操作入口…
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 期望的方案
|
||||
description: 你心目中的实现方式(可简略)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 备选方案
|
||||
description: 如有其他思路可一并说明
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: 涉及模块
|
||||
options:
|
||||
- 前台 UI/UX
|
||||
- 管理后台
|
||||
- 评论系统
|
||||
- 帖子 / 板块
|
||||
- 认证 / 权限
|
||||
- 部署 / 构建
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
51
.gitignore
vendored
51
.gitignore
vendored
@@ -1,27 +1,30 @@
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
||||
/data/
|
||||
|
||||
# 本地配置(保留 app.ini.example)
|
||||
/app.ini
|
||||
|
||||
# Node / 构建缓存
|
||||
/node_modules/
|
||||
/web_src/node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Go 编译产物(统一进 dist/;勿把二进制扔在仓库根目录)
|
||||
/dist/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/jiang13
|
||||
/jiang13-*
|
||||
!/cmd/jiang13/
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
# 临时文件
|
||||
tmp-cookie.txt
|
||||
*-err.txt
|
||||
*-out.txt
|
||||
|
||||
# 编辑器 / AI 草稿 / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.trae/
|
||||
*.swp
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
44
CONTRIBUTING.md
Normal file
44
CONTRIBUTING.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 参与贡献
|
||||
|
||||
感谢你对姜十三论坛的关注!这是一个开源项目,欢迎提交 Issue 和 Pull Request。
|
||||
|
||||
## 开发环境
|
||||
|
||||
**要求:** Go 1.26+、Node.js 18+(仅构建 `web_src` 静态资源)
|
||||
|
||||
本分支(`rebuild/gitea-ssr`)为 **Go 模板 SSR**;对照 React SPA 请 `git checkout main`。
|
||||
|
||||
```bat
|
||||
REM Windows:请用 build.bat(内部 Bypass ExecutionPolicy)
|
||||
build.bat -Target dev
|
||||
REM 浏览器访问 http://localhost:3000
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
make dev
|
||||
```
|
||||
|
||||
## 提交规范
|
||||
|
||||
- 一个 PR 只做一件事,保持 diff 小而清晰
|
||||
- 前端(`web_src` / 模板)改动请确认浅色 / 暗色主题下都正常
|
||||
- 涉及 UI 变更时,建议在 PR 中附上截图
|
||||
- 功能语义以 [`docs/rebuild-spec/`](docs/rebuild-spec/) 为准
|
||||
|
||||
## 完整构建
|
||||
|
||||
```bat
|
||||
build.bat REM Windows:先 web_src,再 go build → dist/
|
||||
```
|
||||
|
||||
```bash
|
||||
make build # Linux / macOS
|
||||
```
|
||||
|
||||
## 报告问题
|
||||
|
||||
在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)):
|
||||
- 期望行为与实际行为
|
||||
- 复现步骤、浏览器 / OS
|
||||
- 相关模板或 `routers/web` 路径(若已知)
|
||||
53
Dockerfile
Normal file
53
Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
||||
# 姜十三论坛 — 多阶段构建:web_src → Go 单二进制 → Alpine 运行镜像
|
||||
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
||||
#
|
||||
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
||||
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
|
||||
# docker build --build-arg IMAGE_PREFIX= ...
|
||||
|
||||
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
|
||||
ARG VERSION=dev
|
||||
|
||||
# ── 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 SQLite,CGO_ENABLED=0)────────────────────────
|
||||
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
|
||||
ARG VERSION
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
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
|
||||
|
||||
# ── Stage 3: 运行镜像 ───────────────────────────────────────────────────────
|
||||
FROM ${IMAGE_PREFIX}alpine:3.21
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache ca-certificates tzdata wget su-exec \
|
||||
&& adduser -D -u 1000 -h /app jiang13
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/jiang13 /app/jiang13
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chown jiang13:jiang13 /app/jiang13 \
|
||||
&& chmod +x /app/jiang13 /docker-entrypoint.sh
|
||||
|
||||
# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"]
|
||||
31
LICENSE
31
LICENSE
@@ -1,18 +1,19 @@
|
||||
MIT License
|
||||
Copyright (c) 2026 The Jiang13 Authors
|
||||
|
||||
Copyright (c) 2026 freefire
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
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:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights 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.
|
||||
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.
|
||||
|
||||
95
Makefile
Normal file
95
Makefile
Normal file
@@ -0,0 +1,95 @@
|
||||
# 姜十三论坛 Jiang13 Forum - Makefile
|
||||
# Go 1.26 单二进制:templates SSR + web_src 渐进资源(本分支无 React SPA)
|
||||
|
||||
APP_NAME := jiang13
|
||||
MAIN_PKG := ./cmd/jiang13
|
||||
BUILD_DIR := dist
|
||||
DEV_DATA_DIR := dist/data
|
||||
VERSION := $(shell git rev-parse --short HEAD 2>/dev/null | sed 's/^/1.0.0+/' || echo 1.0.0)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
||||
|
||||
GO := go
|
||||
GOFLAGS := -trimpath
|
||||
|
||||
.PHONY: all build build-windows build-linux build-darwin 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
|
||||
|
||||
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
||||
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
|
||||
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
|
||||
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)
|
||||
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 只跑一次)
|
||||
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)
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
||||
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||
@echo "✓ 全平台编译完成"
|
||||
|
||||
## 整理依赖
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
|
||||
## 本地运行 SSR(先构建 web_src)
|
||||
run: web-src-build
|
||||
@mkdir -p $(DEV_DATA_DIR)
|
||||
$(GO) run $(MAIN_PKG) --work-path . --data $(DEV_DATA_DIR)
|
||||
|
||||
## 同 run(SPA 对照请 git checkout main)
|
||||
dev: run
|
||||
|
||||
## 清理编译产物
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
## 构建 Docker 镜像
|
||||
docker:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
|
||||
|
||||
## Docker Compose 启动
|
||||
compose-up:
|
||||
docker compose up -d --build
|
||||
|
||||
## Docker Compose 停止
|
||||
compose-down:
|
||||
docker compose down
|
||||
|
||||
help:
|
||||
@echo "姜十三论坛编译命令 (rebuild/gitea-ssr):"
|
||||
@echo " make web-src-build - 构建 SSR 渐进资源 (web_src)"
|
||||
@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 / 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"
|
||||
408
README.md
408
README.md
@@ -1,3 +1,407 @@
|
||||
# jiang13-forum
|
||||
<div align="center">
|
||||
|
||||
轻量自用论坛:Go 单二进制 + React SPA 内嵌 + SQLite,面向小圈子内部交流。
|
||||
# 姜十三论坛 Jiang13 Forum
|
||||
|
||||
**能聊 · 好看 · 好装**
|
||||
|
||||
面向小圈子、团队与同好社群的轻量现代化论坛。
|
||||
本分支(`rebuild/gitea-ssr`):Go 模板真 SSR + `web_src` 渐进增强,单二进制 + SQLite。
|
||||
对照 React SPA 请见 `main` 分支。
|
||||
|
||||
<br>
|
||||
|
||||
[](https://bbs.iioio.com/)
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||
[](go.mod)
|
||||
[](docs/rebuild-spec/08-gitea-ssr-architecture.md)
|
||||
[](#)
|
||||
|
||||
[在线演示](https://bbs.iioio.com/) ·
|
||||
[快速开始](#-快速开始) ·
|
||||
[界面预览](#-界面预览) ·
|
||||
[功能亮点](#-功能亮点) ·
|
||||
[路线图](ROADMAP.md) ·
|
||||
[参与贡献](CONTRIBUTING.md)
|
||||
|
||||
<br>
|
||||
|
||||
<img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%">
|
||||
|
||||
<sub>浅色主题 · 三栏布局 · Feed 排序 · 板块导航 · 标签云</sub>
|
||||
|
||||
<br>
|
||||
|
||||
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)(现网多为 `main` SPA)
|
||||
> 本分支按 [Gitea 式 SSR 规格](docs/rebuild-spec/08-gitea-ssr-architecture.md) 重构;欢迎提 Issue / PR 共建。
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 它是什么
|
||||
|
||||
姜十三论坛不做大而全的社区平台,只做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
|
||||
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
||||
| 项目配套社区 | 可与 Gitea 等通过 OIDC(开放身份连接)做 SSO(单点登录) |
|
||||
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
|
||||
|
||||
---
|
||||
|
||||
## 界面预览
|
||||
|
||||
截图来自演示站 [bbs.iioio.com](https://bbs.iioio.com/)。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
|
||||
<br><b>浅色主题</b><br>
|
||||
<sub>左栏板块 · Feed 排序 · 右栏热门 / 标签 / 评论</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
|
||||
<br><b>暗色主题</b><br>
|
||||
<sub>一键切换 · 护眼阅读 · 全局色彩自适应</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%">
|
||||
<br><b>帖子详情</b><br>
|
||||
<sub>文章目录 · 标签 · 作者卡片 · 修订信息</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
|
||||
<br><b>富文本渲染</b><br>
|
||||
<sub>Markdown 排版 · 图片 · 代码块 · 目录导航</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280">
|
||||
<br><b>移动端</b> — 板块快捷筛选 · Feed 排序 · 触控友好列表
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 功能亮点
|
||||
|
||||
### 界面与交互
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| **三栏布局** | 左栏板块导航 + 中间帖列表 + 右栏热门 / 标签 / 最新评论 |
|
||||
| **真 SSR** | 公开页服务端渲染完整 HTML(Go `html/template`) |
|
||||
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
|
||||
| **主题切换** | 浅色 / 暗色 / 跟随系统(`localStorage`) |
|
||||
| **响应式** | ≤900px 隐藏右栏;搜索、发帖、登录触手可及 |
|
||||
|
||||
### 社区功能
|
||||
|
||||
- 安装向导创建管理员;用户注册 / 登录(bcrypt + 会话 Cookie)
|
||||
- 板块、发帖、Markdown 工具栏、正文图片上传、标签、置顶 / 精华
|
||||
- 帖子修订历史;可配置普通用户编辑时限
|
||||
- 楼层式评论:回复、嵌套树、内容门控(登录/回复/积分可见)
|
||||
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
|
||||
- SSR 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
|
||||
- 可选:邮件验证码、OIDC Provider、S3 兼容对象存储(Gitea 仓库同步后置)
|
||||
|
||||
### 部署体验
|
||||
|
||||
- **单二进制** — `go:embed` 打包模板与 SSR 资源
|
||||
- **可切换数据库** — 默认 SQLite;可选 PostgreSQL / MySQL(Env 引导)
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 编译
|
||||
|
||||
**Windows(推荐):**
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
```
|
||||
|
||||
> 请通过 `build.bat` 调用(内部已处理 ExecutionPolicy)。不要直接 `.\build.ps1`,也不要在 Windows 上使用系统自带的 Embarcadero `make`。
|
||||
|
||||
**Linux / macOS:**
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
**手动分步(全平台):**
|
||||
|
||||
```bash
|
||||
cd web_src && npm run build
|
||||
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||
```
|
||||
|
||||
跨平台编译:
|
||||
|
||||
```bat
|
||||
build.bat -Target build-windows
|
||||
build.bat -Target build-linux
|
||||
build.bat -Target build-all
|
||||
```
|
||||
|
||||
### 2. Docker 部署(推荐)
|
||||
|
||||
**一键启动(本地构建):**
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
```bat
|
||||
.\build.bat -Target compose-up
|
||||
```
|
||||
|
||||
```bash
|
||||
make compose-up
|
||||
```
|
||||
|
||||
浏览器打开 `http://localhost:3000/install` 完成安装向导(站点名 + 管理员)。
|
||||
|
||||
**拉取已构建镜像(Docker Hub):**
|
||||
|
||||
```bash
|
||||
docker pull hangzhang714128/jiang13-forum:latest
|
||||
docker run -d --name jiang13 \
|
||||
-p 3000:3000 \
|
||||
-v jiang13-data:/data \
|
||||
--restart unless-stopped \
|
||||
hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
**数据持久化:** 容器内 `/data` 对应 SQLite(默认)、上传、日志与 JWT/OIDC 密钥。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户)。
|
||||
|
||||
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
||||
|
||||
**可选环境变量(容器编排):**
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
|
||||
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
|
||||
| `JIANG13_DB_TYPE` | `sqlite`(默认)\| `postgres` \| `mysql` |
|
||||
| `JIANG13_DB_DSN` | 完整 DSN(非 sqlite 时推荐) |
|
||||
| `JIANG13_WORK_PATH` | 工作目录 |
|
||||
|
||||
JWT 自动写入 `/data/.jwt_secret`,无需 Env。
|
||||
|
||||
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
||||
|
||||
**发布镜像到 Docker Hub(手动):**
|
||||
|
||||
```bash
|
||||
docker login
|
||||
.\build.bat -Target docker # Windows
|
||||
# make docker # Linux/macOS
|
||||
docker push hangzhang714128/jiang13-forum:1.0.0
|
||||
docker push hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
或直接构建:
|
||||
|
||||
```bash
|
||||
docker build --build-arg VERSION=1.0.0 -t hangzhang714128/jiang13-forum:1.0.0 -t hangzhang714128/jiang13-forum:latest .
|
||||
docker push hangzhang714128/jiang13-forum:1.0.0
|
||||
docker push hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
停止服务:`docker compose down` / `.\build.bat -Target compose-down` / `make compose-down`
|
||||
|
||||
**构建失败(无法连接 auth.docker.io)?** Dockerfile 已默认经 DaoCloud 拉取基础镜像。若仍超时,可在 Docker Desktop → Settings → Docker Engine 添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": ["https://docker.m.daocloud.io"]
|
||||
}
|
||||
```
|
||||
|
||||
保存并重启 Docker 后重试 `.\build.bat -Target docker`。PowerShell 中请用 `.\build.bat`(带 `.\` 前缀)。
|
||||
|
||||
**1Panel 部署提示:**
|
||||
|
||||
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
|
||||
2. 端口映射 `3000:3000`
|
||||
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
|
||||
4. 首次访问 `http://服务器IP:3000/install` 完成安装
|
||||
|
||||
### 3. 直接启动(二进制)
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\dist\jiang13.exe --data .\dist\data
|
||||
|
||||
# Linux / macOS
|
||||
./dist/jiang13 --data ./data
|
||||
```
|
||||
|
||||
默认 SQLite,库文件在 `{DATA}/jiang13.db`。无 `app.ini`。
|
||||
|
||||
### 4. 首次使用
|
||||
|
||||
1. 浏览器打开 `http://localhost:3000/install`
|
||||
2. 填写站点名与管理员账号
|
||||
3. 完成后登录,访问管理后台配置品牌等(热更新,无需重启)
|
||||
|
||||
### 配置分层(无 INI)
|
||||
|
||||
| 层 | 内容 | 需重启 |
|
||||
|----|------|--------|
|
||||
| CLI / Env | 端口、数据目录、数据库类型与 DSN | 是 |
|
||||
| `data/.jwt_secret`、`.oidc_rsa.pem` | 密钥 | 换密钥需重启 |
|
||||
| DB `forum_settings` | 品牌、邮件、OIDC 开关、限流、存储… | 否 |
|
||||
|
||||
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
|
||||
|
||||
### 启动参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--work-path` | 可执行文件目录 | 工作目录 |
|
||||
| `--port` | `3000` | HTTP 监听端口 |
|
||||
| `--http-addr` | (空) | 监听地址 |
|
||||
| `--data` | `data` | 数据目录 |
|
||||
| `--db-type` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
|
||||
| `--db-dsn` | (sqlite 默认 `{data}/jiang13.db`) | 完整 DSN |
|
||||
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
||||
|
||||
**环境变量:** `JIANG13_HTTP_PORT`、`JIANG13_HTTP_ADDR`、`JIANG13_DATA`、`JIANG13_WORK_PATH`、`JIANG13_DB_TYPE`、`JIANG13_DB_DSN`、以及 `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE`。
|
||||
|
||||
PostgreSQL / MySQL 示例见 [`docs/rebuild-spec/07-config-ops.md`](docs/rebuild-spec/07-config-ops.md)。
|
||||
|
||||
### 5. 注册为系统服务(可选)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/jiang13
|
||||
sudo cp jiang13 /opt/jiang13/
|
||||
sudo /opt/jiang13/jiang13 --work-path /opt/jiang13 --data /opt/jiang13/data --service install
|
||||
sudo /opt/jiang13/jiang13 --service start
|
||||
sudo systemctl enable jiang13
|
||||
```
|
||||
|
||||
**Windows(管理员 PowerShell):**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
|
||||
Copy-Item .\jiang13.exe C:\jiang13\
|
||||
C:\jiang13\jiang13.exe --work-path C:\jiang13 --data C:\jiang13\data --service install
|
||||
C:\jiang13\jiang13.exe --service start
|
||||
```
|
||||
|
||||
> 改端口或 `DB_*` 后执行 `--service restart`(必要时重装服务以更新参数)。日志:`data/jiang13.log`。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite / PostgreSQL / MySQL · `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) |
|
||||
|
||||
---
|
||||
|
||||
## 本地开发(SSR)
|
||||
|
||||
```bat
|
||||
build.bat -Target run
|
||||
```
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
浏览器访问 `http://localhost:3000`。数据目录默认 `dist/data`。
|
||||
|
||||
改模板 / Go 后重启进程;改 `web_src` 后需再跑 `build.bat -Target web-src`(或完整 `build`)。
|
||||
|
||||
需要对照旧 SPA UI:`git checkout main` 或 `git worktree add ../jiang13-spa main`。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
jiang13-forum/ # 分支 rebuild/gitea-ssr
|
||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||
├── config/ # CLI / Env 引导配置(无 INI)
|
||||
├── Dockerfile # web_src → Go → Alpine
|
||||
├── docker-compose.yml
|
||||
├── models/ # GORM 模型
|
||||
├── services/ # 业务逻辑
|
||||
├── routers/
|
||||
│ ├── setup.go # 路由总装
|
||||
│ ├── web/ # HTML SSR
|
||||
│ └── api/ # 机器入口(health / OIDC / SEO / thumb)
|
||||
├── modules/
|
||||
│ ├── auth/ # JWT / 限流
|
||||
│ ├── webrender/ # 模板渲染
|
||||
│ └── seo/
|
||||
├── templates/ # Go html/template(embed)
|
||||
├── web_src/ # 渐进 CSS/JS 源码
|
||||
├── public/assets/ # web_src 构建产物(embed)
|
||||
├── docs/rebuild-spec/ # 产品规格与 SSR 架构
|
||||
├── docs/screenshots/
|
||||
└── ROADMAP.md
|
||||
```
|
||||
|
||||
> SPA 源码树仅存在于 `main`(`frontend/`、`embed_static/`)。
|
||||
|
||||
---
|
||||
|
||||
## 数据目录
|
||||
|
||||
```
|
||||
data/
|
||||
├── jiang13.db # SQLite 主数据库
|
||||
├── jiang13.log # 运行日志
|
||||
├── filter_words.txt # 敏感词配置
|
||||
├── .jwt_secret # JWT 密钥(自动生成)
|
||||
├── uploads/avatars/ # 用户头像
|
||||
├── uploads/posts/ # 帖子正文图片
|
||||
└── jiang13_backup_*.db # 后台导出的备份
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发状态
|
||||
|
||||
项目**积极开发中**,欢迎参与共建。完整列表见 **[ROADMAP.md](ROADMAP.md)**。
|
||||
|
||||
| 类型 | 示例 |
|
||||
|------|------|
|
||||
| ✅ 已可用 | 三栏布局、主题、Feed 排序、楼层评论、嵌套树 |
|
||||
| ✅ 发帖体验 | Markdown 工具栏、图片上传、修订历史、内容门控 |
|
||||
| ✅ 管理后台 | SSR `/admin/*`(对照 SPA 见 `main`) |
|
||||
| 📋 计划中 | 见 [ROADMAP.md](ROADMAP.md) / [09-ssr-progress.md](docs/rebuild-spec/09-ssr-progress.md) |
|
||||
|
||||
---
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
在线体验与反馈也可直接在演示站进行:[https://bbs.iioio.com/](https://bbs.iioio.com/)
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)(与 [Gitea](https://github.com/go-gitea/gitea) 相同的 Expat 文本格式)— 自由使用、修改与分发。
|
||||
|
||||
52
ROADMAP.md
Normal file
52
ROADMAP.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 路线图 ROADMAP
|
||||
|
||||
> 姜十三论坛仍在积极开发中。
|
||||
> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
|
||||
> **本仓库默认开发分支**:`rebuild/gitea-ssr`(Gitea 式 SSR)。`main` = React SPA 对照。
|
||||
|
||||
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
|
||||
|
||||
---
|
||||
|
||||
## 开发状态概览
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 公开页 SSR | ✅ | Go `html/template`;首页 / 板块 / 帖详情 / 用户 / 消息等 |
|
||||
| 管理后台 SSR | ✅ | `/admin/*` 表单;对照 SPA 见 `main` |
|
||||
| 评论系统 | ✅ | 楼层 + 嵌套树(`ThreadParentID`) |
|
||||
| Markdown 编辑 | ✅ | 工具栏 + `/compose/preview`(非 TipTap) |
|
||||
| 主题 | ✅ | 浅色 / 暗色 / 跟随系统 |
|
||||
| OIDC Provider | ✅ | Discovery / Authorize / Token / UserInfo;Admin 配置面可继续打磨 |
|
||||
|
||||
细节进度见 [`docs/rebuild-spec/09-ssr-progress.md`](docs/rebuild-spec/09-ssr-progress.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🐛 已知缺陷(Bug)
|
||||
|
||||
_当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_
|
||||
|
||||
---
|
||||
|
||||
## 📋 计划中(Planned)
|
||||
|
||||
| 优先级 | 功能 | 说明 |
|
||||
|--------|------|------|
|
||||
| 中 | OIDC / 存储 Admin 打磨 | SSO 与 S3 热切换运维面 |
|
||||
| 低 | 编辑器增强 | 表格、表情贴纸(TipTap 不作为本分支默认) |
|
||||
| 低 | Gitea `/projects` | 仓库列表页(后置) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成(摘,本分支)
|
||||
|
||||
- [x] Gitea 式目录与 Go 模板 SSR
|
||||
- [x] 五种帖类型 + 核心闭环(赞/藏/门控/审核…)
|
||||
- [x] Admin 仪表盘、板块、审核、用户、徽章、媒体、设置等
|
||||
- [x] Markdown 发帖/评论编辑器与预览
|
||||
- [x] 评论嵌套树、浅色/暗色主题
|
||||
- [x] OIDC Provider 机器入口
|
||||
- [x] 本分支删除未挂载论坛 JSON API(对照 `main`)
|
||||
|
||||
更全清单:[`docs/rebuild-spec/02-features.md`](docs/rebuild-spec/02-features.md)。
|
||||
4
build.bat
Normal file
4
build.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
REM Jiang13 Forum Windows build wrapper (double-click or run from cmd)
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
146
build.ps1
Normal file
146
build.ps1
Normal file
@@ -0,0 +1,146 @@
|
||||
# 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', 'web-src', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
|
||||
[string]$Target = 'build'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$AppName = 'jiang13'
|
||||
$MainPkg = './cmd/jiang13'
|
||||
$BuildDir = 'dist'
|
||||
$DevDataDir = 'dist/data'
|
||||
$Version = '1.0.0'
|
||||
try {
|
||||
$gitSha = (git rev-parse --short HEAD 2>$null)
|
||||
if ($LASTEXITCODE -eq 0 -and $gitSha) {
|
||||
$Version = "1.0.0+$gitSha"
|
||||
}
|
||||
} catch {}
|
||||
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
||||
$Ldlags = "-s -w -X main.version=$Version"
|
||||
|
||||
function Ensure-Dir($path) {
|
||||
if (-not (Test-Path $path)) {
|
||||
New-Item -ItemType Directory -Path $path | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Build-WebSrc {
|
||||
Write-Host '[web_src] npm run build...' -ForegroundColor Cyan
|
||||
Push-Location web_src
|
||||
try {
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) { throw 'web_src 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 }
|
||||
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
|
||||
|
||||
# 纯 Go SQLite(glebarez),交叉编译无需 C 工具链
|
||||
$prevCgo = $env:CGO_ENABLED
|
||||
$env:CGO_ENABLED = '0'
|
||||
|
||||
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
|
||||
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
|
||||
$OutFile = "$OutFile.exe"
|
||||
}
|
||||
|
||||
$outPath = Join-Path $BuildDir $OutFile
|
||||
Write-Host "[go] build -> $outPath (CGO_ENABLED=0)" -ForegroundColor Cyan
|
||||
try {
|
||||
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
|
||||
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
||||
Write-Host "[ok] $outPath" -ForegroundColor Green
|
||||
} finally {
|
||||
if ($null -eq $prevCgo) {
|
||||
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:CGO_ENABLED = $prevCgo
|
||||
}
|
||||
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
switch ($Target) {
|
||||
'help' {
|
||||
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 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'
|
||||
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).'
|
||||
}
|
||||
'web-src' { Build-WebSrc }
|
||||
'tidy' { go mod tidy }
|
||||
'clean' {
|
||||
if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
|
||||
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
||||
}
|
||||
'run' {
|
||||
Ensure-Dir $DevDataDir
|
||||
Build-WebSrc
|
||||
go run $MainPkg --work-path . --data $DevDataDir
|
||||
}
|
||||
'dev' {
|
||||
Ensure-Dir $DevDataDir
|
||||
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-Go -OutFile $AppName
|
||||
}
|
||||
'build-windows' {
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
}
|
||||
'build-linux' {
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||
}
|
||||
'build-darwin' {
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||
}
|
||||
'build-all' {
|
||||
Build-WebSrc
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||
Write-Host '[ok] all platforms done' -ForegroundColor Green
|
||||
}
|
||||
'docker' {
|
||||
Write-Host "[docker] build $RegistryImage`:$Version and latest" -ForegroundColor Cyan
|
||||
docker build --build-arg "VERSION=$Version" -t "${RegistryImage}:$Version" -t "${RegistryImage}:latest" .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker build failed' }
|
||||
Write-Host '[ok] docker image built' -ForegroundColor Green
|
||||
}
|
||||
'compose-up' {
|
||||
docker compose up -d --build
|
||||
if ($LASTEXITCODE -ne 0) { throw 'compose up failed' }
|
||||
}
|
||||
'compose-down' {
|
||||
docker compose down
|
||||
if ($LASTEXITCODE -ne 0) { throw 'compose down failed' }
|
||||
}
|
||||
}
|
||||
46
cmd/jiang13/main.go
Normal file
46
cmd/jiang13/main.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
kardsvc "github.com/kardianos/service"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
)
|
||||
|
||||
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Parse()
|
||||
if err != nil {
|
||||
log.Fatalf("配置解析失败: %v", err)
|
||||
}
|
||||
|
||||
svcCfg, err := buildServiceConfig(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("构建服务配置失败: %v", err)
|
||||
}
|
||||
|
||||
prg := &program{cfg: cfg}
|
||||
svc, err := kardsvc.New(prg, svcCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("创建系统服务失败: %v", err)
|
||||
}
|
||||
|
||||
if cfg.ServiceAction != "" {
|
||||
if err := runServiceControl(svc, cfg.ServiceAction); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "服务操作失败 (%s): %v\n", cfg.ServiceAction, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 交互终端或由服务管理器拉起时均走 Run:
|
||||
// Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM
|
||||
if err := svc.Run(); err != nil {
|
||||
log.Fatalf("运行失败: %v", err)
|
||||
}
|
||||
}
|
||||
175
cmd/jiang13/program.go
Normal file
175
cmd/jiang13/program.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
kardsvc "github.com/kardianos/service"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/routers"
|
||||
)
|
||||
|
||||
const (
|
||||
svcName = "jiang13"
|
||||
svcDisplayName = "姜十三论坛"
|
||||
svcDescription = "姜十三论坛 Jiang13 Forum — 轻量单二进制论坛服务"
|
||||
)
|
||||
|
||||
// program 实现 kardianos/service.Interface,兼容 Windows Service 与 Linux systemd
|
||||
type program struct {
|
||||
cfg *config.Config
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func (p *program) Start(s kardsvc.Service) error {
|
||||
if err := p.setup(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("HTTP 服务异常: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *program) Stop(s kardsvc.Service) error {
|
||||
log.Println("收到关机信号,正在优雅关闭...")
|
||||
if p.server == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := p.server.Shutdown(ctx); err != nil {
|
||||
log.Printf("HTTP 服务关闭异常: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Println("姜十三论坛已安全退出")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *program) setup() error {
|
||||
cfg := p.cfg
|
||||
cfg.Version = version
|
||||
|
||||
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开日志文件失败: %w", err)
|
||||
}
|
||||
// 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写
|
||||
if kardsvc.Interactive() {
|
||||
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
||||
} else {
|
||||
log.SetOutput(logFile)
|
||||
}
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
|
||||
log.Println("========================================")
|
||||
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
|
||||
log.Printf(" 版本: %s", version)
|
||||
log.Println("========================================")
|
||||
|
||||
if err := models.InitDB(models.DatabaseConfig{
|
||||
Type: cfg.DB.Type,
|
||||
DSN: cfg.DB.DSN,
|
||||
SQLitePath: cfg.DB.SQLitePath,
|
||||
MaxOpenConns: cfg.DB.MaxOpenConns,
|
||||
MaxIdleConns: cfg.DB.MaxIdleConns,
|
||||
ConnMaxLifetimeSec: cfg.DB.ConnMaxLifetimeSec,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("数据库初始化失败: %w", err)
|
||||
}
|
||||
|
||||
engine, err := routers.Setup(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("路由初始化失败: %w", err)
|
||||
}
|
||||
|
||||
addr := cfg.ListenAddr()
|
||||
p.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: engine,
|
||||
}
|
||||
|
||||
log.Printf("姜十三论坛已启动: http://localhost:%d", cfg.Port)
|
||||
log.Printf("后台管理地址: http://localhost:%d/admin/dashboard", cfg.Port)
|
||||
log.Printf("工作目录: %s", cfg.WorkPath)
|
||||
log.Printf("数据目录: %s", cfg.DataDir)
|
||||
log.Printf("数据库: %s", cfg.DB.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildServiceConfig(cfg *config.Config) (*kardsvc.Config, error) {
|
||||
// 服务绑定工作目录与数据目录;改端口 / DB_* 需重启进程(可用 Env 或重装服务参数)
|
||||
args := []string{
|
||||
"--work-path", cfg.WorkPath,
|
||||
"--data", cfg.DataDir,
|
||||
"--port", fmt.Sprintf("%d", cfg.Port),
|
||||
"--db-type", cfg.DB.Type,
|
||||
}
|
||||
if cfg.DB.Type == config.DBTypeSQLite {
|
||||
args = append(args, "--db-dsn", cfg.DB.SQLitePath)
|
||||
} else if cfg.DB.DSN != "" {
|
||||
args = append(args, "--db-dsn", cfg.DB.DSN)
|
||||
}
|
||||
return &kardsvc.Config{
|
||||
Name: svcName,
|
||||
DisplayName: svcDisplayName,
|
||||
Description: svcDescription,
|
||||
WorkingDirectory: cfg.WorkPath,
|
||||
Arguments: args,
|
||||
Option: kardsvc.KeyValue{
|
||||
// systemd:异常退出后自动拉起
|
||||
"Restart": "always",
|
||||
// Windows:崩溃后重启
|
||||
"OnFailure": "restart",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runServiceControl(s kardsvc.Service, action string) error {
|
||||
if action == "status" {
|
||||
st, err := s.Status()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch st {
|
||||
case kardsvc.StatusRunning:
|
||||
fmt.Println("服务状态: 运行中 (running)")
|
||||
case kardsvc.StatusStopped:
|
||||
fmt.Println("服务状态: 已停止 (stopped)")
|
||||
default:
|
||||
fmt.Println("服务状态: 未知 (unknown)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := kardsvc.Control(s, action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "install":
|
||||
fmt.Println("服务已安装。可用以下命令启动:")
|
||||
fmt.Printf(" %s --service start\n", os.Args[0])
|
||||
fmt.Println("或使用系统工具:")
|
||||
fmt.Println(" Linux: sudo systemctl start jiang13 && sudo systemctl enable jiang13")
|
||||
fmt.Println(" Windows: Start-Service jiang13")
|
||||
case "uninstall":
|
||||
fmt.Println("服务已卸载")
|
||||
case "start":
|
||||
fmt.Println("服务已启动")
|
||||
case "stop":
|
||||
fmt.Println("服务已停止")
|
||||
case "restart":
|
||||
fmt.Println("服务已重启")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
341
config/config.go
Normal file
341
config/config.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 3000
|
||||
defaultDataRel = "data"
|
||||
|
||||
DBTypeSQLite = "sqlite"
|
||||
DBTypePostgres = "postgres"
|
||||
DBTypeMySQL = "mysql"
|
||||
|
||||
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||
StorageTypeLocal = "local"
|
||||
StorageTypeS3 = "s3"
|
||||
)
|
||||
|
||||
// DatabaseConfig 数据库引导配置(需重启)
|
||||
type DatabaseConfig struct {
|
||||
Type string // sqlite | postgres | mysql
|
||||
DSN string // 非空则优先
|
||||
Host string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
SSLMode string // postgres
|
||||
// SQLite 文件路径(Type=sqlite 时由 DataDir 推导或 DSN)
|
||||
SQLitePath string
|
||||
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetimeSec int
|
||||
}
|
||||
|
||||
// Config 进程引导配置:仅 CLI / 环境变量(无 INI)
|
||||
type Config struct {
|
||||
WorkPath string
|
||||
HTTPAddr string // 空表示 0.0.0.0
|
||||
Port int
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
LogFile string
|
||||
ServiceAction string
|
||||
DevMode bool
|
||||
Version string // 构建版本(ldflags);用于 /ssr-assets ?v= 缓存穿透
|
||||
DB DatabaseConfig
|
||||
}
|
||||
|
||||
// Parse 解析命令行与环境变量并准备数据目录
|
||||
// 优先级:命令行显式参数 > 环境变量 > 内置默认
|
||||
func Parse() (*Config, error) {
|
||||
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
||||
portFlag := flag.Int("port", 0, "HTTP 监听端口(0 表示用环境变量或默认 3000)")
|
||||
addrFlag := flag.String("http-addr", "", "HTTP 监听地址(默认空=全接口)")
|
||||
dataFlag := flag.String("data", "", "数据存储目录")
|
||||
dbTypeFlag := flag.String("db-type", "", "数据库类型:sqlite|postgres|mysql")
|
||||
dbDSNFlag := flag.String("db-dsn", "", "数据库 DSN(优先于拆分参数)")
|
||||
dbHostFlag := flag.String("db-host", "", "数据库主机")
|
||||
dbUserFlag := flag.String("db-user", "", "数据库用户")
|
||||
dbPassFlag := flag.String("db-pass", "", "数据库密码")
|
||||
dbNameFlag := flag.String("db-name", "", "数据库名")
|
||||
dbSSLFlag := flag.String("db-sslmode", "", "PostgreSQL sslmode")
|
||||
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
||||
devFlag := flag.Bool("dev", false, "开发模式")
|
||||
_ = flag.String("config", "", "已废弃:不再使用 ini 配置文件")
|
||||
_ = flag.String("jwt-secret", "", "已废弃:JWT 仅使用 data/.jwt_secret")
|
||||
flag.Parse()
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
||||
if action != "" && !validServiceAction(action) {
|
||||
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
||||
}
|
||||
|
||||
workPathInput := firstNonEmpty(*workFlag, envOrDefault(envWorkPath))
|
||||
workPath, err := resolveWorkPath(workPathInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
port := defaultPort
|
||||
if p := envIntOrZero(envHTTPPort); p > 0 {
|
||||
port = p
|
||||
}
|
||||
if *portFlag > 0 {
|
||||
port = *portFlag
|
||||
}
|
||||
|
||||
httpAddr := firstNonEmpty(*addrFlag, envOrDefault(envHTTPAddr))
|
||||
|
||||
dataInput := firstNonEmpty(*dataFlag, envOrDefault(envData), defaultDataRel)
|
||||
absData, err := absPath(workPath, dataInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
||||
}
|
||||
|
||||
dbCfg, err := buildDatabaseConfig(absData, dbFlags{
|
||||
Type: *dbTypeFlag, DSN: *dbDSNFlag, Host: *dbHostFlag,
|
||||
User: *dbUserFlag, Pass: *dbPassFlag, Name: *dbNameFlag, SSL: *dbSSLFlag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
WorkPath: workPath,
|
||||
HTTPAddr: httpAddr,
|
||||
Port: port,
|
||||
DataDir: absData,
|
||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||
ServiceAction: action,
|
||||
DevMode: *devFlag,
|
||||
DB: dbCfg,
|
||||
}
|
||||
|
||||
needDirs := action == "" || action == "install"
|
||||
if needDirs {
|
||||
if err := ensureDataDirs(absData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.resolveJWT(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
type dbFlags struct {
|
||||
Type, DSN, Host, User, Pass, Name, SSL string
|
||||
}
|
||||
|
||||
func buildDatabaseConfig(dataDir string, f dbFlags) (DatabaseConfig, error) {
|
||||
typ := strings.ToLower(firstNonEmpty(f.Type, envOrDefault(envDBType), DBTypeSQLite))
|
||||
switch typ {
|
||||
case "sqlite", "sqlite3":
|
||||
typ = DBTypeSQLite
|
||||
case "postgres", "postgresql", "pg":
|
||||
typ = DBTypePostgres
|
||||
case "mysql", "mariadb":
|
||||
typ = DBTypeMySQL
|
||||
default:
|
||||
return DatabaseConfig{}, fmt.Errorf("不支持的数据库类型 %q,可选:sqlite|postgres|mysql", typ)
|
||||
}
|
||||
|
||||
out := DatabaseConfig{
|
||||
Type: typ,
|
||||
DSN: firstNonEmpty(f.DSN, envOrDefault(envDBDSN)),
|
||||
Host: firstNonEmpty(f.Host, envOrDefault(envDBHost)),
|
||||
User: firstNonEmpty(f.User, envOrDefault(envDBUser)),
|
||||
Password: firstNonEmpty(f.Pass, envOrDefault(envDBPass)),
|
||||
Name: firstNonEmpty(f.Name, envOrDefault(envDBName)),
|
||||
SSLMode: firstNonEmpty(f.SSL, envOrDefault(envDBSSLMode), "disable"),
|
||||
MaxOpenConns: envIntDefault(envDBMaxOpen, 0),
|
||||
MaxIdleConns: envIntDefault(envDBMaxIdle, 0),
|
||||
ConnMaxLifetimeSec: envIntDefault(envDBConnLife, 0),
|
||||
}
|
||||
|
||||
if typ == DBTypeSQLite {
|
||||
if out.DSN != "" {
|
||||
out.SQLitePath = out.DSN
|
||||
} else {
|
||||
out.SQLitePath = filepath.Join(dataDir, "jiang13.db")
|
||||
out.DSN = out.SQLitePath
|
||||
}
|
||||
if out.MaxOpenConns == 0 {
|
||||
out.MaxOpenConns = 1
|
||||
}
|
||||
if out.MaxIdleConns == 0 {
|
||||
out.MaxIdleConns = 1
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if out.DSN == "" {
|
||||
dsn, err := buildDSN(out)
|
||||
if err != nil {
|
||||
return DatabaseConfig{}, err
|
||||
}
|
||||
out.DSN = dsn
|
||||
}
|
||||
if out.MaxOpenConns == 0 {
|
||||
out.MaxOpenConns = 25
|
||||
}
|
||||
if out.MaxIdleConns == 0 {
|
||||
out.MaxIdleConns = 5
|
||||
}
|
||||
if out.ConnMaxLifetimeSec == 0 {
|
||||
out.ConnMaxLifetimeSec = 300
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildDSN(c DatabaseConfig) (string, error) {
|
||||
if c.Host == "" || c.User == "" || c.Name == "" {
|
||||
return "", fmt.Errorf("%s 需要 JIANG13_DB_DSN,或 JIANG13_DB_HOST/USER/NAME(及可选 PASS)", c.Type)
|
||||
}
|
||||
switch c.Type {
|
||||
case DBTypePostgres:
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(c.User, c.Password),
|
||||
Host: c.Host,
|
||||
Path: "/" + c.Name,
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sslmode", c.SSLMode)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
case DBTypeMySQL:
|
||||
// 特殊字符密码请直接用 JIANG13_DB_DSN;此处为拆分参数简易拼接
|
||||
return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true&loc=Local&charset=utf8mb4",
|
||||
c.User, c.Password, c.Host, c.Name), nil
|
||||
default:
|
||||
return "", fmt.Errorf("无法为 %s 拼接 DSN", c.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWorkPath(flagVal string) (string, error) {
|
||||
if strings.TrimSpace(flagVal) != "" {
|
||||
abs, err := filepath.Abs(flagVal)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析工作目录失败: %w", err)
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
return defaultWorkPath()
|
||||
}
|
||||
|
||||
func ensureDataDirs(dataDir string) error {
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建数据目录失败: %w", err)
|
||||
}
|
||||
for _, sub := range []string{
|
||||
filepath.Join(dataDir, "uploads", "avatars"),
|
||||
filepath.Join(dataDir, "uploads", "posts"),
|
||||
filepath.Join(dataDir, "uploads", "site"),
|
||||
} {
|
||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) resolveJWT() error {
|
||||
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(bytesTrimSpace(data)) > 0 {
|
||||
c.JWTSecret = string(bytesTrimSpace(data))
|
||||
return nil
|
||||
}
|
||||
sec, err := generateRandomSecret(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.JWTSecret = sec
|
||||
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
|
||||
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bytesTrimSpace(b []byte) []byte {
|
||||
return []byte(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func validServiceAction(action string) bool {
|
||||
switch action {
|
||||
case "install", "uninstall", "start", "stop", "restart", "status":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAddr 返回 host:port
|
||||
func (c *Config) ListenAddr() string {
|
||||
if c.HTTPAddr == "" {
|
||||
return fmt.Sprintf(":%d", c.Port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", c.HTTPAddr, c.Port)
|
||||
}
|
||||
|
||||
// SQLitePath 兼容旧调用:仅 sqlite 有意义
|
||||
func (c *Config) DBPath() string {
|
||||
if c.DB.Type == DBTypeSQLite {
|
||||
return c.DB.SQLitePath
|
||||
}
|
||||
return c.DB.DSN
|
||||
}
|
||||
|
||||
func (c *Config) AvatarUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||
}
|
||||
func (c *Config) PostImageUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "posts")
|
||||
}
|
||||
func (c *Config) SiteUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "site")
|
||||
}
|
||||
func (c *Config) FilterWordsPath() string {
|
||||
return filepath.Join(c.DataDir, "filter_words.txt")
|
||||
}
|
||||
|
||||
func generateRandomSecret(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("生成密钥失败: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func envIntDefault(key string, def int) int {
|
||||
v := envOrDefault(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
41
config/env.go
Normal file
41
config/env.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 引导环境变量(无 INI)
|
||||
const (
|
||||
envWorkPath = "JIANG13_WORK_PATH"
|
||||
envHTTPPort = "JIANG13_HTTP_PORT"
|
||||
envHTTPAddr = "JIANG13_HTTP_ADDR"
|
||||
envData = "JIANG13_DATA"
|
||||
envDBType = "JIANG13_DB_TYPE"
|
||||
envDBDSN = "JIANG13_DB_DSN"
|
||||
envDBHost = "JIANG13_DB_HOST"
|
||||
envDBUser = "JIANG13_DB_USER"
|
||||
envDBPass = "JIANG13_DB_PASS"
|
||||
envDBName = "JIANG13_DB_NAME"
|
||||
envDBSSLMode = "JIANG13_DB_SSLMODE"
|
||||
envDBMaxOpen = "JIANG13_DB_MAX_OPEN"
|
||||
envDBMaxIdle = "JIANG13_DB_MAX_IDLE"
|
||||
envDBConnLife = "JIANG13_DB_CONN_MAX_LIFETIME_SEC"
|
||||
)
|
||||
|
||||
func envOrDefault(key string) string {
|
||||
return strings.TrimSpace(os.Getenv(key))
|
||||
}
|
||||
|
||||
func envIntOrZero(key string) int {
|
||||
v := envOrDefault(key)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
69
config/paths.go
Normal file
69
config/paths.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// resolveAppPath 返回可执行文件的绝对路径(解析符号链接)
|
||||
func resolveAppPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取可执行文件路径失败: %w", err)
|
||||
}
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析可执行文件路径失败: %w", err)
|
||||
}
|
||||
return exe, nil
|
||||
}
|
||||
|
||||
// defaultWorkPath 类似 Gitea:默认可执行文件所在目录;
|
||||
// go run 时二进制在临时目录,回退为当前工作目录。
|
||||
func defaultWorkPath() (string, error) {
|
||||
exe, err := resolveAppPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
if isEphemeralExeDir(dir) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取当前目录失败: %w", err)
|
||||
}
|
||||
return filepath.Clean(wd), nil
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func isEphemeralExeDir(dir string) bool {
|
||||
lower := strings.ToLower(filepath.Clean(dir))
|
||||
sep := string(filepath.Separator)
|
||||
markers := []string{
|
||||
sep + "go-build",
|
||||
sep + "go-run",
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
markers = append(markers, "\\go-build", "\\go-run")
|
||||
}
|
||||
for _, m := range markers {
|
||||
if strings.Contains(lower, strings.ToLower(m)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func absPath(base, p string) (string, error) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("路径为空")
|
||||
}
|
||||
if filepath.IsAbs(p) {
|
||||
return filepath.Clean(p), nil
|
||||
}
|
||||
return filepath.Abs(filepath.Join(base, p))
|
||||
}
|
||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
# 姜十三论坛 — Docker Compose 单服务部署
|
||||
# 启动:docker compose up -d --build
|
||||
# 或:make compose-up / build.bat -Target compose-up
|
||||
#
|
||||
# 默认 SQLite(数据在 /data)。换库示例:
|
||||
# JIANG13_DB_TYPE=postgres
|
||||
# JIANG13_DB_DSN=postgres://forum:secret@host:5432/jiang13?sslmode=disable
|
||||
# JIANG13_DB_TYPE=mysql
|
||||
# JIANG13_DB_DSN=forum:secret@tcp(host:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4
|
||||
|
||||
services:
|
||||
jiang13:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# JIANG13_DB_TYPE: sqlite
|
||||
# JIANG13_HTTP_PORT: "3000"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 15s
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
jiang13-data:
|
||||
15
docker-entrypoint.sh
Normal file
15
docker-entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
|
||||
# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。
|
||||
|
||||
DATA_DIR="/data"
|
||||
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R jiang13:jiang13 "$DATA_DIR"
|
||||
exec su-exec jiang13 /app/jiang13 "$@"
|
||||
fi
|
||||
|
||||
exec /app/jiang13 "$@"
|
||||
158
docs/introduction.md
Normal file
158
docs/introduction.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 姜十三论坛:为小圈子而生的轻量论坛
|
||||
|
||||
> 轻量 · 好看 · 单文件部署
|
||||
> 面向内部交流,不堆功能,把「能聊、好看、好装」做到位。
|
||||
|
||||
---
|
||||
|
||||
## 它是什么
|
||||
|
||||
**姜十三论坛(Jiang13 Forum)** 是一款面向小圈子、团队与兴趣社群的现代化论坛软件。
|
||||
|
||||
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
|
||||
|
||||
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:公开页用 **Go `html/template` 真 SSR**,`web_src` 做渐进增强 CSS/JS(`go:embed`),数据库默认 **SQLite**,拷贝到服务器就能跑。
|
||||
|
||||
---
|
||||
|
||||
## 为什么做它
|
||||
|
||||
市面上的论坛方案往往落在两端:
|
||||
|
||||
- **重型产品**(Discourse、phpBB 等):功能完整,但部署与运维成本高,对小团队过重。
|
||||
- **即时通讯 / 文档工具**:适合聊天与协作,却缺少「发帖—回复—沉淀」的社区节奏。
|
||||
|
||||
小圈子真正需要的,常常是中间态:
|
||||
|
||||
- 有板块、帖子、楼层回复,讨论可追溯;
|
||||
- 界面清爽、信息密度够高,桌面与手机都好用;
|
||||
- 部署简单到「一个文件 + 一份配置」;
|
||||
- 数据在自己手里,备份一眼能懂。
|
||||
|
||||
姜十三论坛正是为这个缺口而生。
|
||||
|
||||
---
|
||||
|
||||
## 谁适合用
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 团队 / 工作室内部论坛 | 需求讨论、进度同步、知识沉淀 |
|
||||
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
||||
| 开源 / 私有项目配套社区 | 与 Gitea 等工具通过 OIDC(开放身份连接)做 SSO(单点登录) |
|
||||
| 想自己托管的个人站长 | 单机即可,无需云数据库与一堆微服务 |
|
||||
|
||||
如果你需要百万用户级的公网社区、复杂插件生态或企业级工单流,它可能不是最优选;如果你要的是 **小而美、自己能装、自己能管**,它会对胃口。
|
||||
|
||||
---
|
||||
|
||||
## 用起来是什么感觉
|
||||
|
||||
### 浏览:高密度、不臃肿
|
||||
|
||||
前台采用熟悉的 **三栏布局**:
|
||||
|
||||
- **左栏**:板块导航,可折叠;
|
||||
- **中间**:帖子 Feed(信息流),支持虚拟滚动,长列表依然流畅;
|
||||
- **右栏**:热门帖与最新评论,社区动态一目了然。
|
||||
|
||||
Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗色主题一键切换,并会记住你的偏好。平板与手机上侧栏会自动收起,触控浏览同样顺手。
|
||||
|
||||
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
|
||||
|
||||
### 发帖:Markdown,够用就好
|
||||
|
||||
发帖使用 **Markdown 工具栏**(标题、粗斜体、列表、代码块、图片上传、内容门控插入)与服务端预览;标签、板块选择日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
|
||||
|
||||
### 讨论:楼层回复,聊得清楚
|
||||
|
||||
评论是楼层式的:可回复指定楼层、引用原文;支持嵌套展示。点赞、收藏、热门帖,把活跃内容自然推到前面。
|
||||
|
||||
### 管理:SSR 后台
|
||||
|
||||
管理后台统一在 `/admin`,与公开页同一套模板布局:
|
||||
|
||||
- 仪表盘、板块与帖子管理
|
||||
- 用户禁言、删帖删评
|
||||
- 论坛参数、限流、敏感词
|
||||
- SQLite **一键备份**
|
||||
|
||||
权限模型很简单:**普通用户 / 管理员**;首次通过 **安装向导** 创建管理员账号。
|
||||
|
||||
---
|
||||
|
||||
## 部署:一个文件,真正开箱
|
||||
|
||||
这大概是姜十三论坛最「硬核」的卖点之一。
|
||||
|
||||
```text
|
||||
编译 → 得到一个 jiang13(或 jiang13.exe)
|
||||
放到目录里运行 → Env / CLI 引导(默认 SQLite)
|
||||
打开浏览器完成安装向导 → 创建管理员
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
|
||||
- **零外部数据库**:SQLite 落在数据目录,备份就是拷贝文件;
|
||||
- **Env / CLI 引导**:端口、数据目录、数据库类型;业务项在管理后台热更新;
|
||||
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
|
||||
- **跨平台**:Windows / Linux / macOS 均可编译与运行。
|
||||
|
||||
典型启动后访问 `http://localhost:3000`,完成安装后即可开始。
|
||||
|
||||
---
|
||||
|
||||
## 技术素描(给好奇的人)
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 后端 / SSR | Go · Gin · GORM · SQLite · `html/template` |
|
||||
| 渐进资源 | `web_src` → `public/assets`(`/ssr-assets/`) |
|
||||
| 构建 | `web_src` + `go:embed` templates/assets,单二进制 |
|
||||
| 认证 | bcrypt + 会话 Cookie;可选 OIDC Provider,对接 Gitea 等 SSO |
|
||||
|
||||
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:改模板/Go 后重启进程;改 `web_src` 后跑 `build.bat -Target web-src`。
|
||||
|
||||
---
|
||||
|
||||
## 现在走到哪一步
|
||||
|
||||
项目仍在积极开发中,核心体验已经可用(分支 `rebuild/gitea-ssr`):
|
||||
|
||||
- ✅ 三栏布局、主题切换、Feed 排序、真 SSR 公开页
|
||||
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史(Markdown 编辑器)
|
||||
- ✅ SSR 管理后台与论坛参数配置
|
||||
- ✅ OIDC Provider(可作 Gitea 等站点的登录源)
|
||||
- ✅ 单二进制部署与系统服务
|
||||
|
||||
计划中的方向见仓库 [ROADMAP.md](../ROADMAP.md) 与 [09-ssr-progress.md](rebuild-spec/09-ssr-progress.md)。
|
||||
|
||||
---
|
||||
|
||||
## 一句话总结
|
||||
|
||||
**姜十三论坛** = 小圈子论坛该有的功能 × 清新好用的界面 × 真正能单文件带走的部署方式。
|
||||
|
||||
如果你正在给团队或同好找一个「自己的小论坛」,不妨编译跑一下:注册第一个账号,从发第一帖开始。
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\build.bat
|
||||
|
||||
# Linux / macOS
|
||||
make build
|
||||
|
||||
# 运行
|
||||
./dist/jiang13 # 或 Windows: .\dist\jiang13.exe
|
||||
```
|
||||
|
||||
浏览器打开 `http://localhost:3000/register`,第一个注册用户即为管理员。
|
||||
|
||||
更多细节(配置项、系统服务、前端开发)见项目 [README](../README.md)。欢迎通过 Issue / PR 参与共建。
|
||||
|
||||
**许可证:** MIT
|
||||
65
docs/issue-templates.md
Normal file
65
docs/issue-templates.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Issue 预填模板
|
||||
|
||||
以下可直接复制到本仓库 Issues 创建。本分支为 **SSR**(`rebuild/gitea-ssr`);对照 SPA 请注明 `main`。
|
||||
|
||||
---
|
||||
|
||||
## Issue · Bug 模板
|
||||
|
||||
**标题:** `[Bug] 简短描述`
|
||||
|
||||
**标签:** `bug` `ui/ux`
|
||||
|
||||
**正文:**
|
||||
|
||||
### 问题描述
|
||||
|
||||
(一句话)
|
||||
|
||||
### 复现步骤
|
||||
|
||||
1. …
|
||||
2. …
|
||||
|
||||
### 期望行为 / 实际行为
|
||||
|
||||
### 相关代码(若已知)
|
||||
|
||||
- 模板:`templates/…`
|
||||
- 路由:`routers/web/…`
|
||||
- 样式/脚本:`web_src/…`
|
||||
|
||||
### 环境
|
||||
|
||||
- 分支:`rebuild/gitea-ssr`(或 `main` SPA)
|
||||
- 浏览器 / OS
|
||||
|
||||
---
|
||||
|
||||
## Issue · 功能增强模板
|
||||
|
||||
**标题:** `[Feature] 简短描述`
|
||||
|
||||
**标签:** `enhancement`
|
||||
|
||||
**正文:**
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
### 现状
|
||||
|
||||
| 能力 | 状态 |
|
||||
| --- | --- |
|
||||
| 规格(`docs/rebuild-spec`) | ✅ / ❌ |
|
||||
| SSR 页面 / 表单 | ✅ / ❌ |
|
||||
| 业务 `services/` | ✅ / ❌ |
|
||||
|
||||
### 期望方案
|
||||
|
||||
1. …
|
||||
2. …
|
||||
|
||||
### 相关代码
|
||||
|
||||
- `routers/web/`、`templates/`、`services/`
|
||||
- 规格:`docs/rebuild-spec/02-features.md` / `06-pages-ux.md`
|
||||
185
docs/rebuild-spec/01-product.md
Normal file
185
docs/rebuild-spec/01-product.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 01 · 产品定位与模块地图
|
||||
|
||||
> **读者**:重构架构师 / 产品对齐
|
||||
> **前置**:[README.md](README.md)
|
||||
> **后续**:[02-features.md](02-features.md)
|
||||
> **源码**:[docs/introduction.md](../introduction.md)、[README.md](../../README.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 定位
|
||||
|
||||
姜十三论坛不做大而全公网社区,只服务「几人到几百人」的内部交流:
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
|
||||
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
||||
| 项目配套社区 | 可与 Gitea 通过 OIDC(开放身份连接)做 SSO(单点登录) |
|
||||
| 个人站长 | 希望数据自管、部署简单 |
|
||||
|
||||
**产品口号气质**:能聊 · 好看 · 好装(部署简单)。
|
||||
|
||||
**规模预期**:非百万用户级;信息密度接近 V2EX / NGA 一类,而非大留白营销站。
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品 vs 运维(拆开看待)
|
||||
|
||||
| 维度 | 当前实现 | 重构时 |
|
||||
|------|----------|--------|
|
||||
| **产品** | 论坛功能全集(见模块地图) | **必须对齐**功能与规则 |
|
||||
| **运维** | 单二进制 + Env 引导 + SQLite/PG/MySQL + `forum_settings` 热更 | **本分支已落地**;无 `app.ini` |
|
||||
|
||||
规格文档把「用户能做什么」写死;把「怎么打包发布」放在 [07-config-ops.md](07-config-ops.md) 供参考。
|
||||
|
||||
---
|
||||
|
||||
## 3. 角色模型
|
||||
|
||||
| 角色 | 识别 | 能力摘要 |
|
||||
|------|------|----------|
|
||||
| **游客** | 未登录 | 浏览公开内容;可发表游客评论(需昵称等);门控内容按规则遮盖 |
|
||||
| **登录用户** | JWT Cookie 有效且未禁言 | 发帖、评论、点赞收藏、私信、签到抽奖、友链申请、举报等 |
|
||||
| **认证用户** | `user.verified = true` | 与管理员一样:**发帖/评论免审**(`SkipsModeration`) |
|
||||
| **管理员** | `role = admin` | 全部后台能力;免审;看待审/被拒内容 |
|
||||
| **系统** | 私信 `from_user_id = 0` | 发系统通知(审核、回复提醒、举报结果等) |
|
||||
|
||||
**引导规则**:站点**第一个注册用户自动成为管理员**(无安装向导单独建管步骤)。
|
||||
|
||||
禁言用户(`banned`):带鉴权的写接口被拒绝;浏览策略以实现为准,前端通常视为不可正常互动。
|
||||
|
||||
---
|
||||
|
||||
## 4. 模块地图
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph core [核心社区]
|
||||
auth[认证与账号]
|
||||
board[板块]
|
||||
feed[Feed与搜索]
|
||||
post[帖子与特殊类型]
|
||||
comment[评论]
|
||||
gate[内容门控]
|
||||
end
|
||||
subgraph social [社交]
|
||||
like[点赞收藏]
|
||||
msg[私信与通知]
|
||||
profile[用户主页]
|
||||
report[举报]
|
||||
end
|
||||
subgraph economy [经济与成长]
|
||||
points[积分钱包]
|
||||
checkin[签到抽奖]
|
||||
badge[徽章]
|
||||
level[等级Exp]
|
||||
end
|
||||
subgraph site [站点扩展]
|
||||
page[自定义单页]
|
||||
links[友情链接]
|
||||
gitea[Gitea码桶]
|
||||
brand[品牌与SEO]
|
||||
end
|
||||
subgraph admin [管理与集成]
|
||||
mod[审核回收站]
|
||||
settings[系统设置]
|
||||
oidc[OIDC Provider]
|
||||
storage[本地或S3存储]
|
||||
mail[SMTP邮件]
|
||||
end
|
||||
auth --> feed
|
||||
board --> feed
|
||||
feed --> post
|
||||
post --> comment
|
||||
post --> gate
|
||||
post --> like
|
||||
comment --> msg
|
||||
points --> gate
|
||||
points --> checkin
|
||||
auth --> oidc
|
||||
settings --> mail
|
||||
settings --> storage
|
||||
```
|
||||
|
||||
### 4.1 认证与账号
|
||||
|
||||
- 注册 / 登录 / 登出;可选邮箱验证码;忘记密码重置
|
||||
- 图形验证码(注册场景)
|
||||
- 个人资料:昵称、签名、头像、改密
|
||||
- 邮件未配置时可能关闭公开注册(见业务规则)
|
||||
|
||||
### 4.2 板块与 Feed
|
||||
|
||||
- 多板块;图标与色板;排序
|
||||
- 首页「全部」+ `/board/:id`
|
||||
- 排序:最新发帖 / 最新回复 / 热门
|
||||
- 搜索:关键词、标签、作者、仅标题
|
||||
- 列表样式:仅标题 / 摘要 / 缩略图(后台可配)
|
||||
|
||||
### 4.3 帖子
|
||||
|
||||
- 富文本正文(HTML,TipTap 产出)+ 可选 Markdown 编辑面
|
||||
- 标签、修订历史与 diff
|
||||
- 五种类型:`normal` | `question` | `poll` | `bounty` | `lottery`
|
||||
- 运营标记:全局置顶、版内置顶、精华、禁止编辑、禁止评论
|
||||
- 审核状态:`pending` | `published` | `rejected`;软删回收站
|
||||
|
||||
### 4.4 内容门控
|
||||
|
||||
正文内嵌自定义标签(非独立表行,存在 `posts.content` HTML 中):
|
||||
|
||||
| 标签 | 含义 |
|
||||
|------|------|
|
||||
| `<members-only>` | 登录可见 |
|
||||
| `<reply-only>` | 本帖已回复可见 |
|
||||
| `<points-only data-cost="N">` | 积分解锁;按块计费 |
|
||||
|
||||
### 4.5 评论
|
||||
|
||||
- 楼层号;回复指定楼;嵌套展示;引用;@ 提及
|
||||
- 游客评论字段;私密评论(仅相关人可见)
|
||||
- 点赞、编辑时限、审核、软删
|
||||
|
||||
### 4.6 积分经济与成长
|
||||
|
||||
- **Points**:可消费积分(签到、抽奖、解锁、悬赏托管等)
|
||||
- **Exp**:不可消费经验 → 等级 Lv1–10
|
||||
- **CreatorIncomeTotal**:创作分成累计(徽章指标)
|
||||
- 徽章:自动(门槛)+ 限定(管理员发放)
|
||||
|
||||
### 4.7 私信与通知
|
||||
|
||||
统一走 `private_messages` 表,用 `kind` 区分用户私信与系统事件。
|
||||
|
||||
### 4.8 友链与站点页
|
||||
|
||||
- 管理员维护品牌友链 JSON;用户申请审核(可选回链检测)
|
||||
- 自定义单页(关于、版规等):slug、发布、导航/页脚展示
|
||||
|
||||
### 4.9 Gitea 开源码桶
|
||||
|
||||
后台配置后定时同步公开仓库到 `gitea_repos`,前台 `/projects` 展示。
|
||||
|
||||
### 4.10 OIDC Provider
|
||||
|
||||
本站作为 IdP:Discovery / Authorize / Token / UserInfo / Logout / JWKS;多 OAuth 客户端。
|
||||
|
||||
### 4.11 管理后台
|
||||
|
||||
仪表盘、板块、单页、友链、帖/评、举报、用户、徽章、媒体、系统设置、SQLite 备份。
|
||||
|
||||
### 4.12 SEO / 发现
|
||||
|
||||
`robots.txt`、`sitemap.xml`、Open Graph / Twitter / JSON-LD;当前另有爬虫 HTML。新站应用 SSR 统一,但 **meta 字段集合应保留**(见 [07-config-ops.md](07-config-ops.md))。
|
||||
|
||||
---
|
||||
|
||||
## 5. 权限一句话
|
||||
|
||||
- **读公开内容**:人人可(含游客)
|
||||
- **写内容**:登录(部分评论允许游客)
|
||||
- **审与运营**:管理员
|
||||
- **免审写**:管理员或 `verified`
|
||||
|
||||
细节状态机见 [05-business-rules.md](05-business-rules.md)。
|
||||
215
docs/rebuild-spec/02-features.md
Normal file
215
docs/rebuild-spec/02-features.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 02 · 功能清单(验收级)
|
||||
|
||||
> **读者**:实现与验收
|
||||
> **前置**:[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`]((仅 main)frontend/src/App.tsx)、[`router/router.go`](../../routers/setup.go)、[`README.md`](../../README.md)
|
||||
|
||||
用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。
|
||||
|
||||
---
|
||||
|
||||
## A. 浏览与布局
|
||||
|
||||
- [x] 三栏布局:左导航 / 中 Feed / 右栏小组件 — SSR(桌面;≤900px 隐藏右栏)
|
||||
- [x] 浅色 / 暗色主题;跟随系统偏好并本地记忆 — CSS `prefers-color-scheme` + `data-theme` 显式覆盖;顶栏循环:跟随系统 / 浅色 / 暗色(`j13-theme`)
|
||||
- [ ] 响应式:平板/手机收起侧栏
|
||||
- [ ] 长列表虚拟滚动或等价流畅方案
|
||||
- [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
|
||||
- [x] 板块筛选:全部 + 单板块
|
||||
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
|
||||
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`)
|
||||
- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin `/admin/settings` 侧栏组件编辑器
|
||||
- [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`)
|
||||
- [ ] 下拉刷新(移动端)
|
||||
- [x] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)— SSR `/admin/settings` 伪静态;公开路径 301 规范 URL
|
||||
- [x] 404 页
|
||||
|
||||
---
|
||||
|
||||
## B. 认证与个人中心
|
||||
|
||||
- [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register`
|
||||
- [x] 图形验证码接口(注册流程)— SSR 注册页强制;`GET /api/captcha` + `POST /api/register` 校验
|
||||
- [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销
|
||||
- [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪)
|
||||
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
|
||||
- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
|
||||
- [x] 个人中心:改昵称、签名、密码、上传头像、积分钱包/签到/抽奖 — SSR `/profile`(裁剪未做)
|
||||
- [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id`
|
||||
- [x] 公开用户主页 `/user/:id`(无邮箱)
|
||||
- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
|
||||
|
||||
---
|
||||
|
||||
## C. 板块
|
||||
|
||||
- [x] 列出板块(含帖数等展示字段)— SSR `/boards` + 侧栏 + Admin
|
||||
- [x] 管理员:创建 / 改 / 删板块 — SSR `/admin/boards`
|
||||
- [x] 板块名称、描述、图标、色板索引、排序
|
||||
- [x] 默认板块保障(空站可引导创建)
|
||||
|
||||
---
|
||||
|
||||
## D. 帖子(通用)
|
||||
|
||||
- [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`(normal)
|
||||
- [x] 正文图片上传 — `/compose/upload` + Markdown 插入
|
||||
- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— **本分支不做 TipTap**;改用 Markdown 工具栏渐进增强
|
||||
- [x] Markdown 编辑(工具栏 + `/compose/preview`;发帖/改帖/评论共用 `shared/md_editor`)— 非 TipTap 双模
|
||||
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
|
||||
- [x] 删除帖子 → 软删进回收站 — SSR Admin(帖详情 + `/admin/trash`)
|
||||
- [x] 修订历史列表与单条详情(可做 diff)— SSR `/post/:id/revisions`(作者/管理员;无 diff)
|
||||
- [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites`
|
||||
- [x] 浏览量 — 详情页计数
|
||||
- [x] 举报帖子 — SSR 表单 + `/admin/reports`
|
||||
- [x] 内容审核状态展示(作者可见待审/被拒)— SSR 帖详情横幅 + 评论楼层标签
|
||||
|
||||
### D.1 帖子类型
|
||||
|
||||
- [x] `normal` 普通讨论 — SSR compose 默认
|
||||
- [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换
|
||||
- [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
|
||||
- [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)— SSR compose + 详情条 + award/refund
|
||||
- [x] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 — SSR compose + 详情卡 + draw
|
||||
|
||||
### D.2 运营标记(管理员)
|
||||
|
||||
- [x] 全局置顶 / 取消 — SSR 帖详情
|
||||
- [x] 版内置顶 / 取消(仅板块列表抬升)— SSR 帖详情
|
||||
- [x] 精华 / 取消 — SSR 帖详情
|
||||
- [x] 禁止编辑(edit lock)— SSR 帖详情
|
||||
- [x] 禁止评论 / 结贴(comments lock)— SSR 帖详情
|
||||
- [x] 审核通过 / 拒绝(拒绝可通知作者)— SSR `/admin/moderation`
|
||||
- [x] 回收站:恢复 / 彻底删除 — SSR `/admin/trash`
|
||||
|
||||
---
|
||||
|
||||
## E. 内容门控
|
||||
|
||||
- [x] 编辑器可插入「登录可见」块 — compose 工具栏
|
||||
- [x] 编辑器可插入「回复可见」块 — compose 工具栏
|
||||
- [x] 编辑器可插入「积分可见」块(可设价格)— compose 工具栏 + prompt
|
||||
- [x] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 — 锁定壳 UI
|
||||
- [x] 已登录未回复:遮盖 reply-only(作者与管理员始终可见)
|
||||
- [x] 积分块:未解锁遮盖;`POST /post/:id/unlock` 扣积分并返回 inner HTML
|
||||
- [x] 搜索 / SEO 出口对门控内容做红action,不泄露正文(既有)
|
||||
|
||||
---
|
||||
|
||||
## F. 评论
|
||||
|
||||
- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;`ThreadParentID` 嵌套树 + 全局 `#floor` 缩进
|
||||
- [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments`
|
||||
- [ ] 游客评论(公开接口可写,字段 guest_*)
|
||||
- [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站
|
||||
- [x] 评论点赞 — `POST /post/:id/comments/:cid/like`
|
||||
- [x] 评论举报 — SSR
|
||||
- [x] @ 提及 → 通知 — SSR 发评/审通调用 `NotifyCommentMentions`
|
||||
- [x] 回复提醒(站内信 + 可选邮件)— SSR 发评/审通调用 `NotifyCommentPublished`
|
||||
- [x] 审核中 / 被拒评论可见性规则 — 服务层已有;SSR 楼层标签(作者/管理员)
|
||||
- [x] 管理员:通过 / 拒绝待审评论 — SSR `/admin/moderation`(回收站/修订未迁)
|
||||
|
||||
---
|
||||
|
||||
## G. 私信与通知
|
||||
|
||||
- [x] 会话列表(含系统会话 peer=0)— SSR `/messages`
|
||||
- [x] 会话消息(分页 / before 游标)— SSR `/messages/with/:peerId`
|
||||
- [x] 发送私信 — 表单 PRG;用户主页「发私信」入口
|
||||
- [x] 未读数(可分私信 / 通知)— 列表分项 + 导航角标
|
||||
- [x] 标记会话已读 / 通知已读 / 全部已读 — 打开会话自动已读;全部标已读
|
||||
- [x] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等(写入已有;`/messages/with/0?kind=` 筛选)
|
||||
|
||||
---
|
||||
|
||||
## H. 积分、签到、抽奖、徽章、等级
|
||||
|
||||
- [x] 积分流水查询 — SSR `/profile` 钱包近 N 条
|
||||
- [x] 每日签到:基础 5,连签每日 +1,封顶 15 — `POST /profile/checkin`
|
||||
- [x] 每日抽奖:奖池加权(0/2/5/10/20),成本 0 — `POST /profile/lottery`
|
||||
- [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务
|
||||
- [x] 短龄同 IP 互刷拒绝分成 — `UnlockPointsBlock` / `suspiciousUnlockPair`(双方注册未满 7 天且 LastLoginIP 相同)
|
||||
- [x] Exp → 等级 Lv1–10 — 门槛推导 + 发帖/评论/获赞加 Exp;Admin 设等级 SSR `/admin/users`
|
||||
- [x] 自动徽章(注册天数 / 获赞 / 创作分成)— 种子 + `EvaluateAuto`(用户页/资料页访问时评估);无独立定时调度 UI
|
||||
- [x] 限定徽章:管理员定义与授予/撤销 — SSR `/admin/badges`
|
||||
- [x] 管理员调整积分、设定认证(免审)、设定等级 — SSR `/admin/users`
|
||||
|
||||
---
|
||||
|
||||
## I. 友链
|
||||
|
||||
- [x] 前台友链页;导航/页脚入口可配 — SSR `/links`;`nav_show_friend_links` / `footer_show_friend_links`
|
||||
- [x] 用户申请(名称、URL、Logo、是否上首页、回链页)— `POST /links/apply`
|
||||
- [x] Logo 上传 — 表单 multipart 或 `POST /links/logo`
|
||||
- [x] 我的申请列表;取消待审 — `/links`(修改待审:取消后重提)
|
||||
- [x] 管理员审核:通过 / 拒绝 / 回链复检 — SSR `/admin/friend-links`(创建时可异步检测)
|
||||
- [x] 管理员维护品牌友链列表 — `/admin/friend-links` 增删
|
||||
|
||||
---
|
||||
|
||||
## J. 站点单页
|
||||
|
||||
- [x] 公开:`/page/:slug` 列表入口(nav/footer)— SSR
|
||||
- [x] 管理员 CRUD;发布开关;排序;nav/footer 展示开关 — SSR `/admin/pages`
|
||||
|
||||
---
|
||||
|
||||
## K. Gitea 码桶(**后置**)
|
||||
|
||||
> 本迭代**不做**产品化同步:不启后台定时任务、不挂管理入口。表结构与 settings 键可保留兼容。
|
||||
|
||||
- [ ] (后置)后台开关、Base URL、Token、同步间隔
|
||||
- [ ] (后置)手动同步 + 后台定时同步
|
||||
- [ ] (后置)前台 `/projects` 列表与搜索
|
||||
|
||||
---
|
||||
|
||||
## L. OIDC Provider
|
||||
|
||||
- [ ] Discovery、JWKS、Authorize、Token、UserInfo、Logout
|
||||
- [ ] 多 OAuth 客户端 CRUD;密钥哈希存储;PKCE 字段支持
|
||||
- [ ] groups claim 映射 admin/user 组
|
||||
|
||||
---
|
||||
|
||||
## M. 媒体与存储
|
||||
|
||||
- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置)— 后端已支持;Admin 热切换 UI 未迁
|
||||
- [x] 头像 / 帖图 / 站点品牌资源分类 — 上传路径 + Admin 分类筛选
|
||||
- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`)— 路由已有;全站默认策略未产品化
|
||||
- [x] 管理后台媒体列表与删除 — SSR `/admin/media`
|
||||
- [x] 媒体索引表同步 — 启动扫盘 + Admin「同步索引」
|
||||
|
||||
---
|
||||
|
||||
## N. 管理后台其它
|
||||
|
||||
- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard`
|
||||
- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
|
||||
- [x] 基础限流(post/comment/register/login/window)— SSR `/admin/settings`
|
||||
- [x] 完整 Limits(字数 / 编辑窗 / 分页 / 签名头像 / Feed 样式 / 新标签)— SSR `/admin/settings/content-limits`
|
||||
- [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区
|
||||
- [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings`
|
||||
- [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear`;`<head>` favicon + og:image
|
||||
- [x] SQLite 一键备份与下载 — SSR `/admin/settings`(仅 sqlite;写入 data 目录后下载)
|
||||
|
||||
---
|
||||
|
||||
## O. 基础设施
|
||||
|
||||
- [x] `GET /health` — `routers/setup.go`
|
||||
- [x] `robots.txt` / `sitemap.xml` — SSR 同源路径
|
||||
- [x] 静态上传文件可达 — `Static /uploads` → data/uploads
|
||||
- [x] 限流:发帖、评论、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`
|
||||
|
||||
---
|
||||
|
||||
## 明确不在当前规格内(计划中可后做)
|
||||
|
||||
摘自 [`ROADMAP.md`](../../ROADMAP.md),**不是**现网必交验收项:
|
||||
|
||||
- 通知动态 UX 大幅优化
|
||||
- 帖子搜索增强(组合筛选更强)
|
||||
|
||||
若新站一并实现,可作为加分项,不阻塞「功能对等」验收。
|
||||
473
docs/rebuild-spec/03-data-model.md
Normal file
473
docs/rebuild-spec/03-data-model.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# 03 · 数据模型
|
||||
|
||||
> **读者**:实现数据库与领域层的 AI
|
||||
> **前置**:[01-product.md](01-product.md)
|
||||
> **后续**:[04-api.md](04-api.md)、[05-business-rules.md](05-business-rules.md)
|
||||
> **源码**:[`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,但**字段语义应对齐**。
|
||||
|
||||
---
|
||||
|
||||
## 1. ER 概览
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
User ||--o{ Post : authors
|
||||
User ||--o{ Comment : authors
|
||||
Board ||--o{ Post : contains
|
||||
Post ||--o{ Comment : has
|
||||
Post ||--o{ PostLike : likes
|
||||
Post ||--o{ PostFavorite : favorites
|
||||
Post ||--o{ PostRevision : revisions
|
||||
Comment ||--o{ CommentLike : likes
|
||||
Comment ||--o{ CommentRevision : revisions
|
||||
Post ||--o| Poll : poll
|
||||
Poll ||--o{ PollOption : options
|
||||
PollOption ||--o{ PollVote : votes
|
||||
Post ||--o{ PostLotteryWinner : winners
|
||||
Post ||--o{ PostContentUnlock : unlocks
|
||||
User ||--o{ PointLedger : ledger
|
||||
User ||--o{ CheckIn : checkins
|
||||
User ||--o{ LotteryDraw : draws
|
||||
User ||--o{ UserBadge : earns
|
||||
BadgeDef ||--o{ UserBadge : defines
|
||||
User ||--o{ PrivateMessage : sends
|
||||
User ||--o{ PostReport : reports
|
||||
User ||--o{ FriendLinkApply : applies
|
||||
User ||--o{ Media : uploads
|
||||
```
|
||||
|
||||
另有:`ForumSetting`(键值)、`Session`(浏览器 opaque 会话)、`OAuthClient` / `OAuthAuthCode`、`GiteaRepo`、`SitePage`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 表与字段
|
||||
|
||||
说明:`json:"-"` 表示默认 API 序列化隐藏;软删列 `deleted_at` 表示 GORM soft delete。
|
||||
|
||||
### 2.0 sessions
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| id | string(64) PK | 密码学随机 opaque id(Cookie `jiang13_session` 的值) |
|
||||
| user_id | uint index | 用户 |
|
||||
| expires_at | time index | 过期;默认 TTL 7 天,滑动续期 |
|
||||
| created_at / last_seen_at | time | |
|
||||
| ip / user_agent | string | 可选审计 |
|
||||
|
||||
登出删单行;禁言 / 改密删该用户全部 session。每次请求以 DB 中 `users.role` / 禁言为准。
|
||||
|
||||
### 2.1 users
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | uint PK | | |
|
||||
| username | string(128) | unique, not null | 登录名 |
|
||||
| email | string(128) | index, default '' | 公开主页不返回 |
|
||||
| password | string(128) | not null | bcrypt 哈希,永不返回 |
|
||||
| nickname | string(64) | | 展示名 |
|
||||
| signature | string(512) | default '' | 个人签名 |
|
||||
| avatar | string(512) | | 相对或绝对 URL |
|
||||
| role | string(16) | default `user` | `user` \| `admin` |
|
||||
| verified | bool | index | 站长认证,免审 |
|
||||
| exp | int | default 0 | 经验(不可消费) |
|
||||
| points | int | default 0 | 可用积分 |
|
||||
| creator_income_total | int | default 0 | 创作分成累计 |
|
||||
| banned | bool | | 禁言 |
|
||||
| banned_at | *time | | |
|
||||
| last_login_at | *time | json 隐藏 | |
|
||||
| last_login_ip | string(45) | json 隐藏 | |
|
||||
| last_access_at | *time | json 隐藏 | 带鉴权访问 |
|
||||
| created_at / updated_at | time | | |
|
||||
| deleted_at | soft | | |
|
||||
|
||||
**非落库展示字段**:`level`(由 Exp 推导)、`badges`(附加)。
|
||||
|
||||
视图结构:`UserPublic` / `UserSelf` / `UserAdmin`(见 [`model/user_view.go`](../../models/user_view.go))。
|
||||
|
||||
### 2.2 boards
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| id, name(64), description(512) | |
|
||||
| icon(64), color_index(default -1) | -1=按 id 自动取色 |
|
||||
| sort_order | 升序 |
|
||||
| created_at, updated_at, deleted_at | |
|
||||
|
||||
### 2.3 posts
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| id, board_id, user_id | FK 索引 |
|
||||
| title(256), content(text) | HTML 正文 |
|
||||
| content_plain(text) | 纯文本搜索索引,`json:"-"` |
|
||||
| tags(256) | 逗号或空格分隔标签串 |
|
||||
| post_type | `normal`\|`question`\|`poll`\|`bounty`\|`lottery` |
|
||||
| question_resolved | 仅问答 |
|
||||
| bounty_points, bounty_status, bounty_comment_id | 悬赏 |
|
||||
| lottery_winner_count, lottery_status | 抽奖帖 |
|
||||
| pinned | 全局置顶 |
|
||||
| board_pinned | 版内置顶 |
|
||||
| featured | 精华 |
|
||||
| edit_locked | 禁止编辑 |
|
||||
| comments_locked | 禁止新评论 |
|
||||
| status | `pending`\|`published`\|`rejected` |
|
||||
| like_count, view_count | |
|
||||
| timestamps + soft delete | |
|
||||
|
||||
关联:Board, User, Comments。
|
||||
|
||||
### 2.4 post_revisions
|
||||
|
||||
每次修改前保存旧版:`post_id`, `editor_id`, `title`, `content`, `tags`, `created_at`。
|
||||
|
||||
### 2.5 comments
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| post_id, user_id | user_id=0 表示游客 |
|
||||
| floor | 楼层号 |
|
||||
| content | HTML/富文本 |
|
||||
| reply_to | *uint 回复目标评论 |
|
||||
| guest_nick / guest_email / guest_url | 游客信息 |
|
||||
| is_private | 私密评论 |
|
||||
| status | pending\|published\|rejected |
|
||||
| like_count | |
|
||||
| soft delete | |
|
||||
|
||||
**非落库**:`reply_target`, `thread_parent_id`, `content_hidden`, `liked`。
|
||||
|
||||
### 2.6 comment_revisions
|
||||
|
||||
`comment_id`, `editor_id`, `content`, `created_at`(管理员可查)。
|
||||
|
||||
### 2.7 post_likes / comment_likes / post_favorites
|
||||
|
||||
唯一索引:(post_id|comment_id, user_id)。收藏带 Post 关联。
|
||||
|
||||
### 2.8 private_messages
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| from_user_id | 0=系统 |
|
||||
| to_user_id | |
|
||||
| subject(256), content(text) | |
|
||||
| kind | 见枚举 |
|
||||
| related_post_id, related_report_id | 可选 |
|
||||
| is_read | |
|
||||
| created_at | |
|
||||
|
||||
### 2.9 post_reports
|
||||
|
||||
帖或评举报:`post_id` 必填;`comment_id` 有值则为评论举报。
|
||||
`reason`, `detail`, `status`, `handler_id`, `handle_note`, `handled_at`。
|
||||
|
||||
### 2.10 friend_link_applies
|
||||
|
||||
申请字段:name, url, description, logo, reciprocal_page_url, link_on_homepage,
|
||||
reciprocal_verified / check_note / checked_at, status, review_note, reviewed_at + soft delete。
|
||||
|
||||
### 2.11 media
|
||||
|
||||
上传索引:`category`=`avatars`\|`posts`\|`site`;`name`, `url`(unique), `size`, `content_type`, `storage_type`=`local`\|`s3`, `user_id`。
|
||||
|
||||
### 2.12 point_ledgers
|
||||
|
||||
`user_id`, `delta`, `balance`(变动后), `reason`, `ref_type`, `ref_id`, `note`, `created_at`。
|
||||
|
||||
### 2.13 check_ins
|
||||
|
||||
唯一 `(user_id, day)`,day=`YYYY-MM-DD`;`points`, `streak`。
|
||||
|
||||
### 2.14 lottery_draws
|
||||
|
||||
每日抽奖唯一 `(user_id, day)`;`points` 可为 0。
|
||||
|
||||
### 2.15 post_content_unlocks
|
||||
|
||||
唯一 `(user_id, post_id, block_key)`;`cost`。
|
||||
|
||||
### 2.16 site_pages
|
||||
|
||||
`title`, `slug`(unique), `content`, `published`, `sort_order`, `show_in_footer`, `show_in_nav` + soft delete。
|
||||
|
||||
### 2.17 polls / poll_options / poll_votes
|
||||
|
||||
- Poll:`post_id` unique;`multi`, `max_choices`, `closed`, `ends_at`
|
||||
- Option:`post_id`, `text`(64), `sort_order`, `vote_count`
|
||||
- Vote:唯一 `(post_id, option_id, user_id)`(多选时多行)
|
||||
|
||||
### 2.18 post_lottery_winners
|
||||
|
||||
`post_id`, `user_id`, `comment_id`, `created_at`。
|
||||
|
||||
### 2.19 badge_defs / user_badges
|
||||
|
||||
BadgeDef:`code` unique, `name`, `description`, `icon`, `kind`=`auto`\|`limited`, `metric`, `threshold`, `sort_order`, `enabled`。
|
||||
UserBadge:唯一 `(user_id, badge_id)`;`awarded_at`, `awarded_by`(0=系统)。
|
||||
|
||||
### 2.20 forum_settings
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| key | PK string(64) |
|
||||
| value | string(2048) |
|
||||
|
||||
### 2.21 oauth_clients / oauth_auth_codes
|
||||
|
||||
Client:`client_id` unique, `client_secret_hash`, `name`, `redirect_uris`(可多行), `enabled`。
|
||||
AuthCode:一次性码 + PKCE 字段 + `expires_at` + `used`。
|
||||
|
||||
### 2.22 gitea_repos
|
||||
|
||||
同步缓存:`gitea_id` unique, owner/name/full_name, description, html_url, language, stars/forks, private, updated_at_remote, forum_user_id, synced_at。
|
||||
|
||||
---
|
||||
|
||||
## 3. 枚举全集
|
||||
|
||||
### 3.1 角色 Role
|
||||
|
||||
`user` | `admin`
|
||||
|
||||
### 3.2 内容状态 ContentStatus
|
||||
|
||||
`pending` | `published` | `rejected`
|
||||
|
||||
### 3.3 帖类型 PostType
|
||||
|
||||
`normal` | `question` | `poll` | `bounty` | `lottery`
|
||||
|
||||
### 3.4 悬赏 BountyStatus
|
||||
|
||||
`open` | `awarded` | `refunded`(空串视为非悬赏)
|
||||
|
||||
### 3.5 帖内抽奖 PostLotteryStatus
|
||||
|
||||
`open` | `drawn`
|
||||
|
||||
### 3.6 私信 kind
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| user | 用户互发 |
|
||||
| system | 系统通知 |
|
||||
| reject | 帖/评被拒 |
|
||||
| report_result | 举报处理结果 |
|
||||
| reply | 被回复 |
|
||||
| mention | 被 @ |
|
||||
| moderation | 待审提醒管理员 |
|
||||
|
||||
### 3.7 举报
|
||||
|
||||
Status:`pending` | `resolved` | `dismissed`
|
||||
Reason:`spam` | `abuse` | `illegal` | `irrelevant` | `other`
|
||||
|
||||
### 3.8 友链申请
|
||||
|
||||
`pending` | `approved` | `rejected`
|
||||
|
||||
### 3.9 积分 reason
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| check_in | 签到 |
|
||||
| lottery | 每日抽奖 |
|
||||
| unlock_spend | 解锁消费 |
|
||||
| creator_income | 创作分成 |
|
||||
| admin_adjust | 管理员调账 |
|
||||
| bounty_escrow | 悬赏托管 |
|
||||
| bounty_award | 悬赏发放 |
|
||||
| bounty_refund | 悬赏退回 |
|
||||
|
||||
### 3.10 徽章
|
||||
|
||||
Kind:`auto` | `limited`
|
||||
Metric:`tenure_days` | `likes_received` | `creator_income`
|
||||
|
||||
---
|
||||
|
||||
## 4. 等级(Exp → Level)
|
||||
|
||||
源:[`model/level.go`](../../models/level.go)
|
||||
|
||||
| Level | 最低 Exp |
|
||||
|-------|----------|
|
||||
| 1 | 0 |
|
||||
| 2 | 20 |
|
||||
| 3 | 50 |
|
||||
| 4 | 100 |
|
||||
| 5 | 200 |
|
||||
| 6 | 400 |
|
||||
| 7 | 800 |
|
||||
| 8 | 1500 |
|
||||
| 9 | 3000 |
|
||||
| 10 | 5000 |
|
||||
|
||||
管理员设等级时,应把 Exp 调到该等级门槛(见后台 API)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 内置自动徽章(seed)
|
||||
|
||||
源:[`model/db.go`](../../models/db.go) `seedDefaultBadges`
|
||||
|
||||
| code | 名称 | metric | threshold |
|
||||
|------|------|--------|-----------|
|
||||
| tenure_30 | 初来乍到 | tenure_days | 30 |
|
||||
| tenure_365 | 资深居民 | tenure_days | 365 |
|
||||
| likes_10 | 小有人气 | likes_received | 10 |
|
||||
| likes_100 | 人气作者 | likes_received | 100 |
|
||||
| likes_1000 | 人气巨星 | likes_received | 1000 |
|
||||
| income_100 | 小有进账 | creator_income | 100 |
|
||||
| income_1000 | 创作达人 | creator_income | 1000 |
|
||||
|
||||
已存在同 `code` 则跳过插入。
|
||||
|
||||
---
|
||||
|
||||
## 6. forum_settings 键与默认值
|
||||
|
||||
源:[`service/settings.go`](../../services/settings.go)、[`service/permalink.go`](../../services/permalink.go)
|
||||
|
||||
### 6.1 论坛限制
|
||||
|
||||
| Key | 默认 | 说明 |
|
||||
|-----|------|------|
|
||||
| post_edit_window_hours | 24 | 0 可表示特殊策略,以实现为准 |
|
||||
| comment_edit_window_minutes | 3 | |
|
||||
| rate_limit_post | 10 | 窗口内次数 |
|
||||
| rate_limit_comment | 10 | |
|
||||
| rate_limit_register | 10 | |
|
||||
| rate_limit_login | 10 | |
|
||||
| rate_limit_window_sec | 60 | |
|
||||
| post_title_max | 128 | |
|
||||
| post_tags_max | 256 | |
|
||||
| post_content_max | 50000 | |
|
||||
| comment_max | 5000 | |
|
||||
| search_keyword_min | 1 | |
|
||||
| search_keyword_max | 50 | |
|
||||
| page_size_default | 30 | API 硬上限 100 |
|
||||
| password_min_len | 6 | |
|
||||
| avatar_max_mb | 2 | |
|
||||
| signature_max | 200 | |
|
||||
| open_posts_in_new_tab | 1 | |
|
||||
| open_content_links_in_new_tab | 1 | |
|
||||
|
||||
### 6.2 Feed / 侧栏 / 友链展示
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| feed_list_style | `title`(另有 `excerpt` / `thumbnail`) |
|
||||
| aside_show_tag_cloud | 0 |
|
||||
| aside_show_recent_comments | 0 |
|
||||
| aside_show_friend_links | 1 |
|
||||
| aside_widgets | JSON 数组,见下 |
|
||||
| nav_show_friend_links | 1 |
|
||||
| footer_show_friend_links | 1 |
|
||||
| friend_link_reciprocal_check | 0 |
|
||||
| permalink_enabled | 0 |
|
||||
| permalink_ext | `html` |
|
||||
|
||||
默认 `aside_widgets`:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id":"tag_cloud","enabled":false},
|
||||
{"id":"recent_comments","enabled":false},
|
||||
{"id":"friend_links","enabled":true}
|
||||
]
|
||||
```
|
||||
|
||||
合法 widget id:`tag_cloud` | `recent_comments` | `recent_users` | `friend_links`。
|
||||
|
||||
### 6.3 SMTP
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| smtp_enabled | 0 |
|
||||
| smtp_host | |
|
||||
| smtp_port | 465 |
|
||||
| smtp_username / smtp_password | |
|
||||
| smtp_from | |
|
||||
| smtp_from_name | 姜十三论坛 |
|
||||
| smtp_encryption | `ssl`(另有 `none` / `starttls`) |
|
||||
|
||||
### 6.4 OIDC
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| oidc_enabled | 0 |
|
||||
| oidc_root_url | |
|
||||
| oidc_group_claim | groups |
|
||||
| oidc_admin_group | gitea-admin |
|
||||
| oidc_user_group | gitea-users |
|
||||
| oidc_rsa_private_pem | (空)启用 OIDC 时懒生成并写入;未启用不落盘 `.oidc_rsa.pem` |
|
||||
|
||||
### 6.4b 敏感词
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| filter_words | 默认词表文本;启动时从旧 `filter_words.txt` 导入(若键为空) |
|
||||
|
||||
### 6.5 Gitea 同步(**后置**,键保留兼容)
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| gitea_sync_enabled | 0 |
|
||||
| gitea_base_url | |
|
||||
| gitea_token | |
|
||||
| gitea_sync_interval_min | 60 |
|
||||
|
||||
本迭代不启同步任务;见 [02-features.md](02-features.md) §K。
|
||||
|
||||
### 6.6 存储
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| storage_type | local |
|
||||
| storage_endpoint / region / bucket | region 默认 us-east-1 |
|
||||
| storage_access_key / storage_secret_key | |
|
||||
| storage_public_base_url / storage_prefix | |
|
||||
| storage_force_path_style | 1 |
|
||||
| storage_image_delivery | webp(或 original) |
|
||||
|
||||
### 6.7 站点品牌
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| site_name | 姜十三论坛 |
|
||||
| site_slogan | 拾三一隅,自在交流 |
|
||||
| site_description / site_keywords | 空 |
|
||||
| site_logo_mark | 姜 |
|
||||
| site_logo / site_favicon / site_og_image | 空 |
|
||||
| site_icp_beian | 空 |
|
||||
| site_icp_beian_url | https://beian.miit.gov.cn/ |
|
||||
| site_friend_links | `[]` JSON,最多 20 条 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 升级兼容补丁(现网 InitDB)
|
||||
|
||||
[`model/db.go`](../../models/db.go) 在 AutoMigrate 后:
|
||||
|
||||
- 空 `status` 的帖/评 → `published`
|
||||
- 空 `post_type` → `normal`
|
||||
- Exp=0 用户按存量内容粗算经验:`posts*10 + comments*2 + like_sum`
|
||||
|
||||
新站若从空库开始可忽略;若迁移旧库需保留等价 backfill。
|
||||
|
||||
---
|
||||
|
||||
## 8. 内容门控在库中的形态
|
||||
|
||||
**无独立表**存放门控块;存在 `posts.content` HTML 中,例如:
|
||||
|
||||
```html
|
||||
<members-only>...</members-only>
|
||||
<reply-only>...</reply-only>
|
||||
<points-only data-cost="10">...</points-only>
|
||||
```
|
||||
|
||||
积分解锁 `block_key` = `sha256(innerHTML)[:16]`(hex),见 [`service/unlock.go`](../../services/unlock.go)。
|
||||
391
docs/rebuild-spec/04-api.md
Normal file
391
docs/rebuild-spec/04-api.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# 04 · HTTP API 合约
|
||||
|
||||
> **读者**:机器客户端 / 集成方;浏览器 UI **不**使用本文件作为主路径
|
||||
> **前置**:[03-data-model.md](03-data-model.md)、[08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
|
||||
> **源码**:[`routers/setup.go`](../../routers/setup.go)、[`modules/auth/auth.go`](../../modules/auth/auth.go)
|
||||
|
||||
本分支(`rebuild/gitea-ssr`)浏览器走 **`routers/web` 模板 + 表单**。
|
||||
**当前进程仅注册** health / OIDC / robots / sitemap / media thumb 等机器相关路由;论坛 CRUD 的 `/api/*` **handler 已从本分支删除**(对照实现见 `main`,合约形状见下文历史章节)。
|
||||
|
||||
`main` 分支 SPA 仍完整实现下表;对照请 checkout `main`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 本分支已注册的机器入口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 探活 |
|
||||
| GET | `/robots.txt` | 抓取规则 |
|
||||
| GET | `/sitemap.xml` | 站点地图 |
|
||||
| GET/POST | `/oauth/*`、`/.well-known/openid-configuration` | OIDC Provider |
|
||||
| GET | `/media/thumb/*`、`/uploads/*` | 媒体 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 历史 JSON 合约(main / 对照,本分支默认不挂载)
|
||||
|
||||
以下章节描述原 SPA 使用的 `/api` 形状,便于迁移业务语义;**实现 UI 时请用 web 表单,勿恢复双轨。**
|
||||
|
||||
### 通用约定(历史)
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| Base | 同源;`credentials: 'same-origin'` |
|
||||
| 成功 | HTTP 2xx + JSON body |
|
||||
| 失败 | 非 2xx + `{ "error": "..." }` |
|
||||
| 鉴权 | Cookie `jiang13_session`(opaque);机器 OIDC 用 Bearer |
|
||||
|
||||
### 分页形态差异
|
||||
|
||||
| 场景 | 典型字段 |
|
||||
|------|----------|
|
||||
| 前台帖列表 | `posts`, `total`, `page`, `size`, `has_more` |
|
||||
| 后台多数列表 | `total`, `page`, `total_pages` + 实体数组 |
|
||||
| 私信会话消息 | `before` 游标式 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 基础设施 / SEO / 静态(节选,仍有效)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/health` | 无 | `{ "status": "ok" }` |
|
||||
| GET | `/robots.txt` | 无 | 文本 |
|
||||
| GET | `/sitemap.xml` | 无 | XML |
|
||||
| GET | `/media/thumb/*filepath` | 无 | 缩略图 / WebP 等 |
|
||||
| GET | `/uploads/*` | 无 | 静态上传文件 |
|
||||
|
||||
---
|
||||
|
||||
## 3. OIDC Provider
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/.well-known/openid-configuration` | 无 | Discovery |
|
||||
| GET | `/oauth/jwks` | 无 | JWKS |
|
||||
| GET | `/oauth/authorize` | OptionalAuth | 授权码流程 |
|
||||
| POST | `/oauth/token` | 无(客户端凭证) | 换 token |
|
||||
| GET/POST | `/oauth/userinfo` | Bearer | 用户信息 |
|
||||
| GET/POST | `/oauth/logout` | 视实现 | 登出 |
|
||||
|
||||
细节以 [`service/oidc.go`](../../services/oidc.go) / [`handler/oidc.go`](../../routers/api/oidc.go) 为准。
|
||||
|
||||
---
|
||||
|
||||
## 4. 公开 API(`/api` + OptionalAuth)
|
||||
|
||||
### 4.1 会话与站点
|
||||
|
||||
| 方法 | 路径 | 响应要点 |
|
||||
|------|------|----------|
|
||||
| GET | `/api/me` | `{ user: UserSelf \| null }` |
|
||||
| GET | `/api/stats` | `{ users, posts, boards, comments }` |
|
||||
| GET | `/api/forum-limits` | `ForumLimitsPublic`(无限流内部字段) |
|
||||
| GET | `/api/site-branding` | `SiteBranding`(可含 `site_url`) |
|
||||
| GET | `/api/captcha` | `{ id, image }` image 为 data URL 或 base64 |
|
||||
| GET | `/api/register/config` | 见下 |
|
||||
|
||||
**RegisterConfig**
|
||||
|
||||
```json
|
||||
{
|
||||
"is_first_user": true,
|
||||
"mail_ready": false,
|
||||
"require_email_code": false,
|
||||
"register_open": true,
|
||||
"email_code_len": 6
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 认证(限流)
|
||||
|
||||
| 方法 | 路径 | Body | 响应 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/register` | Form/JSON: username, password, nickname, email, captcha_id, captcha, email_code? | 成功后通常种 cookie |
|
||||
| POST | `/api/login` | Form: username, password | 种 cookie |
|
||||
| POST | `/api/register/email-code` | JSON `{ email }` | `{ message }` |
|
||||
| POST | `/api/password-reset/email-code` | JSON `{ email }` | `{ message }` |
|
||||
| POST | `/api/password-reset` | JSON `{ email, email_code, new_password }` | `{ message }` |
|
||||
|
||||
### 4.3 内容只读
|
||||
|
||||
| 方法 | 路径 | Query / 说明 |
|
||||
|------|------|----------------|
|
||||
| GET | `/api/boards` | `{ boards: Board[] }` |
|
||||
| GET | `/api/posts` | 见下表 |
|
||||
| GET | `/api/posts/hot` | 热门列表 |
|
||||
| GET | `/api/posts/:id` | `skip_view=1` 可选;返回 `PostDetailResponse` |
|
||||
| GET | `/api/posts/:id/comments` | `my_ids` 可选(逗号分隔,便于标自己的楼) |
|
||||
| GET | `/api/tags` | `limit` 默认 40 → `{ tags: [{name,count}] }` |
|
||||
| GET | `/api/comments/recent` | `{ comments: RecentComment[] }` |
|
||||
| GET | `/api/users/search` | `q`, `limit` |
|
||||
| GET | `/api/users/recent` | `{ users: RecentUser[] }` |
|
||||
| GET | `/api/users/:id` | `{ user: UserPublic, stats }` |
|
||||
| GET | `/api/pages` | 已发布摘要列表 |
|
||||
| GET | `/api/pages/:slug` | 单页详情 |
|
||||
| GET | `/api/projects` | `page`, `limit`, `q` |
|
||||
|
||||
**GET `/api/posts` Query**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| page | 默认 1 |
|
||||
| size | 默认 page_size_default,上限 100 |
|
||||
| board_id | 0 或不传=全部 |
|
||||
| user_id | 某用户的帖 |
|
||||
| keyword | 搜索词 |
|
||||
| tag | 标签 |
|
||||
| author | 用户名优先,否则昵称精确匹配 |
|
||||
| title_only | `1`/`true` 仅搜标题 |
|
||||
| sort | `latest` \| `reply` \| `hot` |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"posts": [ /* PostItem */ ],
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"size": 30,
|
||||
"has_more": true
|
||||
}
|
||||
```
|
||||
|
||||
**PostDetailResponse 要点**
|
||||
|
||||
```json
|
||||
{
|
||||
"post": { /* PostItem + content */ },
|
||||
"comment_count": 0,
|
||||
"liked": false,
|
||||
"favorited": false,
|
||||
"has_replied": false,
|
||||
"can_edit": true,
|
||||
"edit_block_reason": "",
|
||||
"is_edited": false,
|
||||
"post_edit_window_hours": 24,
|
||||
"poll": { /* PollView 可选 */ },
|
||||
"lottery": { /* PostLotteryView 可选 */ },
|
||||
"bounty_can_refund": false,
|
||||
"bounty_refund_block_reason": "",
|
||||
"bounty_eligible_reply_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 游客可写评论
|
||||
|
||||
| 方法 | 路径 | 限流 | Body |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/posts/:id/comments` | comment | Form: content, reply_to?, is_private?, 以及游客字段(以实现为准) |
|
||||
|
||||
登录用户发评也走此路径(RequireAuth 组外公开组已注册该路由)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 需登录 API(`/api` + RequireAuth)
|
||||
|
||||
### 5.1 会话与资料
|
||||
|
||||
| 方法 | 路径 | Body | 响应 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/logout` | | 清 cookie |
|
||||
| GET | `/api/favorites` | | `{ favorites, total }` |
|
||||
| GET | `/api/profile/stats` | | `{ stats: UserActivityStats }` |
|
||||
| POST | `/api/profile/nickname` | Form nickname | |
|
||||
| POST | `/api/profile/signature` | Form signature | `{ message, user }` |
|
||||
| POST | `/api/profile/password` | Form old_password, new_password | |
|
||||
| POST | `/api/profile/avatar` | Form avatar=file | `{ avatar }` |
|
||||
| POST | `/api/uploads/image` | Form image=file | `{ url }` |
|
||||
|
||||
### 5.2 帖子写操作
|
||||
|
||||
| 方法 | 路径 | Body | 响应 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/posts` | Form: board_id, title, content, tags?, post_type?, poll_options?, bounty_points?, lottery_winner_count? | `{ message, post_id, status }` |
|
||||
| PUT | `/api/posts/:id` | Form: title, content, tags?, board_id?, post_type? | `{ message }` |
|
||||
| DELETE | `/api/posts/:id` | | 软删 |
|
||||
| GET | `/api/posts/:id/revisions` | | `{ revisions }` |
|
||||
| GET | `/api/posts/:id/revisions/:revId` | | `{ revision }` |
|
||||
| POST | `/api/posts/:id/like` | | `{ liked, like_count }` |
|
||||
| POST | `/api/posts/:id/favorite` | | `{ favorited }` |
|
||||
| POST | `/api/posts/:id/resolve` | Form resolved=`1`\|`0` | `{ question_resolved }` |
|
||||
| POST | `/api/posts/:id/poll/vote` | JSON `{ option_ids: number[] }` | `{ poll }` |
|
||||
| POST | `/api/posts/:id/poll/close` | | `{ poll }` |
|
||||
| POST | `/api/posts/:id/bounty/award` | Form comment_id | |
|
||||
| POST | `/api/posts/:id/bounty/refund` | | |
|
||||
| POST | `/api/posts/:id/lottery/draw` | | `{ lottery }` |
|
||||
| POST | `/api/posts/:id/report` | JSON `{ reason, detail? }` | `{ report }` |
|
||||
| POST | `/api/posts/:id/unlock` | JSON `{ block_key }` | 见下 |
|
||||
|
||||
**poll_options JSON 示例**(Form 字段字符串)
|
||||
|
||||
```json
|
||||
{
|
||||
"multi": false,
|
||||
"max_choices": 1,
|
||||
"ends_at": "2026-09-01T12:00:00Z",
|
||||
"options": [{ "text": "选项A" }, { "text": "选项B" }]
|
||||
}
|
||||
```
|
||||
|
||||
**unlock 响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "...",
|
||||
"unlock": {
|
||||
"block_key": "abcdef0123456789",
|
||||
"cost": 10,
|
||||
"points_balance": 90,
|
||||
"inner_html": "<p>...</p>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 评论写操作
|
||||
|
||||
| 方法 | 路径 | Body |
|
||||
|------|------|------|
|
||||
| POST | `/api/comments/:id/like` | → `{ liked, like_count }` |
|
||||
| POST | `/api/comments/:id/report` | JSON `{ reason, detail? }` |
|
||||
| PUT | `/api/comments/:id` | Form content |
|
||||
| DELETE | `/api/comments/:id` | |
|
||||
|
||||
### 5.4 私信
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/messages/unread-count` | `{ count, dm_count?, notify_count? }` |
|
||||
| GET | `/api/messages/notifications` | page, size, kind |
|
||||
| POST | `/api/messages/notifications/read` | |
|
||||
| GET | `/api/messages/conversations` | page, size |
|
||||
| GET | `/api/messages/conversations/:peerId` | size, before;peerId=0 为系统 |
|
||||
| POST | `/api/messages/conversations/:peerId/read` | |
|
||||
| POST | `/api/messages` | JSON `{ to_user_id, subject?, content }` |
|
||||
| POST | `/api/messages/read-all` | |
|
||||
|
||||
### 5.5 经济
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/me/points` | page;含 ledger、check_in、lottery |
|
||||
| GET/POST | `/api/me/check-in` | 状态 / 执行签到 |
|
||||
| GET/POST | `/api/me/lottery` | 状态 / 抽奖 |
|
||||
|
||||
### 5.6 友链申请
|
||||
|
||||
| 方法 | 路径 | Body |
|
||||
|------|------|------|
|
||||
| POST | `/api/friend-links/apply` | JSON name, url, logo, link_on_homepage, reciprocal_page_url? |
|
||||
| POST | `/api/friend-links/logo` | Form logo=file → `{ url }` |
|
||||
| GET | `/api/friend-links/my-applies` | |
|
||||
| PUT | `/api/friend-links/applies/:id` | 同申请字段 |
|
||||
| DELETE | `/api/friend-links/applies/:id` | 取消 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 管理 API(`/api/admin` + Auth + Admin)
|
||||
|
||||
### 6.1 仪表盘与设置
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/dashboard` | AdminDashboard |
|
||||
| GET | `/settings` | AdminSettings 聚合 |
|
||||
| PUT | `/settings/forum` | ForumLimits |
|
||||
| PUT | `/settings/mail` | MailConfig |
|
||||
| POST | `/settings/mail/test` | `{ to }` |
|
||||
| PUT | `/settings/oidc` | OIDCConfig |
|
||||
| PUT | `/settings/gitea` | **后置**(501) |
|
||||
| POST | `/settings/gitea/sync` | **后置**(501) |
|
||||
| PUT | `/settings/storage` | StorageConfig |
|
||||
| PUT | `/settings/branding` | SiteBranding |
|
||||
| POST | `/settings/branding/upload` | Form kind=`logo`\|`favicon`\|`og_image`, file |
|
||||
| POST | `/settings/branding/clear` | JSON `{ kind }` |
|
||||
| GET/PUT | `/settings/filter-words` | GET 读;PUT `{ content }` |
|
||||
|
||||
(上表路径均相对于 `/api/admin`。)
|
||||
|
||||
### 6.2 OAuth 客户端
|
||||
|
||||
| 方法 | 路径 |
|
||||
|------|------|
|
||||
| GET/POST | `/oauth/clients` |
|
||||
| PUT/DELETE | `/oauth/clients/:id` |
|
||||
|
||||
创建/更新 body:`name`, `redirect_uris`, `client_id?`, `enabled?`, `client_secret?`, `rotate_secret?`。
|
||||
|
||||
### 6.3 板块 / 单页 / 友链
|
||||
|
||||
| 方法 | 路径 |
|
||||
|------|------|
|
||||
| POST/PUT/DELETE | `/boards`, `/boards/:id` |
|
||||
| GET/POST | `/pages` |
|
||||
| GET/PUT/DELETE | `/pages/:id` |
|
||||
| PUT | `/pages/:id/published` → `{ published }` |
|
||||
| GET | `/friend-link-applies` |
|
||||
| PUT | `/friend-link-settings` |
|
||||
| POST | `/friend-link-applies/:id/approve` \| `reject` \| `recheck` |
|
||||
|
||||
### 6.4 帖子审核与运营
|
||||
|
||||
| 方法 | 路径 | Body |
|
||||
|------|------|------|
|
||||
| GET | `/posts` | page, keyword, status |
|
||||
| GET | `/posts/trash` | |
|
||||
| POST | `/posts/:id/pin` | `{ pinned }` |
|
||||
| POST | `/posts/:id/board-pin` | `{ board_pinned }` |
|
||||
| POST | `/posts/:id/feature` | `{ featured }` |
|
||||
| POST | `/posts/:id/lock` | `{ locked }` → edit_locked |
|
||||
| POST | `/posts/:id/comments-lock` | `{ locked }` |
|
||||
| POST | `/posts/:id/approve` | |
|
||||
| POST | `/posts/:id/reject` | `{ reason }` |
|
||||
| POST | `/posts/:id/restore` | |
|
||||
| DELETE | `/posts/:id/purge` | 硬删 |
|
||||
| DELETE | `/posts/:id` | 软删 |
|
||||
|
||||
### 6.5 评论 / 举报 / 用户 / 徽章 / 媒体 / 备份
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/comments`, `/comments/trash` | |
|
||||
| GET | `/comments/:id/revisions` | |
|
||||
| POST | `/comments/:id/approve` \| `reject` \| `restore` | reject 可带 reason |
|
||||
| DELETE | `/comments/:id`, `/comments/:id/purge` | |
|
||||
| GET | `/reports` | page, status |
|
||||
| POST | `/reports/:id/handle` | `{ action, handle_note?, reject_reason? }`;action=`dismiss`\|`resolve`\|`reject_post`\|`reject_comment` |
|
||||
| GET | `/users` | page, keyword, filter |
|
||||
| POST | `/users/:id/ban` | `{ banned }` |
|
||||
| POST | `/users/:id/verify` | `{ verified }` |
|
||||
| POST | `/users/:id/level` | `{ level }` |
|
||||
| POST | `/users/:id/points` | `{ delta, note? }` |
|
||||
| POST | `/users/:id/badges` | `{ badge_id, revoke? }` |
|
||||
| GET/POST | `/badges` | 列表 / upsert |
|
||||
| GET | `/media` | category, page, size, q |
|
||||
| POST | `/media/delete` | `{ urls: string[] }` |
|
||||
| POST | `/backup` | `{ filename, download }` |
|
||||
| GET | `/backup/download/:name` | 文件下载 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心类型速查(与前端对齐)
|
||||
|
||||
详见 [`frontend/src/api/types.ts`]((仅 main)frontend/src/api/types.ts)。实现时至少对齐:
|
||||
|
||||
- `User` / `UserPublic` / `UserActivityStats`
|
||||
- `Board` / `PostItem` / `PostDetailResponse` / `Comment`
|
||||
- `ForumLimits` / `ForumLimitsPublic` / `SiteBranding`
|
||||
- `PollView` / `PostLotteryView`
|
||||
- `PrivateMessage` / `MessageConversation`
|
||||
- `PostReport` / `FriendLinkApply` / `BadgeDef` / `PointLedger`
|
||||
- `CheckInStatus` / `LotteryStatus`
|
||||
- `AdminDashboard` / `AdminSettings` / `StorageConfig` / `MailConfig` / `OIDCConfig`
|
||||
|
||||
---
|
||||
|
||||
## 8. 鉴权错误语义(现网)
|
||||
|
||||
中间件对未登录 / 过期 / 禁言返回 JSON error(并可能清 cookie)。前端统一 `throw new Error(data.error)`。新站应保持可区分的错误文案或错误码,避免前端无法提示。
|
||||
|
||||
源:[`middleware/auth.go`](../../modules/auth/auth.go)。
|
||||
230
docs/rebuild-spec/05-business-rules.md
Normal file
230
docs/rebuild-spec/05-business-rules.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 05 · 业务规则与状态机
|
||||
|
||||
> **读者**:实现领域逻辑的 AI(最易「看起来像但算错」)
|
||||
> **前置**:[03-data-model.md](03-data-model.md)、[04-api.md](04-api.md)
|
||||
> **源码**:[`service/`](../../services/)、[`model/models.go`](../../models/models.go)
|
||||
|
||||
---
|
||||
|
||||
## 1. 注册与引导
|
||||
|
||||
源:[`routers/web/auth.go`](../../routers/web/auth.go)、[`services/auth.go`](../../services/auth.go)
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 管理员 | **仅** `/install` 向导创建(不再「首注册变管理员」) |
|
||||
| 开放注册 | 安装完成后开放;不依赖 SMTP |
|
||||
| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时可无码注册 |
|
||||
| 会话 | Cookie `jiang13_session` = opaque id;表 `sessions`;HttpOnly + SameSite=Lax;HTTPS 时 Secure;TTL 7 天滑动续期 |
|
||||
| 吊销 | 登出删当前 session;禁言 / 重置密码删该用户全部 session |
|
||||
| HMAC 密钥 | `{DATA}/.jwt_secret` 仅 CSRF 等 HMAC(**不是**浏览器登录 JWT) |
|
||||
|
||||
密码:bcrypt;最小长度来自 `password_min_len`(默认 6)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 内容审核
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: 普通用户发帖或评论
|
||||
[*] --> published: admin或verified免审
|
||||
pending --> published: 管理员通过
|
||||
pending --> rejected: 管理员拒绝
|
||||
published --> pending: 非免审用户编辑后可再进审
|
||||
```
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 免审 | `role=admin` 或 `verified=true`(`SkipsModeration`) |
|
||||
| 可见性 | `pending`/`rejected`:**仅作者与管理员**可见(对外表现为 404) |
|
||||
| 列表 | 公开 Feed 只出 `published` |
|
||||
| 拒绝 | 可写原因;通知作者(站内信 kind=`reject`,可选邮件) |
|
||||
| 待审提醒 | 通知管理员(kind=`moderation`) |
|
||||
| 游客评论 | 通常直接或按实现进入审核;勿假设与登录用户完全相同 |
|
||||
|
||||
源:[`service/post.go`](../../services/post.go) `CanViewPost`、[`service/comment.go`](../../services/comment.go)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 编辑时限与锁
|
||||
|
||||
| 对象 | 规则 |
|
||||
|------|------|
|
||||
| 帖子 | 普通用户在 `post_edit_window_hours`(默认 24)内可编;超时不可(管理员除外) |
|
||||
| 帖子 | `edit_locked=true` 时非管理员不可编 |
|
||||
| 评论 | `comment_edit_window_minutes`(默认 3) |
|
||||
| 评论 | 帖子 `comments_locked` 时禁止新评论 |
|
||||
|
||||
详情接口返回 `can_edit`、`edit_block_reason`、`post_edit_window_hours`。
|
||||
|
||||
修订:每次成功修改前写入 `post_revisions` / `comment_revisions`(旧内容快照)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 经验 Exp(不可消费)
|
||||
|
||||
| 事件 | Delta |
|
||||
|------|------|
|
||||
| 发帖成功(公开路径) | +10 |
|
||||
| 评论成功 | +2 |
|
||||
| 帖子被点赞 | +1(作者) |
|
||||
|
||||
源:[`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 到门槛值。
|
||||
|
||||
---
|
||||
|
||||
## 5. 内容门控(红action)
|
||||
|
||||
源:[`service/content.go`](../../services/content.go)、[`handler/api.go`](../../routers/api/api.go) `APIPostDetail`、[`service/unlock.go`](../../services/unlock.go)
|
||||
|
||||
### 5.1 出口顺序(详情)
|
||||
|
||||
1. `SanitizePostHTML`(消毒)
|
||||
2. 若 **游客**:`RedactMembersOnlyHTML` + `RedactReplyOnlyHTML`
|
||||
3. 若 **已登录** 且非管理员、非作者、且未回复:仅 `RedactReplyOnlyHTML`
|
||||
4. 作者与管理员:members/reply 块不遮
|
||||
5. 积分块:按已解锁 key 集合 `RedactPointsOnlyHTML`;作者/管理员策略以实现为准(作者可免费解锁记录)
|
||||
|
||||
搜索 / SEO:`RedactGatedPostHTML` = members + reply + points 全遮。
|
||||
|
||||
### 5.2 积分解锁
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| block_key | `hex(sha256(innerHTML))[:16]` |
|
||||
| cost | `data-cost`,最小 1 |
|
||||
| 读者 | 扣 `cost`(reason=`unlock_spend`) |
|
||||
| 作者分成 | `cost * 70 / 100`(`CreatorSharePercent`),reason=`creator_income`;并累加 `creator_income_total` |
|
||||
| 平台留存 | 剩余 30%(无单独流水,表现为读者扣全额、作者只加 70%) |
|
||||
| 作者自己 | cost=0 记解锁,无分成 |
|
||||
| 已解锁 | 返回错误「已解锁」 |
|
||||
| 防刷 | 双方账号注册未满 **7 天** 且 **LastLoginIP 相同** → 拒绝整单 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 特殊帖类型
|
||||
|
||||
### 6.1 问答 question
|
||||
|
||||
- `question_resolved` 布尔;作者(或管理员)可切换
|
||||
- 列表/详情用图标展示已解决状态
|
||||
|
||||
### 6.2 投票 poll
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 选项数 | 2–10;单选项 ≤64 字 |
|
||||
| 多选 | `multi`;`max_choices` 钳制在 1..选项数 |
|
||||
| 截止 | `ends_at` 可选;过期或 `closed` 不可再投 |
|
||||
| 投票 | 每用户每帖;已投不可改(`ErrPollAlreadyVoted`) |
|
||||
| 结束 | 作者或管理员 `poll/close` |
|
||||
|
||||
### 6.3 悬赏 bounty
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> open: 发帖托管积分
|
||||
open --> awarded: 采纳他人已发布评论
|
||||
open --> refunded: 退款
|
||||
```
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 发帖 | 积分 ≥1;立即 `bounty_escrow` 扣作者积分 |
|
||||
| 采纳 | 不能采纳自己的回复;评论须 published;全额给评论作者 `bounty_award` |
|
||||
| 退款 | 状态 open;作者在**无他人已发布回复**时可退;**管理员始终可强制退** |
|
||||
| 退款后 | status=`refunded`,`bounty_points=0`,积分退回作者 |
|
||||
|
||||
### 6.4 抽奖帖 lottery
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 中奖人数 | 1–20 |
|
||||
| 参与者 | 已发布评论且 **非楼主**;按用户去重(保留最早评论) |
|
||||
| 开奖 | 作者/管理员;人数不足报错;随机抽取;写 `post_lottery_winners`;status=`drawn` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 签到与每日抽奖
|
||||
|
||||
源:[`service/points.go`](../../services/points.go)
|
||||
|
||||
### 签到
|
||||
|
||||
- 自然日 `YYYY-MM-DD`(服务器本地时区)每用户一次
|
||||
- 连续:若昨日报到则 streak+1,否则 1
|
||||
- 奖励:`5 + (streak-1)`,封顶 **15**,保底 5
|
||||
- 写 `check_ins` + `point_ledgers` reason=`check_in`
|
||||
|
||||
### 每日抽奖
|
||||
|
||||
- 每天一次;`cost=0`
|
||||
- 奖池权重:0×40, 2×30, 5×18, 10×10, 20×2
|
||||
- 中奖积分入账 reason=`lottery`
|
||||
|
||||
---
|
||||
|
||||
## 8. 评论特殊规则
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 楼层 | 按帖递增 |
|
||||
| 私密评论 | 仅作者、帖作者、管理员、以及相关可见链可见(见 `canViewPrivate`) |
|
||||
| 嵌套 | `thread_parent_id` 在父不可见时回挂祖先 |
|
||||
| @提及 | 解析后发 kind=`mention` |
|
||||
| 回复提醒 | kind=`reply`;可选 SMTP |
|
||||
| HasUserReplied | 已发布或审核中的评论算「已回复」(不含被拒),用于 reply-only |
|
||||
|
||||
---
|
||||
|
||||
## 9. 举报处理
|
||||
|
||||
管理员 `handle` action:
|
||||
|
||||
| action | 效果 |
|
||||
|--------|------|
|
||||
| dismiss | 驳回举报 |
|
||||
| resolve | 标记已处理(不必然删内容) |
|
||||
| reject_post | 拒绝/下架帖 |
|
||||
| reject_comment | 拒绝评论 |
|
||||
|
||||
结果通知举报人(kind=`report_result`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 友链
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 申请 | 登录用户;可上传 logo |
|
||||
| 回链检测 | 设置开启时抓取 reciprocal 页检查是否含本站链接 |
|
||||
| 通过 | 可写入品牌 `site_friend_links`(视实现:首页展示链接) |
|
||||
| 展示开关 | nav / footer / aside 独立 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 敏感词与限流
|
||||
|
||||
- 敏感词:`forum_settings.filter_words`(Admin SSR 可改并热更);旧文件可导入
|
||||
- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings(Admin 可改基础四项+窗口)
|
||||
|
||||
---
|
||||
|
||||
## 12. 徽章自动授予
|
||||
|
||||
定期或触发时检查 `BadgeDef`(kind=auto):tenure_days / likes_received / creator_income 达阈值则写入 `user_badges`。限定徽章仅管理员发放。
|
||||
|
||||
---
|
||||
|
||||
## 13. 置顶排序语义
|
||||
|
||||
| 标记 | 首页全部 Feed | 板块 Feed |
|
||||
|------|---------------|-----------|
|
||||
| `pinned` | 抬升 | 抬升 |
|
||||
| `board_pinned` | **不**抬升 | 抬升 |
|
||||
| `featured` | 标记展示,不一定改变排序 | 同左 |
|
||||
|
||||
具体 SQL/排序实现见 [`service/post.go`](../../services/post.go) ListItems。
|
||||
229
docs/rebuild-spec/06-pages-ux.md
Normal file
229
docs/rebuild-spec/06-pages-ux.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# 06 · 页面、交互与信息架构
|
||||
|
||||
> **读者**:实现前台 / 后台 UI 的 AI
|
||||
> **前置**:[02-features.md](02-features.md)
|
||||
> **源码**:[`frontend/src/App.tsx`]((仅 main)frontend/src/App.tsx)、[`frontend/src/pages/`]((仅 main)frontend/src/pages/)、[`frontend/src/components/`]((仅 main)frontend/src/components/)、[`frontend/src/layouts/`]((仅 main)frontend/src/layouts/)
|
||||
|
||||
视觉可重设(见 [10-design-system.md](10-design-system.md));**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。
|
||||
|
||||
---
|
||||
|
||||
## 1. 路由表
|
||||
|
||||
> **本分支(`rebuild/gitea-ssr`)**:浏览器 UI 走 `routers/web` 模板 + 表单,**不依赖**论坛 JSON `/api`。下表「SSR」列表示是否已迁。
|
||||
|
||||
### 1.1 认证
|
||||
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/login` | 登录 / 登出 | 已迁 |
|
||||
| `/register` | 注册(图形验证码;邮件就绪时要邮箱验证码) | 已迁 |
|
||||
| `/forgot-password` | 忘记密码 | 已迁 |
|
||||
|
||||
### 1.2 前台
|
||||
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/` | Feed | 已迁 |
|
||||
| `/board/:id` | 板块 Feed | 已迁 |
|
||||
| `/boards` | 板块索引 | 已迁 |
|
||||
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
||||
| `/compose` | 发帖(类型字段 + Markdown 工具栏/预览/图片/门控) | 已迁 |
|
||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
||||
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
||||
| `/user/:id` | 公开用户页 | 已迁 |
|
||||
| `/favorites` | 收藏 | 已迁 |
|
||||
| `/projects` | Gitea 码桶 | 后置 |
|
||||
| `/links` | 友链 | 已迁 |
|
||||
| `/messages` | 私信/通知会话列表 | 已迁 |
|
||||
| `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 |
|
||||
| `/page/:slug` | 站点单页 | 已迁 |
|
||||
| `*` | 404 / pending | 已迁 |
|
||||
|
||||
### 1.3 后台(Admin SSR,表单 + CSRF,不挂管理 JSON `/api`)
|
||||
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/admin` | 重定向 dashboard | 已迁 |
|
||||
| `/admin/dashboard` | 概览计数 | 已迁 |
|
||||
| `/admin/boards` | 板块 CRUD | 已迁 |
|
||||
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
|
||||
| `/admin/settings` | 品牌(含 Logo/Favicon/OG)+ 限流 + 内容限制 + 伪静态 + 敏感词 + SMTP + 备份 | 已迁 |
|
||||
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
|
||||
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
|
||||
| `/admin/reports` | 举报处理 | 已迁 |
|
||||
| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 |
|
||||
| `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 |
|
||||
| `/admin/media` | 媒体库分类浏览、删除、同步索引 | 已迁 |
|
||||
| `/admin/trash` | 帖回收站 | 已迁 |
|
||||
| `/admin/login` | 重定向前台登录 | 已迁 |
|
||||
|
||||
未迁(原 SPA):OIDC·Gitea·存储热切换 Admin 等。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三栏布局(桌面)
|
||||
|
||||
```text
|
||||
+------------------+---------------------------+------------------+
|
||||
| Sidebar | Feed / 主内容 | RightPanel |
|
||||
| - 全部/收藏/码桶 | - FeedHeader / SortBar | - 签到条(登录) |
|
||||
| - 板块列表 | - VirtualPostList | - 热门帖 |
|
||||
| - 站点页/友链 | - 或 PostDetail 等 | - aside_widgets |
|
||||
| - 管理入口 | | 标签云/评论/ |
|
||||
| | | 用户/友链 |
|
||||
+------------------+---------------------------+------------------+
|
||||
| Footer: ICP / 友链入口 / 站点页链接 |
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 2.1 左栏 Sidebar
|
||||
|
||||
- 全部帖子、我的收藏(登录)、开源码桶
|
||||
- 板块列表(图标 + 色点 + 名称);空站引导「创建第一个板块」
|
||||
- 站点区:友链入口(`nav_show_friend_links`)、`show_in_nav` 的站点页
|
||||
- 管理员:管理后台入口
|
||||
|
||||
### 2.2 中栏
|
||||
|
||||
**Feed**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题;已迁 SSR GET)+ 列表项(标题、作者、板块徽章、回复数、置顶/精华)。虚拟滚动 / `feed_list_style` 未迁。
|
||||
|
||||
**帖子详情**:标题区操作(赞、藏、举报、编辑、管理操作)→ 待审/被拒横幅(作者/管理员)→ 特殊组件(投票卡 / 悬赏条 / 抽奖卡)→ 正文 `PostContent`(门控块 UI)→ 文章目录 → 作者卡片 → 修订入口 → 评论线程 + 评论框。
|
||||
|
||||
### 2.3 右栏 RightPanel
|
||||
|
||||
- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **已迁** SSR 右栏;流水/详情仍在 `/profile#wallet`
|
||||
- 热门帖 — **已迁** SSR(固定块)
|
||||
- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin `/admin/settings` 侧栏组件开关与排序;友链页 aside 勾选共用同一 JSON
|
||||
- 桌面三列;≤900px 隐藏右栏
|
||||
|
||||
### 2.4 移动端
|
||||
|
||||
- 侧栏抽屉化;顶栏搜索/发帖/登录触手可及
|
||||
- `PullToRefresh` 下拉刷新
|
||||
- 触控友好列表行高
|
||||
|
||||
### 2.5 主题
|
||||
|
||||
- 浅色 / 暗色;默认 **跟随系统**(CSS `@media (prefers-color-scheme)`);`localStorage` 键 `j13-theme`(`system` | `light` | `dark`)
|
||||
- 显式浅/深时设 `<html data-theme="light|dark">`;`system` 时去掉该属性,交给媒体查询
|
||||
- 顶栏按钮循环切换;兼容旧 SPA 仅存 `light`/`dark` 的值
|
||||
|
||||
---
|
||||
|
||||
## 3. 发帖页 Compose
|
||||
|
||||
组件:`ComposeHeader`、`ComposeContextBar`(帖类型)、`ComposeSpecialFields`、`ComposeDocument` / `ArticleEditor`。
|
||||
|
||||
### 3.1 帖类型切换
|
||||
|
||||
| 类型 | 附加 UI |
|
||||
|------|---------|
|
||||
| 讨论 | 无 |
|
||||
| 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 |
|
||||
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
|
||||
| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 |
|
||||
| 抽奖 | 中奖人数 1–20 — SSR compose |
|
||||
|
||||
### 3.2 编辑器能力(本分支:Markdown 渐进增强)
|
||||
|
||||
源对照:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx)(TipTap,**不迁**)
|
||||
|
||||
| 能力 | SSR 状态 |
|
||||
|------|----------|
|
||||
| 标题 h2–h6(`#` 映射为 h2) | Markdown 工具栏 + `ComposeBodyToHTML` |
|
||||
| 粗体/斜体/删除线/行内代码 | 已迁 |
|
||||
| 链接 | prompt 插入 |
|
||||
| 围栏代码块 | 已迁(语言 class) |
|
||||
| 列表 / 引用 | 已迁 |
|
||||
| 图片上传 | `/compose/upload` |
|
||||
| 登录/回复/积分可见门控 | compose 工具栏 |
|
||||
| 预览 | `POST /compose/preview`(同消毒管线) |
|
||||
| Tab 缩进 / 未保存离开 | `beforeunload` |
|
||||
| 表格 / 图片组 / 表情贴纸 / TipTap 双模 | **未做**(后置) |
|
||||
|
||||
共用片段:`templates/shared/md_editor.tmpl`(发帖、改帖、评论、改评)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 帖子详情关键交互
|
||||
|
||||
| 模块 | 行为 |
|
||||
|------|------|
|
||||
| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
|
||||
| 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR |
|
||||
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
|
||||
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` |
|
||||
| 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` |
|
||||
| 评论 | 楼层列表、`reply_to` 引用、嵌套树(`ThreadParentID` + 缩进,全局 `#floor`)、私密/赞/举报/编辑/删除、回复/@ 通知 — SSR |
|
||||
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) |
|
||||
| 图片 | Lightbox 查看 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 消息页
|
||||
|
||||
- 左:会话列表(系统会话单独)— SSR `/messages`;快捷链到回复/提及筛选
|
||||
- 右:消息时间线;发送框 — SSR `/messages/with/:peerId`
|
||||
- 顶:未读角标(全局导航也可显示)
|
||||
- 通知筛选(kind)— 系统会话 `?kind=reply|mention|…`
|
||||
|
||||
---
|
||||
|
||||
## 6. 个人中心 / 公开主页
|
||||
|
||||
- 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile`
|
||||
- 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱)
|
||||
- 收藏列表 — SSR `/favorites`
|
||||
- 积分钱包面板(余额、近 N 条流水、签到/抽奖表单 PRG)— SSR `/profile`;导航展示当前积分
|
||||
- 徽章展示:用户主页 / 个人中心 — SSR(访问时 `EvaluateAuto`)
|
||||
|
||||
---
|
||||
|
||||
## 7. 友链页
|
||||
|
||||
- 展示品牌友链 — SSR `/links`
|
||||
- 登录申请表单:名称、URL、Logo(地址或上传)、是否上首页、回链页 — `POST /links/apply`
|
||||
- 我的申请状态列表;待审可取消
|
||||
- Admin:`/admin/friend-links` 品牌增删、申请通过/拒绝、nav/footer/回链检测开关
|
||||
|
||||
---
|
||||
|
||||
## 8. 管理后台操作流(按页)
|
||||
|
||||
| 页 | 关键操作 |
|
||||
|----|----------|
|
||||
| Dashboard | 看计数与待办;点进对应列表 |
|
||||
| Boards | 拖拽或数字排序;图标/色板选择;增删改 |
|
||||
| Pages | 列表发布开关;编辑正文(HTML textarea);nav/footer 勾选 — SSR `/admin/pages` |
|
||||
| Links | 品牌友链增删;申请通过/拒绝/回链复检;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(aside 与 Settings 侧栏组件共用 `aside_widgets`) |
|
||||
| Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` |
|
||||
| Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 |
|
||||
| Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
|
||||
| Users | 搜索;禁言;认证;调积分;设等级 — SSR `/admin/users`(授徽章在 `/admin/badges`) |
|
||||
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
|
||||
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` |
|
||||
| Media | 分类浏览;批量删 |
|
||||
| Settings | 品牌(含 Logo/Favicon/OG)、限流、内容限制、伪静态、侧栏、敏感词、邮件、SQLite 备份 — SSR `/admin/settings`(OIDC/Gitea/存储热切换未迁) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 全局 UX 细节
|
||||
|
||||
- Toast(sonner)反馈成功/失败
|
||||
- 路由级 ErrorBoundary / AppRouteError
|
||||
- 懒加载页面 + retry(`lazyWithRetry`)
|
||||
- 新标签打开帖子 / 正文外链:受 `open_posts_in_new_tab`、`open_content_links_in_new_tab` 控制
|
||||
- 文档标题:`站点名 - 标语`;详情页应换成帖标题(SSR 时首屏即正确)
|
||||
|
||||
---
|
||||
|
||||
## 10. SSR 重构提示(交互层)
|
||||
|
||||
当前 SPA 在客户端挂载后才拉 `/api/posts/:id`。新站应:
|
||||
|
||||
1. 服务端渲染列表项与帖文 HTML(已按门控红action)
|
||||
2. 水合后接上赞/评/解锁等交互
|
||||
3. 管理后台仍可为 CSR,但前台公开页优先 SSR
|
||||
|
||||
勿再维护「爬虫一套 HTML、用户一套空壳」双轨,除非过渡期兼容。
|
||||
213
docs/rebuild-spec/07-config-ops.md
Normal file
213
docs/rebuild-spec/07-config-ops.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 07 · 配置、运维与 SEO
|
||||
|
||||
> **读者**:部署与运维、以及实现配置层的 AI
|
||||
> **前置**:[README.md](README.md)
|
||||
> **源码**:[`config/`](../../config/)、[`README.md`](../../README.md)、[`routers/api/seo.go`](../../routers/api/seo.go)、[`routers/install/`](../../routers/install/)
|
||||
|
||||
运维形态可改;下列描述**本分支**行为。
|
||||
|
||||
---
|
||||
|
||||
## 0. 首次安装(Gitea 式)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 锁文件 | `data/install.lock` |
|
||||
| 向导 | `GET/POST /install`(`templates/install.tmpl`) |
|
||||
| 未锁定 | 除 `/install`、`/ssr-assets/*`、`/health` 外重定向到向导 |
|
||||
| 管理员 | 仅安装向导创建;不再「首个注册用户变管理员」 |
|
||||
| 向导内容 | 站点名 + 管理员账号(数据库已在进程启动时连上) |
|
||||
| 旧数据 | 启动时若已有用户且无锁,自动补写锁 |
|
||||
|
||||
**无 `app.ini`。** 引导仅 CLI / Env。
|
||||
|
||||
---
|
||||
|
||||
## 1. 配置分层与重启边界
|
||||
|
||||
| 层 | 存什么 | 变更方式 | 需重启 |
|
||||
|----|--------|----------|--------|
|
||||
| **Bootstrap** | `DATA`、`HTTP_PORT`/`ADDR`、`DB_TYPE` + DSN/连接参数、工作目录 | CLI / Env | **是** |
|
||||
| **密钥文件** | App HMAC(`data/.jwt_secret`,文件名历史遗留);OIDC RSA **仅启用时**写入 settings(可选遗留文件迁移) | 自动生成 | HMAC 换钥需重启 |
|
||||
| **站点运行时** | 品牌、邮件、OIDC 开关、限流、敏感词、存储、伪静态… | DB `forum_settings` | **否**(热更) |
|
||||
|
||||
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
|
||||
|
||||
### 进程引导
|
||||
|
||||
| CLI | 环境变量 | 默认 | 说明 |
|
||||
|-----|----------|------|------|
|
||||
| `--port` | `JIANG13_HTTP_PORT` | 3000 | 监听端口 |
|
||||
| `--http-addr` | `JIANG13_HTTP_ADDR` | (空=全接口) | 监听地址 |
|
||||
| `--data` | `JIANG13_DATA` | `data` | 数据目录 |
|
||||
| `--work-path` | `JIANG13_WORK_PATH` | 可执行文件目录 | 工作目录 |
|
||||
| `--db-type` | `JIANG13_DB_TYPE` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
|
||||
| `--db-dsn` | `JIANG13_DB_DSN` | (sqlite 默认 `{DATA}/jiang13.db`) | 完整 DSN,优先 |
|
||||
| `--db-host` 等 | `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE` | | DSN 为空时拼接(pg/mysql) |
|
||||
| `--service` | | | install/uninstall/start/stop/restart/status |
|
||||
|
||||
`{DATA}/.jwt_secret`:**App HMAC 密钥**(CSRF 双提交等),启动时自动生成。**不是**浏览器登录 JWT。`--config` / `--jwt-secret` / `JIANG13_JWT_SECRET` 已废弃。
|
||||
|
||||
浏览器登录:DB `sessions` + Cookie `jiang13_session`。OIDC 对外 token 仍为 JWT,私钥在 `forum_settings.oidc_rsa_private_pem`(启用时懒加载;未启用不生成 `.oidc_rsa.pem`)。
|
||||
|
||||
业务配置(邮件、OIDC、存储、品牌、敏感词等)在 **DB `forum_settings`**,管理后台热更新。
|
||||
|
||||
### 数据库 Env 示例
|
||||
|
||||
**SQLite(默认):**
|
||||
|
||||
```bash
|
||||
JIANG13_DATA=/data
|
||||
# 可不设 DB_*;库文件 = $JIANG13_DATA/jiang13.db
|
||||
```
|
||||
|
||||
**PostgreSQL:**
|
||||
|
||||
```bash
|
||||
JIANG13_DB_TYPE=postgres
|
||||
JIANG13_DB_DSN="postgres://forum:secret@db:5432/jiang13?sslmode=disable"
|
||||
# 或拆分:
|
||||
# JIANG13_DB_HOST=db:5432
|
||||
# JIANG13_DB_USER=forum
|
||||
# JIANG13_DB_PASS=secret
|
||||
# JIANG13_DB_NAME=jiang13
|
||||
# JIANG13_DB_SSLMODE=disable
|
||||
```
|
||||
|
||||
**MySQL / MariaDB:**
|
||||
|
||||
```bash
|
||||
JIANG13_DB_TYPE=mysql
|
||||
JIANG13_DB_DSN="forum:secret@tcp(db:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4"
|
||||
```
|
||||
|
||||
连库失败时进程**退出并打印 Env 提示**,不会静默回落 sqlite。
|
||||
|
||||
### Docker Compose 多库示意
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jiang13:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
environment:
|
||||
JIANG13_DB_TYPE: postgres
|
||||
JIANG13_DB_DSN: postgres://forum:secret@postgres:5432/jiang13?sslmode=disable
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
depends_on: [postgres]
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: forum
|
||||
POSTGRES_PASSWORD: secret
|
||||
POSTGRES_DB: jiang13
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据目录结构
|
||||
|
||||
```text
|
||||
data/
|
||||
├── install.lock # 安装完成锁(与 DB 引擎无关)
|
||||
├── jiang13.db # 仅 SQLite 时的主库文件(含 sessions / forum_settings)
|
||||
├── jiang13.log # 运行日志
|
||||
├── filter_words.txt # 遗留:启动时可导入 settings;新源以 DB 为准
|
||||
├── .jwt_secret # App HMAC(CSRF 等;勿提交;非登录 JWT)
|
||||
├── .oidc_rsa.pem # 遗留:仅启用 OIDC 且从文件迁移时可能存在;新站优先 DB
|
||||
├── uploads/
|
||||
│ ├── avatars/
|
||||
│ ├── posts/
|
||||
│ └── site/
|
||||
└── jiang13_backup_*.db # SQLite 一键备份(其它引擎请用库方工具)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署方式(现网)
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| 单二进制 | `build.bat` / `make build` → `dist/jiang13(.exe)` |
|
||||
| Docker | 镜像挂载 `/data`;健康检查 `GET /health` |
|
||||
| Compose | `docker compose up -d --build` |
|
||||
| systemd / Windows Service | `--service install` 后启停 |
|
||||
|
||||
构建约定见 [`.cursor/rules/build-scripts.mdc`](../../.cursor/rules/build-scripts.mdc):Windows 用 `build.bat`,勿直接 `make` / `.\build.ps1`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 存储后端
|
||||
|
||||
| type | 行为 |
|
||||
|------|------|
|
||||
| `local` | 文件落在 `data/uploads`;URL 通常 `/uploads/...` |
|
||||
| `s3` | S3 兼容;endpoint、bucket、密钥、public_base_url、prefix、force_path_style |
|
||||
|
||||
`image_delivery`:`webp`(默认,经 `/media/thumb`)或 `original`。
|
||||
|
||||
媒体索引表 `media` 供后台列表;启动时可后台 SyncMediaIndex。
|
||||
|
||||
---
|
||||
|
||||
## 5. SEO / 社交分享字段集合
|
||||
|
||||
新站应用真 SSR,但 **meta 字段应对齐**:
|
||||
|
||||
| 字段 | 来源 |
|
||||
|------|------|
|
||||
| `<title>` | 站点 DocumentTitle 或帖标题 |
|
||||
| meta description | 站点简介优先,否则标语;帖文则摘要 |
|
||||
| meta keywords | 站点 keywords |
|
||||
| canonical | 绝对 URL |
|
||||
| og:* / twitter:* | |
|
||||
| JSON-LD | 结构化数据 |
|
||||
| robots | 个别页可 noindex |
|
||||
|
||||
| 机制 | 说明 |
|
||||
|------|------|
|
||||
| robots.txt / sitemap.xml | 动态生成 |
|
||||
|
||||
重构验收:普通浏览器「查看网页源代码」应能看到帖文正文。
|
||||
|
||||
---
|
||||
|
||||
## 6. 伪静态 Permalink
|
||||
|
||||
设置:`permalink_enabled`、`permalink_ext`(默认 `html`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全相关运维注意
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| HMAC / OIDC | 勿提交 `.jwt_secret`;OIDC PEM 优先在 DB;遗留 `.oidc_rsa.pem` 亦勿提交 |
|
||||
| Cookie | `jiang13_session` HttpOnly + SameSite=Lax;生产 HTTPS 下 Secure |
|
||||
| 上传 | 类型/大小限制 |
|
||||
| 敏感词 | `forum_settings.filter_words`,后台可改热更 |
|
||||
| 备份 | SQLite 文件备份含哈希与私信;PG/MySQL 用官方工具 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 健康检查
|
||||
|
||||
`GET /health` → JSON `status`。DB 不可用时非 200。
|
||||
|
||||
---
|
||||
|
||||
## 9. 从旧站迁数据建议
|
||||
|
||||
1. SQLite:复制 `jiang13.db`;或 dump 到 PG/MySQL 并映射表
|
||||
2. 复制 `uploads/`、`.jwt_secret`(HMAC 兼容);敏感词若仍在文件可启动导入
|
||||
3. 迁移 `forum_settings` 或后台重配(含 `filter_words`)
|
||||
4. OIDC:`oauth_clients` + settings 中 PEM(或遗留 `.oidc_rsa.pem` 一次迁移)
|
||||
5. 旧站 JWT Cookie 无效;用户需重新登录(opaque session)
|
||||
|
||||
表语义以 [03-data-model.md](03-data-model.md) 为准。
|
||||
|
||||
---
|
||||
|
||||
## 10. 文档包索引
|
||||
|
||||
返回 [README.md](README.md) 阅读顺序;功能验收用 [02-features.md](02-features.md);规则用 [05-business-rules.md](05-business-rules.md)。
|
||||
105
docs/rebuild-spec/08-gitea-ssr-architecture.md
Normal file
105
docs/rebuild-spec/08-gitea-ssr-architecture.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 08 · Gitea 式 SSR 架构(开发约定)
|
||||
|
||||
> **读者**:在本仓库 `rebuild/gitea-ssr` 分支上开发的 AI / 开发者
|
||||
> **前置**:[README.md](README.md)
|
||||
> **对照上游**:[go-gitea/gitea](https://github.com/go-gitea/gitea)
|
||||
|
||||
---
|
||||
|
||||
## 目标栈(已确认)
|
||||
|
||||
| 层 | 选择 |
|
||||
|----|------|
|
||||
| 公开页渲染 | Go `html/template` **真 SSR** |
|
||||
| 浏览器写操作 | `routers/web` HTML 表单 POST + CSRF + PRG |
|
||||
| JSON `/api` | **仅机器客户端**(OIDC、health、`/api/captcha`、`/api/register` 等);不服务已迁页面 UI |
|
||||
| 渐进增强 | `web_src/` → `public/assets/` → `/ssr-assets/` |
|
||||
| 发布 | 单二进制 + `go:embed` |
|
||||
| 业务语义 | `01`–`07`;冲突时改代码并回写规格 |
|
||||
| 不做 | React SPA、爬虫/用户双轨 HTML、为旧 SPA 保留死代码 |
|
||||
|
||||
---
|
||||
|
||||
## 分支
|
||||
|
||||
| 分支 | 用途 |
|
||||
|------|------|
|
||||
| `main` | React SPA 对照(git checkout / worktree) |
|
||||
| `rebuild/gitea-ssr` | 唯一重建分支 |
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
routers/
|
||||
setup.go
|
||||
install/ # INSTALL_LOCK 未置位时的安装向导
|
||||
web/ # HTML + 表单
|
||||
api/ # 精简机器接口(health / OIDC / robots / sitemap / media)
|
||||
modules/
|
||||
webctx/ # Doer / CSRF / Flash / HTML / Redirect
|
||||
auth/
|
||||
webrender/
|
||||
seo/
|
||||
templates/
|
||||
install.tmpl
|
||||
post-install.tmpl
|
||||
base/ home/ post/ shared/ status/ auth/ admin/ profile/ user/ favorites/ messages/
|
||||
services/
|
||||
web_src/ → public/assets/
|
||||
```
|
||||
|
||||
**已删除:** `frontend/`、`embed_static/`、`ServePublicSPA`、爬虫双轨 HTML、首注册变管理员 bootstrap、`app.ini`、浏览器 JWT Cookie 登录。
|
||||
|
||||
**配置:** 引导 = CLI/Env(含 `DB_*`);运行时 = `forum_settings` 热更;`.jwt_secret` = App HMAC;OIDC PEM 启用时进 settings。详见 [07-config-ops.md](07-config-ops.md)。
|
||||
|
||||
**会话:** Cookie `jiang13_session` → 表 `sessions`;可吊销。
|
||||
|
||||
**数据库:** GORM 方言 `sqlite`(默认)| `postgres` | `mysql`;连库失败不回落。
|
||||
|
||||
**后置:** Gitea 仓库同步(不启后台任务)。
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
- 锁文件:`data/install.lock`
|
||||
- 未安装:除 `/install`、`/ssr-assets/*`、`/health` 外一律重定向到安装向导
|
||||
- 管理员仅由安装向导创建;已有用户数据启动时会自动补写锁
|
||||
- 向导不选库:库由启动 Env 决定
|
||||
|
||||
---
|
||||
|
||||
## 渲染与交互原则
|
||||
|
||||
1. 已迁路径「查看源代码」须含内容 DOM。
|
||||
2. UI 读写不依赖 `/api` 灌首屏或写操作(`/compose/upload` 为同站表单辅助 JSON,带 CSRF)。
|
||||
3. 模板默认转义;`safeHTML` 仅用于消毒 + 门控后正文/评论 HTML。
|
||||
4. 未迁路径用 `status/pending.tmpl` 或 404,不维护 SPA 占位语义。
|
||||
5. 会话 Cookie:`jiang13_session`;`SameSite=Lax`;HTTPS 下 `Secure`。
|
||||
|
||||
### 已迁路径(摘要)
|
||||
|
||||
公开写:`/install`、`/login`、`/logout`、`/register`、`/forgot-password`(发码/重置)、`/compose`(含门控插入)、`/post/:id/edit`、帖详情评论(reply_to/赞/私密)/赞/藏、评论赞、积分解锁 `POST /post/:id/unlock`。
|
||||
|
||||
个人闭环:`/profile`(昵称/签名/密码/头像、积分钱包/签到/抽奖)、`/user/:id`、`/favorites`。
|
||||
`POST /profile/checkin`、`POST /profile/lottery`(CSRF + PRG)。
|
||||
|
||||
私信:`/messages`、`/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。
|
||||
|
||||
公开浏览:`/boards` 板块索引(链到 `/board/:id`)。
|
||||
|
||||
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。
|
||||
|
||||
右栏:首页与帖详情三栏;登录用户签到/抽奖条(PRG 可回跳当前 Feed/帖页);固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链);Admin `/admin/settings` 可开关排序,友链页 aside 勾选与其共用同一配置。
|
||||
|
||||
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、nav/footer/aside 入口开关)。
|
||||
|
||||
站点单页:公开 `/page/:slug`;nav/footer 按 `show_in_nav` / `show_in_footer` 注入;Admin `/admin/pages`(CRUD、发布、排序、展示开关)。
|
||||
|
||||
举报:帖/评 SSR 表单;Admin `/admin/reports`(dismiss / resolve / reject_post / reject_comment)。
|
||||
|
||||
运营标记:帖详情 Admin 条(全局/版内置顶、精华、禁编、锁评、软删);回收站 `/admin/trash`(恢复/彻底删除)。
|
||||
|
||||
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/侧栏组件/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`、`/admin/trash`。
|
||||
52
docs/rebuild-spec/09-ssr-progress.md
Normal file
52
docs/rebuild-spec/09-ssr-progress.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 09 · SSR 重构进度与路线图
|
||||
|
||||
> **读者**:产品方 / 实现 AI
|
||||
> **分支**:`rebuild/gitea-ssr`(`main` = SPA 对照)
|
||||
> **验收清单**: [02-features.md](02-features.md)
|
||||
|
||||
---
|
||||
|
||||
## 当前指针
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| **上一刀** | 首页对齐 main SPA 视觉与交互(草绿壳 + Feed v2) |
|
||||
| **下一刀** | 需点名(移动抽屉 / 帖详情抛光 / OIDC Admin…) |
|
||||
| **工作区** | 应干净 |
|
||||
|
||||
---
|
||||
|
||||
## 一句话状态
|
||||
|
||||
核心论坛、Admin、Limits/伪静态、嵌套评论、Markdown 编辑器、主题、清青产品壳与资源版本号、首页门面密度已齐;本分支 `routers/api` 仅保留机器入口。余量多为后置与体验打磨。
|
||||
|
||||
---
|
||||
|
||||
## 已完成(摘)
|
||||
|
||||
| 域 | 状态 |
|
||||
|----|------|
|
||||
| 五种帖 + Admin P1 + §H | 已迁 |
|
||||
| Limits / 伪静态 / 品牌图 | 已迁 |
|
||||
| 评论嵌套树 UI | 已迁 |
|
||||
| Markdown 工具栏 + preview | 已迁 |
|
||||
| 浅色 / 暗色 / 跟随系统 | 已迁(CSS 媒体查询,无 head 防闪脚本) |
|
||||
| 未挂载论坛 JSON API | 已删(对照 `main`) |
|
||||
| 公开页设计系统 | 已迁(首页对齐 main SPA 草绿壳;[10-design-system.md](10-design-system.md)) |
|
||||
|
||||
---
|
||||
|
||||
## 未完成
|
||||
|
||||
- P3:游客评论、修订 diff、响应式侧栏抽屉
|
||||
- 编辑器后置:TipTap、表格、图片组、表情贴纸
|
||||
- 后置:OIDC Admin UI、S3 热切换、Gitea `/projects`
|
||||
|
||||
---
|
||||
|
||||
## 如何查阅
|
||||
|
||||
| 问题 | 看哪里 |
|
||||
|------|--------|
|
||||
| 某功能做没做? | [02-features.md](02-features.md) |
|
||||
| 现在该干什么? | 本文「当前指针」 |
|
||||
94
docs/rebuild-spec/10-design-system.md
Normal file
94
docs/rebuild-spec/10-design-system.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 10 · 公开页设计系统
|
||||
|
||||
> **读者**:实现公开页视觉 / CSS 的 AI
|
||||
> **前置**:[01-product.md](01-product.md)、[06-pages-ux.md](06-pages-ux.md)
|
||||
> **实现**:[`web_src/css/site.css`](../../web_src/css/site.css)、[`templates/base/`](../../templates/base/)
|
||||
> **品牌**:默认「姜十三论坛」;首页视觉对齐 `main` SPA 草绿产品壳
|
||||
|
||||
视觉可重设;**信息架构与关键操作流以 06 为准**。本文件只约定公开页壳层与内容表面的视觉语言。
|
||||
|
||||
静态资源须带构建版本号:`/ssr-assets/site.css?v={{.AssetVersion}}`(长缓存 + query 穿透,对齐 Gitea)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 气质
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 关键词 | 对齐 `main` SPA:草绿 `#18a058`、清新产品壳 |
|
||||
| 密度 | SPA 同款顶栏工具条 + Feed `post-row--v2` |
|
||||
| 反模式 | 紫渐变、暖奶油底、大圆角卡片墙、全幅 hero、Inter/Roboto |
|
||||
|
||||
---
|
||||
|
||||
## 2. Token
|
||||
|
||||
### 2.1 色(CSS 变量 `--j13-*`)
|
||||
|
||||
| Token | 浅色 | 暗色 | 用途 |
|
||||
|-------|------|------|------|
|
||||
| `--j13-bg` / page | `#f5f7fa` | `#141416` | 页底 |
|
||||
| `--j13-surface` | `#ffffff` | `#1f1f23` | 顶栏、主栏 |
|
||||
| `--j13-accent` / green | `#18a058` | `#23c36b` | 主色(对齐 main SPA) |
|
||||
| `--j13-accent-ink` | `#138f4c` | `#6ee7a0` | 强强调 |
|
||||
| `--j13-soft` / green-bg | `#edfbf3` / `rgba(24,160,88,.08)` | 绿 soft | active / hover |
|
||||
| `--j13-border` | `#e8edf2` | `#2e2e32` | 分隔 |
|
||||
|
||||
语义色(ok / warn / err / pin / feat)随主题成对定义;feat 用青 soft,不用草绿。
|
||||
|
||||
### 2.2 字号与字重
|
||||
|
||||
| 阶 | 尺寸 | 用途 |
|
||||
|----|------|------|
|
||||
| `xs` | 0.72–0.75rem | 口号、辅助 |
|
||||
| `sm` | 0.8125rem | 元信息、侧栏、顶栏次级 |
|
||||
| `md` | 0.9375rem | 正文默认 |
|
||||
| `lg` | 1.15rem | Feed 标题 |
|
||||
| `xl` | 1.35rem | 帖标题 |
|
||||
| `brand` | 1.3rem / 700 | 顶栏站点名 |
|
||||
|
||||
字体栈:**中文优先** PingFang SC / Hiragino / Microsoft YaHei / Noto Sans SC(与 SPA 一致,确保本机可见);代码 Cascadia / Consolas。不引入 Inter/Roboto,不依赖本机 IBM Plex。
|
||||
|
||||
### 2.3 间距与圆角
|
||||
|
||||
- 栅格:4px 基准;总宽 `--j13-frame: 1320px`
|
||||
- 圆角:`--j13-radius: 8px`;搜索 pill / 排序分段可用满圆角
|
||||
- 阴影:sticky 顶栏微阴影
|
||||
|
||||
### 2.4 动效
|
||||
|
||||
| 处 | 行为 |
|
||||
|----|------|
|
||||
| 主题 | `color` / `background` / `border-color` `150ms ease` |
|
||||
| Feed 行 | hover 背景 |
|
||||
| 顶栏 | sticky 阴影;搜索 focus soft ring |
|
||||
|
||||
---
|
||||
|
||||
## 3. 布局
|
||||
|
||||
| 断点 | 行为 |
|
||||
|------|------|
|
||||
| ≥901px | 三栏:左 210px / 中 1fr / 右 280px;总宽 ~1400px(对齐 SPA) |
|
||||
| ≤900px | 隐藏右栏;顶栏搜索折行(抽屉化 **下一刀**) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 组件约定
|
||||
|
||||
| 组件 | 规则 |
|
||||
|------|------|
|
||||
| 顶栏 | SPA:品牌 \| 搜索胶囊(筛选钮 + Ctrl+K)\| 发帖绿钮 \| 主题/私信/头像菜单 |
|
||||
| 左栏 | 浏览 / 板块;active 绿底;板块色槽 + 帖数 |
|
||||
| Feed 行 | `post-row--v2`:头像 · 徽章+标题 · 摘要(依 feed_list_style)· 元信息 · 回复数 |
|
||||
| 高级搜索 | 顶栏筛选打开;有条件时 details 展开 |
|
||||
| 排序 | SPA `feed-sort-tab` 绿 active |
|
||||
|
||||
---
|
||||
|
||||
## 5. 范围边界
|
||||
|
||||
- **覆盖**:公开页壳层(含首页门面密度)、资源 `?v=`、浅/暗色
|
||||
- **不覆盖**:Admin 深重绘、移动抽屉、虚拟滚动、TipTap、改站名
|
||||
|
||||
交叉:[06-pages-ux.md](06-pages-ux.md) · [09-ssr-progress.md](09-ssr-progress.md)
|
||||
140
docs/rebuild-spec/README.md
Normal file
140
docs/rebuild-spec/README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 姜十三论坛 · 重构规格文档包
|
||||
|
||||
> **读者**:准备用新栈(建议真 SSR)重写站点的 AI / 开发者
|
||||
> **事实来源**:本仓库现有代码;规格描述「产品必须保留什么」,不是「必须继续用 Go + React SPA」
|
||||
> **交叉引用**:[01-product](01-product.md) · [02-features](02-features.md) · [03-data-model](03-data-model.md) · [04-api](04-api.md) · [05-business-rules](05-business-rules.md) · [06-pages-ux](06-pages-ux.md) · [07-config-ops](07-config-ops.md) · [08-gitea-ssr-architecture](08-gitea-ssr-architecture.md) · [09-ssr-progress](09-ssr-progress.md) · [10-design-system](10-design-system.md)
|
||||
|
||||
> **实现栈(重构分支)**:Gitea 式 Go 模板 SSR;开发分支 `rebuild/gitea-ssr`;`main` 保留 React SPA 对照。详见 [08](08-gitea-ssr-architecture.md)。
|
||||
|
||||
---
|
||||
|
||||
## 阅读顺序(请按序投喂)
|
||||
|
||||
| 顺序 | 文件 | 用途 |
|
||||
|------|------|------|
|
||||
| 1 | 本文 `README.md` | 架构痛点、重构约束、术语 |
|
||||
| 2 | [01-product.md](01-product.md) | 产品定位、角色、模块地图 |
|
||||
| 3 | [02-features.md](02-features.md) | 验收级功能清单(可打勾) |
|
||||
| 4 | [03-data-model.md](03-data-model.md) | 表结构、枚举、设置键、等级徽章 |
|
||||
| 5 | [04-api.md](04-api.md) | HTTP 合约(路径 / 鉴权 / 请求响应) |
|
||||
| 6 | [05-business-rules.md](05-business-rules.md) | 状态机与数值规则 |
|
||||
| 7 | [06-pages-ux.md](06-pages-ux.md) | 路由、布局、编辑器、后台流程 |
|
||||
| 8 | [07-config-ops.md](07-config-ops.md) | 配置、数据目录、部署、SEO 字段 |
|
||||
| 9 | [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) | Gitea 式 SSR 分支与目录约定 |
|
||||
| 10 | [09-ssr-progress.md](09-ssr-progress.md) | **进度与刀序**(「现在做到哪 / 下一刀」) |
|
||||
| 11 | [10-design-system.md](10-design-system.md) | 公开页视觉 token / 组件(壳层重绘) |
|
||||
|
||||
单次 context 不够时:先投喂 `README` + `01` + `02`;跟进度看 `09`;实现某模块时再追加对应 `03`–`06` 章节。
|
||||
|
||||
---
|
||||
|
||||
## 当前产品是什么
|
||||
|
||||
**姜十三论坛(Jiang13 Forum)** 面向小圈子 / 团队 / 同好社群的轻量论坛。
|
||||
|
||||
当前实现技术栈(**可抛弃,仅作对照**):
|
||||
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 后端 | Go · Gin · GORM · SQLite |
|
||||
| 前端 | React 18 SPA · TipTap · Tailwind · TanStack Virtual |
|
||||
| 发布 | Vite 构建 → `go:embed` 打进单二进制 |
|
||||
| 认证 | bcrypt + DB opaque session Cookie(`jiang13_session`) |
|
||||
|
||||
演示站:https://bbs.iioio.com/
|
||||
|
||||
---
|
||||
|
||||
## 为何要重构:架构痛点(必须打破)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
browser[Browser]
|
||||
spa[ReactSPA]
|
||||
gin[GinAPI]
|
||||
sqlite[SQLite]
|
||||
bot[BotHTML]
|
||||
browser -->|"用户"| spa
|
||||
spa -->|"JSON /api"| gin
|
||||
gin --> sqlite
|
||||
browser -->|"爬虫 UA"| bot
|
||||
bot --> gin
|
||||
```
|
||||
|
||||
| 痛点 | 现状 | 对用户的影响 |
|
||||
|------|------|----------------|
|
||||
| 非真 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(旧) | 浏览器登录曾用 JWT Cookie | **本分支已改为** DB `sessions` + opaque Cookie `jiang13_session`;`.jwt_secret` 仅 CSRF/HMAC |
|
||||
|
||||
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(SSR)HTML;SEO meta 与正文同源。技术选型自定(Next.js / Nuxt / Remix / 其它均可)。
|
||||
|
||||
---
|
||||
|
||||
## 重构时必须保留 vs 可以改
|
||||
|
||||
### 必须保留(产品语义)
|
||||
|
||||
- [02-features.md](02-features.md) 中列出的功能能力
|
||||
- [03-data-model.md](03-data-model.md) 中的实体关系与枚举含义(表名可改,语义对齐)
|
||||
- [05-business-rules.md](05-business-rules.md) 中的数值与状态机(积分、审核、门控、悬赏分成等)
|
||||
- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;**管理员仅由 `/install` 创建**(不再首注册变管理员)
|
||||
|
||||
### 建议兼容(降低迁移成本)
|
||||
|
||||
- [04-api.md](04-api.md) 的 JSON 字段命名与路径形状(可做版本前缀,但旧字段名便于对照)
|
||||
- Cookie 名 `jiang13_session`(opaque session id;重建分支不做 `jiang13_token` 双读)
|
||||
- 数据目录语义:`jiang13.db`、`uploads/`;敏感词在 `forum_settings.filter_words`(旧 `filter_words.txt` 可导入)
|
||||
|
||||
### 可以彻底改
|
||||
|
||||
- 语言与框架(不必再 Go + React SPA)
|
||||
- 单二进制 / `go:embed`(可改为前后端分离部署)
|
||||
- SQLite(可换 PostgreSQL 等;规格不强制)
|
||||
- UI 视觉(布局信息密度见 [06-pages-ux.md](06-pages-ux.md),视觉可重设)
|
||||
- 爬虫专用 HTML 双轨(用真 SSR 取代)
|
||||
|
||||
---
|
||||
|
||||
## 术语表(首次出现)
|
||||
|
||||
| 术语 | 中文 | 说明 |
|
||||
|------|------|------|
|
||||
| SSR | 服务端渲染 | 首屏 HTML 含正文,非纯客户端壳 |
|
||||
| SPA | 单页应用 | 当前前台实现形态 |
|
||||
| OIDC | 开放身份连接 | 本站可作 Provider,供 Gitea 等 SSO |
|
||||
| JWT | JSON Web Token | OIDC 对外 `id_token`/`access_token` 仍用;**浏览器登录不用 JWT** |
|
||||
| Opaque session | 不透明会话 | Cookie 只存随机 id,服务端 `sessions` 表可吊销 |
|
||||
| Feed | 信息流 | 首页 / 板块帖列表 |
|
||||
| 门控 | Content gate | 登录可见 / 回复可见 / 积分可见区块 |
|
||||
| 伪静态 | Permalink | 如 `/post/123.html` 的可选后缀 |
|
||||
|
||||
---
|
||||
|
||||
## 源码速查(核对规格时)
|
||||
|
||||
| 主题 | 路径 |
|
||||
|------|------|
|
||||
| 路由总装 | [`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) |
|
||||
|
||||
---
|
||||
|
||||
## 文档包完成标准
|
||||
|
||||
另一 AI 仅阅读本目录、**不打开业务源码**,应能:
|
||||
|
||||
1. 列出全部用户可见功能与后台能力
|
||||
2. 画出核心表 ER 并理解枚举
|
||||
3. 实现或 mock 与现网兼容的 API 形状
|
||||
4. 复现审核 / 积分 / 门控 / 特殊帖规则
|
||||
5. 搭出等价的页面信息架构与关键交互
|
||||
|
||||
若规格与代码冲突:**以代码为准**,并应回写修正本目录文档。
|
||||
22
docs/rebuild-spec/plans/admin-badges.md
Normal file
22
docs/rebuild-spec/plans/admin-badges.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Admin 徽章管理(SSR)— 实现计划
|
||||
|
||||
> 状态:已完成(SSR `/admin/badges`)
|
||||
> 分支:`rebuild/gitea-ssr`
|
||||
|
||||
## 范围
|
||||
|
||||
| 能力 | 服务 | 路由 |
|
||||
|------|------|------|
|
||||
| 列表(含停用) | `BadgeService.ListDefs(true)` | `GET /admin/badges` |
|
||||
| 创建/更新定义 | `UpsertDef` | `POST /admin/badges`、`POST /admin/badges/:id` |
|
||||
| 删除定义 | `DeleteDef`(清 `user_badges`) | `POST /admin/badges/:id/delete` |
|
||||
| 颁发限定 | `AwardLimited` | `POST /admin/badges/award` |
|
||||
| 收回 | `Revoke` | `POST /admin/badges/revoke` |
|
||||
|
||||
## 不做
|
||||
|
||||
自动徽章规则引擎调度 UI(P2,服务层 `EvaluateAuto` 仍可被触发)、等级设定。
|
||||
|
||||
## 验证
|
||||
|
||||
`build.bat`;冒烟:创建限定徽章 → 颁发给用户 → 收回 → 删除。
|
||||
21
docs/rebuild-spec/plans/admin-media.md
Normal file
21
docs/rebuild-spec/plans/admin-media.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Admin 媒体库(SSR)— 实现计划
|
||||
|
||||
> 状态:已完成(SSR `/admin/media`)
|
||||
> 分支:`rebuild/gitea-ssr`
|
||||
|
||||
## 范围
|
||||
|
||||
| 能力 | 服务 | 路由 |
|
||||
|------|------|------|
|
||||
| 分类/搜索/分页列表 | `UploadStore.ListMedia` | `GET /admin/media` |
|
||||
| 单删 | `DeleteMediaByIDs` | `POST /admin/media/:id/delete` |
|
||||
| 批量删 | 同上 | `POST /admin/media/delete` |
|
||||
| 同步索引 | `SyncMediaIndex` | `POST /admin/media/sync` |
|
||||
|
||||
## 不做
|
||||
|
||||
S3 热切换 Admin、全站未引用扫描清理、WebP 产品化默认策略。
|
||||
|
||||
## 验证
|
||||
|
||||
`build.bat`;冒烟:写入 uploads → 同步索引 → 列表可见 → 删除。
|
||||
30
docs/rebuild-spec/plans/admin-users.md
Normal file
30
docs/rebuild-spec/plans/admin-users.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Admin 用户管理(SSR)— 实现计划
|
||||
|
||||
> 状态:已完成(SSR `/admin/users`)
|
||||
> 分支:`rebuild/gitea-ssr`
|
||||
|
||||
## 范围
|
||||
|
||||
在 Admin 增加用户列表与运营操作,复用已有服务层,不恢复浏览器管理 JSON `/api`。
|
||||
|
||||
| 能力 | 服务 | 路由 |
|
||||
|------|------|------|
|
||||
| 列表/搜索/分页 | `UserService.ListUsers` | `GET /admin/users` |
|
||||
| 禁言/解禁 | `UserService.BanUser` | `POST /admin/users/:id/ban` |
|
||||
| 认证开关 | `SetVerified` | `POST /admin/users/:id/verify` |
|
||||
| 调积分 | `PointsService.AdminAdjust` | `POST /admin/users/:id/points` |
|
||||
|
||||
## 实现要点
|
||||
|
||||
1. [`routers/web/home.go`](../routers/web/home.go) admin 组注册上述路由
|
||||
2. 新建 [`routers/web/admin_users.go`](../routers/web/admin_users.go):渲染 + CSRF POST + PRG
|
||||
3. [`templates/admin/users.tmpl`](../templates/admin/users.tmpl) + [`templates/admin/nav.tmpl`](../templates/admin/nav.tmpl) 入口
|
||||
4. 回写 `02` §H(调积分/认证相关)、`06` `/admin/users` 已迁;更新 `09` 当前指针 → badges
|
||||
|
||||
## 不做
|
||||
|
||||
等级设定 UI、徽章授予、批量导入、恢复 `/api/admin/users` 作 UI。
|
||||
|
||||
## 验证
|
||||
|
||||
`build.bat`;冒烟:搜索用户 → 禁言 → 调积分 → 切换 verified。
|
||||
17
docs/rebuild-spec/plans/level-badges-display.md
Normal file
17
docs/rebuild-spec/plans/level-badges-display.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# P2 等级设定与徽章展示 — 实现计划
|
||||
|
||||
> 状态:已完成
|
||||
> 分支:`rebuild/gitea-ssr`
|
||||
|
||||
## 范围
|
||||
|
||||
| 能力 | 实现 |
|
||||
|------|------|
|
||||
| Admin 设等级 | `POST /admin/users/:id/level` → `SetUserLevel`(Exp 调至门槛) |
|
||||
| 公开等级 | 既有 `/user/:id`、`/profile` Lv/Exp |
|
||||
| 徽章展示 | 用户页 / 资料页列表;访问时 `EvaluateAuto` |
|
||||
| 防刷分成 | 确认 `suspiciousUnlockPair` 已落地并勾选 `02` |
|
||||
|
||||
## 验证
|
||||
|
||||
`build.bat`;冒烟:设用户 Lv5 → Exp=200;用户页可见等级。
|
||||
BIN
docs/screenshots/home-dark.png
Normal file
BIN
docs/screenshots/home-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
BIN
docs/screenshots/home-light.png
Normal file
BIN
docs/screenshots/home-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
docs/screenshots/mobile-home.png
Normal file
BIN
docs/screenshots/mobile-home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/screenshots/post-detail.png
Normal file
BIN
docs/screenshots/post-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/screenshots/post-rich.png
Normal file
BIN
docs/screenshots/post-rich.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
28
docs/site-posts/README.md
Normal file
28
docs/site-posts/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 站务公告 · 发帖素材
|
||||
|
||||
## 板块设置
|
||||
|
||||
| 项 | 建议值 |
|
||||
| --- | --- |
|
||||
| 名称 | 站务公告 |
|
||||
| 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 |
|
||||
| 图标 | `megaphone` |
|
||||
| 色标 | 建议第 0 或 1 槽 |
|
||||
| 排序 | `0` |
|
||||
|
||||
## 主文(必发)
|
||||
|
||||
| 文件 | 标题 | 标签 | 操作 |
|
||||
| --- | --- | --- | --- |
|
||||
| [about-jiang13.md](./about-jiang13.md) | 关于本站 · 姜十三论坛 | `站务` `介绍` | **置顶**(可选精华) |
|
||||
|
||||
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
||||
|
||||
## 可选短文
|
||||
|
||||
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
||||
|
||||
- [community-guidelines.md](./community-guidelines.md) — 发帖约定
|
||||
- [feedback.md](./feedback.md) — 反馈与建议
|
||||
|
||||
复制正文时:从各文件第一个 `---` **之后**开始粘贴。
|
||||
185
docs/site-posts/about-jiang13.md
Normal file
185
docs/site-posts/about-jiang13.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 关于本站 · 姜十三论坛
|
||||
|
||||
> 建议标题:关于本站 · 姜十三论坛
|
||||
> 建议标签:`站务` `介绍`
|
||||
> 建议操作:置顶(可选精华)
|
||||
|
||||
---
|
||||
|
||||
你好,欢迎加入本站。
|
||||
|
||||
这里运行的是开源论坛 **姜十三论坛(Jiang13 Forum)**——面向小圈子、团队与同好社群,目标很明确:
|
||||
|
||||
**能聊 · 好看 · 好装**
|
||||
|
||||
下文介绍它是什么、怎么上手、有哪些能力,以及为何采用单文件部署。读完即可开始发帖;细节可随时回来查阅。
|
||||
|
||||
---
|
||||
|
||||
## 一、它是什么
|
||||
|
||||
姜十三论坛不是大而全的社区平台,而是一款轻量、现代化的论坛软件。
|
||||
|
||||
它做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
|
||||
|
||||
- 有板块、帖子、楼层回复,讨论能沉淀、能追溯
|
||||
- 界面清爽,信息密度适中,桌面与手机都好用
|
||||
- 单个程序即可部署,备份路径清晰
|
||||
|
||||
技术上,它是一个 **Go 单二进制**:前端页面内嵌,数据库用内置 **SQLite**。拷到服务器、启动、注册——就可以用。
|
||||
|
||||
### 谁适合用
|
||||
|
||||
| 场景 | 说明 |
|
||||
| --- | --- |
|
||||
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
|
||||
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
||||
| 项目配套社区 | 可与 Gitea 等做 SSO 联动 |
|
||||
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
|
||||
|
||||
若你需要百万级公网社区、复杂插件生态或企业级工单流,它可能不是最优选。
|
||||
若你要的是 **小而美、自己能装、自己能管**,就对了。
|
||||
|
||||
### 本板块说明
|
||||
|
||||
「站务公告」只发站务与介绍类内容。日常聊天、提问、分享,请到其他讨论板块。
|
||||
|
||||
---
|
||||
|
||||
## 二、三分钟上手
|
||||
|
||||
### 注册与登录
|
||||
|
||||
1. 打开注册页,填写用户名、邮箱与密码。
|
||||
2. 若已开启邮件服务,可能需要邮箱验证码。
|
||||
3. **第一个注册用户自动成为管理员**,无需安装向导。
|
||||
|
||||
登录后,右上角会出现头像与私信入口。
|
||||
|
||||
### 认识首页
|
||||
|
||||
| 区域 | 内容 |
|
||||
| --- | --- |
|
||||
| 左侧 | 板块导航、常用入口 |
|
||||
| 中间 | 帖子列表(Feed) |
|
||||
| 右侧 | 热门帖、最新评论等 |
|
||||
|
||||
列表可按 **最新发帖 / 最新回复 / 热门** 切换。
|
||||
支持浅色 / 暗色主题,偏好会记住。手机上侧栏会收起,不影响浏览与发帖。
|
||||
|
||||
### 发帖与回复
|
||||
|
||||
点「发帖」→ 选板块 → 写标题、标签(可选)与正文。
|
||||
正文支持富文本与图片上传。评论是 **楼层式** 的,可回复、引用、@。
|
||||
|
||||
帖子可点赞、收藏;公开主页可展示你的资料与动态。
|
||||
|
||||
### 个人入口
|
||||
|
||||
头像菜单常见项:个人主页、账号设置、站内私信、我的收藏。
|
||||
|
||||
### 管理后台(仅管理员)
|
||||
|
||||
访问 `/admin`:板块、帖子、评论、举报、用户、媒体、系统设置,以及备份等。
|
||||
|
||||
| 改什么 | 去哪里 |
|
||||
| --- | --- |
|
||||
| 端口、数据目录、DB、JWT | CLI / Env + `data/.jwt_secret`(改引导项需重启) |
|
||||
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
|
||||
|
||||
逛完板块,发第一帖或回一楼——聊起来就对了。
|
||||
|
||||
---
|
||||
|
||||
## 三、功能速览
|
||||
|
||||
功能刻意收在「小圈子真正用得到」的范围内。
|
||||
|
||||
### 浏览与发帖
|
||||
|
||||
- 三栏布局,桌面 / 移动自适应
|
||||
- Feed 排序
|
||||
- 浅色 / 暗色主题(可跟随系统);板块图标与主题色可配
|
||||
- Markdown 编辑器、标签、正文图片、内容门控
|
||||
- 楼层评论:回复、引用、嵌套树
|
||||
- 点赞、收藏;置顶 / 精华
|
||||
- 编辑修订历史
|
||||
- 可配置普通用户的编辑时限
|
||||
|
||||
### 社交与个人
|
||||
|
||||
- 公开用户主页
|
||||
- 会话式站内私信
|
||||
- 昵称、签名、头像等资料
|
||||
|
||||
### 管理后台
|
||||
|
||||
SSR 后台统一在 `/admin`:
|
||||
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
| 仪表盘 | 站点概览 |
|
||||
| 板块 | 创建、编辑、排序、外观 |
|
||||
| 帖子 | 审核、置顶、精华、锁定、回收站 |
|
||||
| 评论 / 举报 | 审核与处理 |
|
||||
| 用户 | 禁言等 |
|
||||
| 媒体 | 查看与清理上传 |
|
||||
| 设置 | 品牌、限流、敏感词、邮件、OIDC、存储等 |
|
||||
| 备份 | SQLite 一键备份 |
|
||||
|
||||
### 可选能力
|
||||
|
||||
按需在后台开启,不是装完就必须用:
|
||||
|
||||
- 邮件验证码注册
|
||||
- OIDC Provider(可作 Gitea 等登录源)
|
||||
- Gitea 公开仓库同步到「开源码桶」
|
||||
- 本地磁盘或 S3 兼容对象存储
|
||||
|
||||
### 权限
|
||||
|
||||
只有两种角色:**普通用户** / **管理员**。
|
||||
第一个注册用户即管理员。小圈子够用,不堆角色矩阵。
|
||||
|
||||
项目仍在积极开发。完整规划见仓库 `ROADMAP.md`,欢迎反馈与共建。
|
||||
|
||||
---
|
||||
|
||||
## 四、为什么是单文件部署
|
||||
|
||||
最硬核的一点,是部署路径极短——不是「理论上能打包」,而是日常用法就是:
|
||||
|
||||
1. 编译得到一个二进制
|
||||
2. 放到目录里运行
|
||||
3. 自动生成 `data/.jwt_secret`
|
||||
4. 浏览器注册,第一个账号即管理员
|
||||
|
||||
### 它补哪块空缺
|
||||
|
||||
论坛方案常落在两端:很重(功能全、运维贵),或很碎(聊天 / 文档方便,却缺少发帖沉淀)。
|
||||
小圈子往往需要中间态:有论坛结构、界面顺手、一个人能装、备份好懂。单文件部署,就是为后两点服务的。
|
||||
|
||||
### 具体意味着什么
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| 单二进制 | 前端已内嵌,不必单独部署前端目录 |
|
||||
| 内置 SQLite | 零外部数据库,数据在本地目录 |
|
||||
| Env / CLI 引导 | 端口、数据目录、数据库 |
|
||||
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
|
||||
| 系统服务 | 内置 Linux systemd / Windows Service |
|
||||
| 跨平台 | Windows / Linux / macOS 均可编译运行 |
|
||||
|
||||
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
|
||||
|
||||
进程级项改 Env/CLI 后重启;业务项在管理后台改。引导面保持精简,热更新走数据库。
|
||||
|
||||
---
|
||||
|
||||
## 写在最后
|
||||
|
||||
**姜十三论坛** = 小圈子该有的论坛能力 × 清新界面 × 真正能单文件带走的部署。
|
||||
|
||||
源码与说明见项目仓库。许可证:MIT。
|
||||
|
||||
有问题可联系管理员。祝你在这里聊得愉快。
|
||||
30
docs/site-posts/community-guidelines.md
Normal file
30
docs/site-posts/community-guidelines.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 本站发帖约定
|
||||
|
||||
> 建议标题:本站发帖约定
|
||||
> 建议标签:`站务` `公约`
|
||||
> 建议操作:普通发布(可选)
|
||||
|
||||
---
|
||||
|
||||
本站是小圈子论坛,约定尽量少、好遵守。
|
||||
|
||||
## 请这样做
|
||||
|
||||
- 选对板块再发;不确定就发到综合讨论类板块
|
||||
- 标题写清楚在说什么,方便后人搜索
|
||||
- 讨论对事不对人;引用、回复尽量指向具体楼层
|
||||
- 分享链接时简单说明来源与用途
|
||||
|
||||
## 请避免
|
||||
|
||||
- 在「站务公告」灌水、闲聊(请到其他板块)
|
||||
- 无意义刷屏、重复灌帖
|
||||
- 人身攻击、骚扰、泄露他人隐私
|
||||
- 明显的广告、引流(除非板块明确允许)
|
||||
|
||||
## 管理说明
|
||||
|
||||
管理员可能会:移动板块、编辑标题、锁定、删除或禁言。
|
||||
有争议可私信管理员说明情况。
|
||||
|
||||
一句话:**把讨论留给值得留下的内容。**
|
||||
26
docs/site-posts/feedback.md
Normal file
26
docs/site-posts/feedback.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 反馈与建议
|
||||
|
||||
> 建议标题:反馈与建议
|
||||
> 建议标签:`站务` `反馈`
|
||||
> 建议操作:普通发布(可选)
|
||||
|
||||
---
|
||||
|
||||
本站程序是开源的 **姜十三论坛**,仍在积极开发。欢迎反馈。
|
||||
|
||||
## 站内怎么说
|
||||
|
||||
- **功能建议 / 体验吐槽**:开新帖,尽量写清场景与期望
|
||||
- **疑似缺陷**:说明步骤、浏览器、是否可复现;有截图更好
|
||||
- **站务问题**(注册、权限、内容处理):私信管理员
|
||||
|
||||
## 开发仓库(可选)
|
||||
|
||||
若你也参与程序本身:
|
||||
|
||||
- 查路线图、提 Issue / PR:见项目仓库
|
||||
- 许可证:MIT
|
||||
|
||||
站内讨论与仓库 Issue 不必重复提交;选一处说清楚即可。
|
||||
|
||||
感谢一起把小圈子聊好。
|
||||
10
docs/site-posts/optional.md
Normal file
10
docs/site-posts/optional.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 可选短文(不与介绍长文重复)
|
||||
|
||||
长文 [`about-jiang13.md`](./about-jiang13.md) 置顶后,原先分篇的「上手 / 功能 / 部署」建议 **直接删除**。
|
||||
|
||||
若希望「站务公告」里还有几篇配套短帖,可用下面两篇(按需发,不必全发)。
|
||||
|
||||
| 文件 | 标题 | 标签 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| [community-guidelines.md](./community-guidelines.md) | 本站发帖约定 | `站务` `公约` | 轻量规则,避免灌水 |
|
||||
| [feedback.md](./feedback.md) | 反馈与建议 | `站务` `反馈` | 引导提问题、提想法 |
|
||||
76
go.mod
Normal file
76
go.mod
Normal file
@@ -0,0 +1,76 @@
|
||||
module git.iioio.com/freefire/jiang13-forum
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/KarpelesLab/gowebp v0.1.1
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/image v0.44.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
177
go.sum
Normal file
177
go.sum
Normal file
@@ -0,0 +1,177 @@
|
||||
github.com/KarpelesLab/gowebp v0.1.1 h1:W11ZrRVx+Zk4ypW5NBEU31FQzghICXIrAbAbO5yd4M0=
|
||||
github.com/KarpelesLab/gowebp v0.1.1/go.mod h1:Js8OXPQ94yl94HqaO/9XuUqk0wOPod6uycryhzmTgsU=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
|
||||
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
177
models/db.go
Normal file
177
models/db.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// DatabaseConfig 与 config.DatabaseConfig 对齐的精简结构(避免 models→config 循环依赖)
|
||||
type DatabaseConfig struct {
|
||||
Type string
|
||||
DSN string
|
||||
SQLitePath string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetimeSec int
|
||||
}
|
||||
|
||||
// InitDB 按方言初始化数据库并自动迁移
|
||||
func InitDB(cfg DatabaseConfig) error {
|
||||
typ := strings.ToLower(strings.TrimSpace(cfg.Type))
|
||||
if typ == "" {
|
||||
typ = "sqlite"
|
||||
}
|
||||
|
||||
dialector, err := openDialector(typ, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接数据库失败 (%s): %w — 请检查 JIANG13_DB_TYPE / JIANG13_DB_DSN", typ, err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.MaxOpenConns > 0 {
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
}
|
||||
if cfg.MaxIdleConns > 0 {
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
}
|
||||
if cfg.ConnMaxLifetimeSec > 0 {
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSec) * time.Second)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Board{}, &Post{}, &Comment{},
|
||||
&PostLike{}, &CommentLike{}, &PostFavorite{}, &PostRevision{}, &CommentRevision{}, &ForumSetting{},
|
||||
&OAuthClient{}, &OAuthAuthCode{},
|
||||
&GiteaRepo{},
|
||||
&PrivateMessage{}, &PostReport{}, &FriendLinkApply{},
|
||||
&Media{},
|
||||
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
|
||||
&BadgeDef{}, &UserBadge{},
|
||||
&SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
|
||||
&Session{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
_ = db.Model(&Post{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
|
||||
_ = db.Model(&Comment{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
|
||||
_ = db.Model(&Post{}).Where("post_type = '' OR post_type IS NULL").Update("post_type", PostTypeNormal).Error
|
||||
|
||||
DB = db
|
||||
seedDefaultBadges(db)
|
||||
backfillUserExp(db)
|
||||
log.Printf("[models] 数据库初始化完成 type=%s", typ)
|
||||
return nil
|
||||
}
|
||||
|
||||
func openDialector(typ string, cfg DatabaseConfig) (gorm.Dialector, error) {
|
||||
switch typ {
|
||||
case "sqlite", "sqlite3":
|
||||
path := cfg.SQLitePath
|
||||
if path == "" {
|
||||
path = cfg.DSN
|
||||
}
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("sqlite 需要文件路径")
|
||||
}
|
||||
if path != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
return sqlite.Open(path), nil
|
||||
case "postgres", "postgresql", "pg":
|
||||
if cfg.DSN == "" {
|
||||
return nil, fmt.Errorf("postgres 需要 JIANG13_DB_DSN 或 HOST/USER/NAME")
|
||||
}
|
||||
return postgres.Open(cfg.DSN), nil
|
||||
case "mysql", "mariadb":
|
||||
if cfg.DSN == "" {
|
||||
return nil, fmt.Errorf("mysql 需要 JIANG13_DB_DSN 或 HOST/USER/NAME")
|
||||
}
|
||||
return mysql.Open(cfg.DSN), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型 %q", typ)
|
||||
}
|
||||
}
|
||||
|
||||
// DialectorName 当前驱动名(sqlite/postgres/mysql)
|
||||
func DialectorName() string {
|
||||
if DB == nil {
|
||||
return ""
|
||||
}
|
||||
return DB.Dialector.Name()
|
||||
}
|
||||
|
||||
// PingDB 检测数据库连接是否可用
|
||||
func PingDB() error {
|
||||
if DB == nil {
|
||||
return fmt.Errorf("数据库未初始化")
|
||||
}
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Ping()
|
||||
}
|
||||
|
||||
// seedDefaultBadges 写入内置自动徽章(已存在则跳过)
|
||||
func seedDefaultBadges(db *gorm.DB) {
|
||||
defs := []BadgeDef{
|
||||
{Code: "tenure_30", Name: "初来乍到", Description: "注册满 30 天", Icon: "calendar", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 30, SortOrder: 10, Enabled: true},
|
||||
{Code: "tenure_365", Name: "常驻居民", Description: "注册满 365 天", Icon: "calendar-heart", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 365, SortOrder: 20, Enabled: true},
|
||||
{Code: "likes_10", Name: "小有人气", Description: "帖子获赞累计 10", Icon: "heart", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 10, SortOrder: 30, Enabled: true},
|
||||
{Code: "likes_100", Name: "人气作者", Description: "帖子获赞累计 100", Icon: "heart-handshake", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 100, SortOrder: 40, Enabled: true},
|
||||
{Code: "likes_1000", Name: "超级人气", Description: "帖子获赞累计 1000", Icon: "flame", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 1000, SortOrder: 50, Enabled: true},
|
||||
{Code: "income_100", Name: "小有进账", Description: "创作者分成累计 100 积分", Icon: "coins", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 100, SortOrder: 60, Enabled: true},
|
||||
{Code: "income_1000", Name: "创收达人", Description: "创作者分成累计 1000 积分", Icon: "gem", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 1000, SortOrder: 70, Enabled: true},
|
||||
}
|
||||
for _, d := range defs {
|
||||
var n int64
|
||||
db.Model(&BadgeDef{}).Where("code = ?", d.Code).Count(&n)
|
||||
if n == 0 {
|
||||
_ = db.Create(&d).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// backfillUserExp 对 Exp 仍为 0 的用户按发帖/评论/获赞粗算经验(仅补一次语义)
|
||||
func backfillUserExp(db *gorm.DB) {
|
||||
var users []User
|
||||
if err := db.Select("id", "exp").Where("exp = 0").Find(&users).Error; err != nil {
|
||||
return
|
||||
}
|
||||
for _, u := range users {
|
||||
var posts, comments int64
|
||||
var likeSum int64
|
||||
_ = db.Model(&Post{}).Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Count(&posts).Error
|
||||
_ = db.Model(&Comment{}).Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Count(&comments).Error
|
||||
_ = db.Model(&Post{}).Select("COALESCE(SUM(like_count), 0)").Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Scan(&likeSum).Error
|
||||
exp := int(posts)*10 + int(comments)*2 + int(likeSum)
|
||||
if exp > 0 {
|
||||
_ = db.Model(&User{}).Where("id = ? AND exp = 0", u.ID).Update("exp", exp).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
27
models/gitea.go
Normal file
27
models/gitea.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// GiteaRepo 从 Gitea 同步的公开仓库缓存(侧栏 /projects 读取)
|
||||
type GiteaRepo struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
GiteaID int64 `gorm:"uniqueIndex;not null" json:"gitea_id"`
|
||||
OwnerLogin string `gorm:"size:128;not null;index" json:"owner_login"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
FullName string `gorm:"size:512;not null" json:"full_name"`
|
||||
Description string `gorm:"size:2048;default:''" json:"description"`
|
||||
HTMLURL string `gorm:"size:1024;not null" json:"html_url"`
|
||||
Language string `gorm:"size:64;default:''" json:"language"`
|
||||
StarsCount int `gorm:"default:0" json:"stars_count"`
|
||||
ForksCount int `gorm:"default:0" json:"forks_count"`
|
||||
Private bool `gorm:"default:false" json:"private"`
|
||||
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
||||
ForumUserID *uint `gorm:"index" json:"forum_user_id"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (GiteaRepo) TableName() string {
|
||||
return "gitea_repos"
|
||||
}
|
||||
34
models/level.go
Normal file
34
models/level.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
// LevelThresholds 各等级所需最低 Exp(下标 0 对应 Lv1)
|
||||
var LevelThresholds = []int{0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000}
|
||||
|
||||
// LevelFromExp 由经验推导等级(1–10)
|
||||
func LevelFromExp(exp int) int {
|
||||
if exp < 0 {
|
||||
exp = 0
|
||||
}
|
||||
level := 1
|
||||
for i, th := range LevelThresholds {
|
||||
if exp >= th {
|
||||
level = i + 1
|
||||
}
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// ExpForLevel 某等级的门槛 Exp(超出范围则钳制)
|
||||
func ExpForLevel(level int) int {
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
if level > len(LevelThresholds) {
|
||||
level = len(LevelThresholds)
|
||||
}
|
||||
return LevelThresholds[level-1]
|
||||
}
|
||||
|
||||
// MaxLevel 最高等级
|
||||
func MaxLevel() int {
|
||||
return len(LevelThresholds)
|
||||
}
|
||||
467
models/models.go
Normal file
467
models/models.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Role 用户角色
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
// 内容审核状态(帖子 / 评论)
|
||||
const (
|
||||
ContentStatusPending = "pending" // 审核中(仅作者与管理员可见)
|
||||
ContentStatusPublished = "published" // 已公开
|
||||
ContentStatusRejected = "rejected" // 未通过(仅作者与管理员可见)
|
||||
)
|
||||
|
||||
// 帖子类型
|
||||
const (
|
||||
PostTypeNormal = "normal" // 普通讨论
|
||||
PostTypeQuestion = "question" // 问答(未解决 / 已解决)
|
||||
PostTypePoll = "poll" // 投票
|
||||
PostTypeBounty = "bounty" // 悬赏
|
||||
PostTypeLottery = "lottery" // 抽奖
|
||||
)
|
||||
|
||||
// 悬赏状态
|
||||
const (
|
||||
BountyStatusOpen = "open"
|
||||
BountyStatusAwarded = "awarded"
|
||||
BountyStatusRefunded = "refunded"
|
||||
)
|
||||
|
||||
// 帖内抽奖状态
|
||||
const (
|
||||
PostLotteryStatusOpen = "open"
|
||||
PostLotteryStatusDrawn = "drawn"
|
||||
)
|
||||
|
||||
// User 用户表
|
||||
// Email / Password / LastLogin* / LastAccessAt 默认不随帖子等嵌套 User 序列化;
|
||||
// 个人中心与后台列表请用 UserSelf / UserAdmin。
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
|
||||
Email string `gorm:"index;size:128;default:''" json:"-"`
|
||||
Password string `gorm:"size:128;not null" json:"-"`
|
||||
Nickname string `gorm:"size:64" json:"nickname"`
|
||||
Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
|
||||
Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL
|
||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||
Verified bool `gorm:"default:false;index" json:"verified"` // 站长认证:免审发帖/评论
|
||||
Exp int `gorm:"default:0" json:"exp"` // 经验(不可消费)
|
||||
Points int `gorm:"default:0" json:"points"` // 可用积分
|
||||
CreatorIncomeTotal int `gorm:"default:0" json:"creator_income_total"` // 累计创作分成
|
||||
Banned bool `gorm:"default:false" json:"banned"`
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
LastLoginAt *time.Time `json:"-"`
|
||||
LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
|
||||
LastAccessAt *time.Time `json:"-"` // 最近一次带鉴权的访问(与登录分开)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 仅序列化展示用,不落库
|
||||
Level int `gorm:"-" json:"level"`
|
||||
Badges []UserBadgeView `gorm:"-" json:"badges,omitempty"`
|
||||
}
|
||||
|
||||
// AfterFind 填充展示用等级
|
||||
func (u *User) AfterFind(tx *gorm.DB) (err error) {
|
||||
u.Level = LevelFromExp(u.Exp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SkipsModeration 站长或认证用户发帖/评论免审
|
||||
func (u *User) SkipsModeration() bool {
|
||||
return u != nil && (u.Role == RoleAdmin || u.Verified)
|
||||
}
|
||||
|
||||
// Board 论坛板块
|
||||
type Board struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
Icon string `gorm:"size:64;default:''" json:"icon"`
|
||||
ColorIndex int `gorm:"default:-1" json:"color_index"` // -1 表示按 id 自动取色
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// Post 帖子
|
||||
type Post struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
BoardID uint `gorm:"index;not null" json:"board_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Title string `gorm:"size:256;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
|
||||
Tags string `gorm:"size:256" json:"tags"`
|
||||
PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery
|
||||
QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
|
||||
BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty)
|
||||
BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded
|
||||
BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
|
||||
LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
|
||||
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
|
||||
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
|
||||
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
|
||||
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
||||
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
||||
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Comments []Comment `gorm:"foreignKey:PostID" json:"comments,omitempty"`
|
||||
}
|
||||
|
||||
// PostRevision 帖子编辑历史(每次修改前保存旧版本)
|
||||
type PostRevision struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
EditorID uint `gorm:"index;not null" json:"editor_id"`
|
||||
Title string `gorm:"size:256;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Tags string `gorm:"size:256" json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
Editor User `gorm:"foreignKey:EditorID" json:"editor,omitempty"`
|
||||
}
|
||||
|
||||
// CommentRevision 评论编辑历史(每次修改前保存旧版本,管理员可查看)
|
||||
type CommentRevision struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CommentID uint `gorm:"index;not null" json:"comment_id"`
|
||||
EditorID uint `gorm:"index;not null" json:"editor_id"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
Editor User `gorm:"foreignKey:EditorID" json:"editor,omitempty"`
|
||||
}
|
||||
|
||||
// ForumSetting 论坛全局设置(键值对)
|
||||
type ForumSetting struct {
|
||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||
Value string `gorm:"size:2048" json:"value"`
|
||||
}
|
||||
|
||||
// Comment 楼层评论
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"` // 0 表示游客
|
||||
Floor int `gorm:"not null" json:"floor"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
ReplyTo *uint `gorm:"index" json:"reply_to,omitempty"`
|
||||
GuestNick string `gorm:"size:64" json:"guest_nick,omitempty"`
|
||||
GuestEmail string `gorm:"size:128" json:"guest_email,omitempty"`
|
||||
GuestURL string `gorm:"size:256" json:"guest_url,omitempty"`
|
||||
IsPrivate bool `gorm:"default:false" json:"is_private"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||
ReplyTarget *Comment `gorm:"-" json:"reply_target,omitempty"`
|
||||
// ThreadParentID 嵌套展示用父评论(父评论不可见时回挂到最近可见祖先)
|
||||
ThreadParentID *uint `gorm:"-" json:"thread_parent_id,omitempty"`
|
||||
ContentHidden bool `gorm:"-" json:"content_hidden"`
|
||||
Liked bool `gorm:"-" json:"liked"`
|
||||
}
|
||||
|
||||
// PostLike 帖子点赞
|
||||
type PostLike struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CommentLike 评论点赞
|
||||
type CommentLike struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CommentID uint `gorm:"uniqueIndex:idx_comment_user;not null" json:"comment_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_comment_user;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PostFavorite 帖子收藏
|
||||
type PostFavorite struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||
}
|
||||
|
||||
// 私信类型
|
||||
const (
|
||||
MessageKindUser = "user" // 用户互发
|
||||
MessageKindSystem = "system" // 系统通知
|
||||
MessageKindReject = "reject" // 帖子被拒/下架
|
||||
MessageKindReportResult = "report_result" // 举报处理结果
|
||||
MessageKindReply = "reply" // 帖子/评论被回复
|
||||
MessageKindMention = "mention" // 被 @提及
|
||||
MessageKindModeration = "moderation" // 新内容待审核(通知管理员)
|
||||
)
|
||||
|
||||
// PrivateMessage 站内私信
|
||||
type PrivateMessage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
FromUserID uint `gorm:"index;not null" json:"from_user_id"` // 0 表示系统
|
||||
ToUserID uint `gorm:"index;not null" json:"to_user_id"`
|
||||
Subject string `gorm:"size:256;not null" json:"subject"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Kind string `gorm:"size:32;default:user;index" json:"kind"`
|
||||
RelatedPostID *uint `gorm:"index" json:"related_post_id,omitempty"`
|
||||
RelatedReportID *uint `gorm:"index" json:"related_report_id,omitempty"`
|
||||
IsRead bool `gorm:"default:false;index" json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
FromUser User `gorm:"foreignKey:FromUserID" json:"from_user,omitempty"`
|
||||
ToUser User `gorm:"foreignKey:ToUserID" json:"to_user,omitempty"`
|
||||
}
|
||||
|
||||
// 举报状态
|
||||
const (
|
||||
ReportStatusPending = "pending"
|
||||
ReportStatusResolved = "resolved"
|
||||
ReportStatusDismissed = "dismissed"
|
||||
)
|
||||
|
||||
// 举报原因
|
||||
const (
|
||||
ReportReasonSpam = "spam"
|
||||
ReportReasonAbuse = "abuse"
|
||||
ReportReasonIllegal = "illegal"
|
||||
ReportReasonIrrelevant = "irrelevant"
|
||||
ReportReasonOther = "other"
|
||||
)
|
||||
|
||||
// 友链申请状态
|
||||
const (
|
||||
FriendLinkApplyStatusPending = "pending"
|
||||
FriendLinkApplyStatusApproved = "approved"
|
||||
FriendLinkApplyStatusRejected = "rejected"
|
||||
)
|
||||
|
||||
// FriendLinkApply 友情链接申请
|
||||
type FriendLinkApply struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:32;not null" json:"name"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
Description string `gorm:"size:200;default:''" json:"description,omitempty"`
|
||||
Logo string `gorm:"size:512;default:''" json:"logo"`
|
||||
ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"`
|
||||
LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"`
|
||||
ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"`
|
||||
ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"`
|
||||
ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"`
|
||||
Status string `gorm:"size:16;default:pending;index" json:"status"`
|
||||
ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// PostReport 帖子/评论举报(CommentID 有值时为评论举报)
|
||||
type PostReport struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
CommentID *uint `gorm:"index" json:"comment_id,omitempty"`
|
||||
ReporterID uint `gorm:"index;not null" json:"reporter_id"`
|
||||
Reason string `gorm:"size:32;not null" json:"reason"`
|
||||
Detail string `gorm:"size:1000" json:"detail"`
|
||||
Status string `gorm:"size:16;default:pending;index" json:"status"`
|
||||
HandlerID *uint `gorm:"index" json:"handler_id,omitempty"`
|
||||
HandleNote string `gorm:"size:1000" json:"handle_note"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||||
|
||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||
Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"`
|
||||
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
|
||||
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
|
||||
}
|
||||
|
||||
// Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计)
|
||||
type Media struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Category string `gorm:"size:32;index;not null" json:"category"` // avatars|posts|site
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
URL string `gorm:"size:512;uniqueIndex;not null" json:"url"`
|
||||
Size int64 `gorm:"not null;default:0" json:"size"`
|
||||
ContentType string `gorm:"size:64;default:''" json:"content_type"`
|
||||
StorageType string `gorm:"size:16;index;default:local" json:"storage_type"` // local|s3
|
||||
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// 积分流水原因
|
||||
const (
|
||||
PointReasonCheckIn = "check_in"
|
||||
PointReasonLottery = "lottery"
|
||||
PointReasonUnlockSpend = "unlock_spend"
|
||||
PointReasonCreatorIncome = "creator_income"
|
||||
PointReasonAdminAdjust = "admin_adjust"
|
||||
PointReasonBountyEscrow = "bounty_escrow"
|
||||
PointReasonBountyAward = "bounty_award"
|
||||
PointReasonBountyRefund = "bounty_refund"
|
||||
)
|
||||
|
||||
// PointLedger 积分流水
|
||||
type PointLedger struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Delta int `gorm:"not null" json:"delta"`
|
||||
Balance int `gorm:"not null" json:"balance"` // 变动后余额
|
||||
Reason string `gorm:"size:32;index;not null" json:"reason"`
|
||||
RefType string `gorm:"size:32;default:''" json:"ref_type"`
|
||||
RefID uint `gorm:"default:0" json:"ref_id"`
|
||||
Note string `gorm:"size:256;default:''" json:"note"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CheckIn 每日签到(用户+自然日唯一)
|
||||
type CheckIn struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_checkin_user_day;not null" json:"user_id"`
|
||||
Day string `gorm:"uniqueIndex:idx_checkin_user_day;size:10;not null" json:"day"` // YYYY-MM-DD
|
||||
Points int `gorm:"not null" json:"points"`
|
||||
Streak int `gorm:"default:1" json:"streak"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// LotteryDraw 每日抽奖记录
|
||||
type LotteryDraw struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_lottery_user_day;not null" json:"user_id"`
|
||||
Day string `gorm:"uniqueIndex:idx_lottery_user_day;size:10;not null" json:"day"`
|
||||
Points int `gorm:"not null" json:"points"` // 抽中积分(可为 0)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PostContentUnlock 帖子积分解锁记录
|
||||
type PostContentUnlock struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_unlock_user_post_block;not null" json:"user_id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_unlock_user_post_block;index;not null" json:"post_id"`
|
||||
BlockKey string `gorm:"uniqueIndex:idx_unlock_user_post_block;size:64;not null" json:"block_key"`
|
||||
Cost int `gorm:"not null" json:"cost"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SitePage 自定义单页(关于我们、版规等)
|
||||
type SitePage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Published bool `gorm:"default:false;index" json:"published"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
ShowInFooter bool `gorm:"default:false" json:"show_in_footer"`
|
||||
ShowInNav bool `gorm:"default:false" json:"show_in_nav"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// Poll 投票帖配置
|
||||
type Poll struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex;not null" json:"post_id"`
|
||||
Multi bool `gorm:"default:false" json:"multi"`
|
||||
MaxChoices int `gorm:"default:1" json:"max_choices"`
|
||||
Closed bool `gorm:"default:false;index" json:"closed"`
|
||||
EndsAt *time.Time `gorm:"index" json:"ends_at,omitempty"`
|
||||
}
|
||||
|
||||
// PollOption 投票选项
|
||||
type PollOption struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
Text string `gorm:"size:64;not null" json:"text"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
VoteCount int `gorm:"default:0" json:"vote_count"`
|
||||
}
|
||||
|
||||
// PollVote 投票记录
|
||||
type PollVote struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"post_id"`
|
||||
OptionID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"option_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"user_id"`
|
||||
}
|
||||
|
||||
// PostLotteryWinner 帖内抽奖中奖记录
|
||||
type PostLotteryWinner struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
CommentID uint `gorm:"default:0" json:"comment_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// 徽章类型
|
||||
const (
|
||||
BadgeKindAuto = "auto"
|
||||
BadgeKindLimited = "limited"
|
||||
)
|
||||
|
||||
// 自动徽章指标
|
||||
const (
|
||||
BadgeMetricTenureDays = "tenure_days"
|
||||
BadgeMetricLikesReceived = "likes_received"
|
||||
BadgeMetricCreatorIncome = "creator_income"
|
||||
)
|
||||
|
||||
// BadgeDef 徽章定义
|
||||
type BadgeDef struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Code string `gorm:"uniqueIndex;size:64;not null" json:"code"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Description string `gorm:"size:256;default:''" json:"description"`
|
||||
Icon string `gorm:"size:64;default:''" json:"icon"` // lucide / 固定 key
|
||||
Kind string `gorm:"size:16;index;not null" json:"kind"` // auto|limited
|
||||
Metric string `gorm:"size:32;default:''" json:"metric"`
|
||||
Threshold int `gorm:"default:0" json:"threshold"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
Enabled bool `gorm:"default:true;index" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserBadge 用户已获徽章
|
||||
type UserBadge struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_user_badge;not null" json:"user_id"`
|
||||
BadgeID uint `gorm:"uniqueIndex:idx_user_badge;index;not null" json:"badge_id"`
|
||||
AwardedAt time.Time `json:"awarded_at"`
|
||||
AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统
|
||||
Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"`
|
||||
}
|
||||
31
models/oauth.go
Normal file
31
models/oauth.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// OAuthClient OIDC/OAuth2 客户端应用(密钥仅存 bcrypt 哈希)
|
||||
type OAuthClient struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClientID string `gorm:"uniqueIndex;size:128;not null" json:"client_id"`
|
||||
ClientSecretHash string `gorm:"size:128;not null" json:"-"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
RedirectURIs string `gorm:"size:2048;not null" json:"redirect_uris"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OAuthAuthCode 一次性授权码(OIDC Authorization Code Flow)
|
||||
type OAuthAuthCode struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Code string `gorm:"uniqueIndex;size:64;not null"`
|
||||
ClientID string `gorm:"size:128;not null;index"`
|
||||
UserID uint `gorm:"not null;index"`
|
||||
RedirectURI string `gorm:"size:512;not null"`
|
||||
Scope string `gorm:"size:256;default:''"`
|
||||
Nonce string `gorm:"size:128;default:''"`
|
||||
CodeChallenge string `gorm:"size:128;default:''"`
|
||||
CodeChallengeMethod string `gorm:"size:16;default:''"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
Used bool `gorm:"default:false"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
14
models/session.go
Normal file
14
models/session.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Session 浏览器 opaque 会话(Cookie 只存 Id)
|
||||
type Session struct {
|
||||
ID string `gorm:"primaryKey;size:64" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
IP string `gorm:"size:45" json:"ip,omitempty"`
|
||||
UserAgent string `gorm:"size:256" json:"user_agent,omitempty"`
|
||||
}
|
||||
149
models/user_view.go
Normal file
149
models/user_view.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UserBadgeView 对外展示的徽章摘要
|
||||
type UserBadgeView struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// UserPublic 公开用户主页视图(无邮箱与登录信息)
|
||||
type UserPublic struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Signature string `json:"signature"`
|
||||
Avatar string `json:"avatar"`
|
||||
Role Role `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
Exp int `json:"exp"`
|
||||
Level int `json:"level"`
|
||||
CreatorIncomeTotal int `json:"creator_income_total"`
|
||||
Badges []UserBadgeView `json:"badges,omitempty"`
|
||||
Banned bool `json:"banned"`
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserSelf 当前登录用户视图(含邮箱,不含登录 IP)
|
||||
type UserSelf struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Signature string `json:"signature"`
|
||||
Avatar string `json:"avatar"`
|
||||
Role Role `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
Exp int `json:"exp"`
|
||||
Level int `json:"level"`
|
||||
Points int `json:"points"`
|
||||
CreatorIncomeTotal int `json:"creator_income_total"`
|
||||
Badges []UserBadgeView `json:"badges,omitempty"`
|
||||
Banned bool `json:"banned"`
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserAdmin 后台用户管理视图(含邮箱、上次登录与最近访问)
|
||||
type UserAdmin struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Signature string `json:"signature"`
|
||||
Avatar string `json:"avatar"`
|
||||
Role Role `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
Exp int `json:"exp"`
|
||||
Level int `json:"level"`
|
||||
Points int `json:"points"`
|
||||
CreatorIncomeTotal int `json:"creator_income_total"`
|
||||
Banned bool `json:"banned"`
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
LastLoginIP string `json:"last_login_ip,omitempty"`
|
||||
LastAccessAt *time.Time `json:"last_access_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ToPublic 转为公开主页视图
|
||||
func (u *User) ToPublic() UserPublic {
|
||||
return UserPublic{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Signature: u.Signature,
|
||||
Avatar: u.Avatar,
|
||||
Role: u.Role,
|
||||
Verified: u.Verified,
|
||||
Exp: u.Exp,
|
||||
Level: LevelFromExp(u.Exp),
|
||||
CreatorIncomeTotal: u.CreatorIncomeTotal,
|
||||
Banned: u.Banned,
|
||||
BannedAt: u.BannedAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToSelf 转为个人中心 /api/me 视图
|
||||
func (u *User) ToSelf() UserSelf {
|
||||
return UserSelf{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Nickname: u.Nickname,
|
||||
Signature: u.Signature,
|
||||
Avatar: u.Avatar,
|
||||
Role: u.Role,
|
||||
Verified: u.Verified,
|
||||
Exp: u.Exp,
|
||||
Level: LevelFromExp(u.Exp),
|
||||
Points: u.Points,
|
||||
CreatorIncomeTotal: u.CreatorIncomeTotal,
|
||||
Banned: u.Banned,
|
||||
BannedAt: u.BannedAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAdmin 转为后台用户列表视图
|
||||
func (u *User) ToAdmin() UserAdmin {
|
||||
return UserAdmin{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Nickname: u.Nickname,
|
||||
Signature: u.Signature,
|
||||
Avatar: u.Avatar,
|
||||
Role: u.Role,
|
||||
Verified: u.Verified,
|
||||
Exp: u.Exp,
|
||||
Level: LevelFromExp(u.Exp),
|
||||
Points: u.Points,
|
||||
CreatorIncomeTotal: u.CreatorIncomeTotal,
|
||||
Banned: u.Banned,
|
||||
BannedAt: u.BannedAt,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
LastLoginIP: u.LastLoginIP,
|
||||
LastAccessAt: u.LastAccessAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// UsersToAdmin 批量转换后台用户列表
|
||||
func UsersToAdmin(users []User) []UserAdmin {
|
||||
out := make([]UserAdmin, 0, len(users))
|
||||
for i := range users {
|
||||
out = append(out, users[i].ToAdmin())
|
||||
}
|
||||
return out
|
||||
}
|
||||
178
modules/auth/auth.go
Normal file
178
modules/auth/auth.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
CtxRole = "role"
|
||||
CtxSessionID = "session_id"
|
||||
CookieName = "jiang13_session"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
auth *services.AuthService
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(auth *services.AuthService) *AuthMiddleware {
|
||||
return &AuthMiddleware{auth: auth}
|
||||
}
|
||||
|
||||
// OptionalAuth 可选鉴权:有会话则加载用户;禁言/无效则清 Cookie
|
||||
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sid := extractSessionID(c)
|
||||
if sid == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
user, sess, err := services.ResolveSession(sid)
|
||||
if err != nil || user == nil {
|
||||
ClearAuthCookie(c)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
services.RevokeUserSessions(user.ID)
|
||||
ClearAuthCookie(c)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, user.ID)
|
||||
c.Set(CtxUsername, user.Username)
|
||||
c.Set(CtxRole, user.Role)
|
||||
c.Set(CtxSessionID, sess.ID)
|
||||
m.auth.TouchLastAccess(user.ID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth 必须登录且未禁言
|
||||
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sid := extractSessionID(c)
|
||||
if sid == "" {
|
||||
respondAuthRequired(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
user, sess, err := services.ResolveSession(sid)
|
||||
if err != nil || user == nil {
|
||||
respondAuthExpired(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
services.RevokeUserSessions(user.ID)
|
||||
ClearAuthCookie(c)
|
||||
respondBanned(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, user.ID)
|
||||
c.Set(CtxUsername, user.Username)
|
||||
c.Set(CtxRole, user.Role)
|
||||
c.Set(CtxSessionID, sess.ID)
|
||||
m.auth.TouchLastAccess(user.ID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin 必须管理员(依赖上游已跑 OptionalAuth/RequireAuth,role 来自 DB)
|
||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, ok := c.Get(CtxUserID)
|
||||
if !ok || uid == nil {
|
||||
respondAuthRequired(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
role, exists := c.Get(CtxRole)
|
||||
if !exists || role != models.RoleAdmin {
|
||||
if isAPI(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractSessionID(c *gin.Context) string {
|
||||
if sid, err := c.Cookie(CookieName); err == nil && sid != "" {
|
||||
return sid
|
||||
}
|
||||
// 兼容清理旧 Cookie 名(一次性)
|
||||
if old, err := c.Cookie("jiang13_token"); err == nil && old != "" {
|
||||
ClearNamedCookie(c, "jiang13_token")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAPI(c *gin.Context) bool {
|
||||
return strings.HasPrefix(c.Request.URL.Path, "/api/")
|
||||
}
|
||||
|
||||
func adminLoginPath(c *gin.Context) string {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
|
||||
return "/login?redirect=" + url.QueryEscape("/admin/dashboard")
|
||||
}
|
||||
return "/login"
|
||||
}
|
||||
|
||||
func respondAuthRequired(c *gin.Context) {
|
||||
if isAPI(c) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
redir := c.Request.URL.RequestURI()
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
|
||||
redir = "/admin/dashboard"
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login?redirect="+url.QueryEscape(redir))
|
||||
}
|
||||
|
||||
func respondAuthExpired(c *gin.Context) {
|
||||
ClearAuthCookie(c)
|
||||
if isAPI(c) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期"})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, adminLoginPath(c))
|
||||
}
|
||||
|
||||
func respondBanned(c *gin.Context) {
|
||||
if isAPI(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login?banned=1")
|
||||
}
|
||||
|
||||
// RateLimitMiddleware 限流中间件
|
||||
func RateLimitMiddleware(limiter *services.RateLimiter, action string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.ClientIP()
|
||||
if uid, ok := c.Get(CtxUserID); ok {
|
||||
key = fmt.Sprintf("%d", uid.(uint))
|
||||
}
|
||||
if !limiter.Allow(action, key) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
43
modules/auth/cookie.go
Normal file
43
modules/auth/cookie.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ClearAuthCookie 清除登录会话 Cookie
|
||||
func ClearAuthCookie(c *gin.Context) {
|
||||
ClearNamedCookie(c, CookieName)
|
||||
ClearNamedCookie(c, "jiang13_token") // 清旧名
|
||||
}
|
||||
|
||||
// ClearNamedCookie 按名清除
|
||||
func ClearNamedCookie(c *gin.Context, name string) {
|
||||
secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// SetSessionCookie 写入 opaque session id
|
||||
func SetSessionCookie(c *gin.Context, sessionID string) {
|
||||
secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: services.SessionCookieMaxAge(),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
16
modules/seo/pagemeta.go
Normal file
16
modules/seo/pagemeta.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package seo
|
||||
|
||||
// PageMeta 页面级 SEO / 社交预览元数据(SSR 模板 head)
|
||||
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
|
||||
}
|
||||
181
modules/webctx/context.go
Normal file
181
modules/webctx/context.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package webctx
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webrender"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
csrfCookie = "jiang13_csrf"
|
||||
flashCookie = "jiang13_flash"
|
||||
)
|
||||
|
||||
// Context 浏览器请求上下文(对齐 Gitea context 的精简版)
|
||||
type Context struct {
|
||||
C *gin.Context
|
||||
Doer *models.User
|
||||
Secret string
|
||||
}
|
||||
|
||||
// New 从 Gin 构造;依赖 OptionalAuth / RequireAuth 已写入 user 信息时可再查库
|
||||
func New(c *gin.Context, secret string) *Context {
|
||||
ctx := &Context{C: c, Secret: secret}
|
||||
if id, ok := c.Get(auth.CtxUserID); ok {
|
||||
if uid, ok := id.(uint); ok && uid > 0 {
|
||||
var u models.User
|
||||
if err := models.DB.First(&u, uid).Error; err == nil {
|
||||
ctx.Doer = &u
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *Context) IsSigned() bool { return ctx.Doer != nil }
|
||||
func (ctx *Context) IsAdmin() bool {
|
||||
return ctx.Doer != nil && ctx.Doer.Role == models.RoleAdmin
|
||||
}
|
||||
func (ctx *Context) UserID() uint {
|
||||
if ctx.Doer == nil {
|
||||
return 0
|
||||
}
|
||||
return ctx.Doer.ID
|
||||
}
|
||||
|
||||
// SkipsModeration 管理员或认证用户免审
|
||||
func (ctx *Context) SkipsModeration() bool {
|
||||
return ctx.Doer != nil && ctx.Doer.SkipsModeration()
|
||||
}
|
||||
|
||||
// HTML 渲染命名模板
|
||||
func (ctx *Context) HTML(status int, name string, data any) {
|
||||
ctx.C.Header("Content-Type", "text/html; charset=utf-8")
|
||||
ctx.C.Status(status)
|
||||
if err := webrender.Execute(ctx.C.Writer, name, data); err != nil {
|
||||
ctx.C.String(http.StatusInternalServerError, "模板渲染失败")
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect 303 见其它 URI(PRG)
|
||||
func (ctx *Context) Redirect(url string) {
|
||||
ctx.C.Redirect(http.StatusSeeOther, url)
|
||||
}
|
||||
|
||||
// SetFlash 一次性提示(下一请求读取)
|
||||
func (ctx *Context) SetFlash(msg string) {
|
||||
v := base64.RawURLEncoding.EncodeToString([]byte(msg))
|
||||
ctx.writeCookie(flashCookie, v, 120, true)
|
||||
}
|
||||
|
||||
// TakeFlash 读取并清除
|
||||
func (ctx *Context) TakeFlash() string {
|
||||
v, err := ctx.C.Cookie(flashCookie)
|
||||
if err != nil || v == "" {
|
||||
return ""
|
||||
}
|
||||
ctx.writeCookie(flashCookie, "", -1, true)
|
||||
b, err := base64.RawURLEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// EnsureCSRF 保证 CSRF cookie,并返回表单 token
|
||||
func (ctx *Context) EnsureCSRF() string {
|
||||
if t, err := ctx.C.Cookie(csrfCookie); err == nil && t != "" && ctx.validCSRF(t) {
|
||||
return t
|
||||
}
|
||||
t := ctx.newCSRF()
|
||||
ctx.writeCookie(csrfCookie, t, int((12 * time.Hour).Seconds()), true)
|
||||
return t
|
||||
}
|
||||
|
||||
// CheckCSRF 校验表单 _csrf(或请求头 X-CSRF-Token,供上传 fetch)
|
||||
func (ctx *Context) CheckCSRF() bool {
|
||||
form := strings.TrimSpace(ctx.C.PostForm("_csrf"))
|
||||
if form == "" {
|
||||
form = strings.TrimSpace(ctx.C.GetHeader("X-CSRF-Token"))
|
||||
}
|
||||
cookie, _ := ctx.C.Cookie(csrfCookie)
|
||||
if form == "" || cookie == "" || form != cookie {
|
||||
return false
|
||||
}
|
||||
return ctx.validCSRF(form)
|
||||
}
|
||||
|
||||
func (ctx *Context) newCSRF() string {
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(ctx.Secret))
|
||||
_, _ = mac.Write([]byte(ts))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
return ts + "." + sig
|
||||
}
|
||||
|
||||
func (ctx *Context) validCSRF(token string) bool {
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
ts, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if time.Since(time.Unix(ts, 0)) > 12*time.Hour {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(ctx.Secret))
|
||||
_, _ = mac.Write([]byte(parts[0]))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
return hmac.Equal([]byte(sig), []byte(parts[1]))
|
||||
}
|
||||
|
||||
// SetLoginCookie 写入 opaque 会话 Cookie
|
||||
func (ctx *Context) SetLoginCookie(sessionID string) {
|
||||
auth.SetSessionCookie(ctx.C, sessionID)
|
||||
}
|
||||
|
||||
// ClearLoginCookie 退出并删 Cookie;若有 session id 则吊销
|
||||
func (ctx *Context) ClearLoginCookie() {
|
||||
if sid, err := ctx.C.Cookie(auth.CookieName); err == nil && sid != "" {
|
||||
services.DeleteSession(sid)
|
||||
}
|
||||
auth.ClearAuthCookie(ctx.C)
|
||||
}
|
||||
|
||||
func (ctx *Context) writeCookie(name, value string, maxAge int, httpOnly bool) {
|
||||
secure := requestIsHTTPS(ctx.C)
|
||||
http.SetCookie(ctx.C.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: httpOnly,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// requestIsHTTPS 直连 TLS 或反代 X-Forwarded-Proto
|
||||
func requestIsHTTPS(c *gin.Context) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
// SafeHTML 供模板使用的类型别名说明(实际转换在 webrender FuncMap)
|
||||
type SafeHTML = template.HTML
|
||||
124
modules/webrender/render.go
Normal file
124
modules/webrender/render.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package webrender
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
apptemplates "git.iioio.com/freefire/jiang13-forum/templates"
|
||||
)
|
||||
|
||||
var (
|
||||
loadOnce sync.Once
|
||||
tpl *template.Template
|
||||
loadErr error
|
||||
)
|
||||
|
||||
func funcMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||||
"dict": func(values ...any) (map[string]any, error) {
|
||||
if len(values)%2 != 0 {
|
||||
return nil, fmt.Errorf("dict 需要偶数个参数")
|
||||
}
|
||||
m := make(map[string]any, len(values)/2)
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
k, ok := values[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dict 键必须是字符串")
|
||||
}
|
||||
m[k] = values[i+1]
|
||||
}
|
||||
return m, nil
|
||||
},
|
||||
"sortURL": func(boardID uint, sort string) string {
|
||||
q := url.Values{}
|
||||
if sort != "" && sort != "latest" {
|
||||
q.Set("sort", sort)
|
||||
}
|
||||
path := "/"
|
||||
if boardID > 0 {
|
||||
path = fmt.Sprintf("/board/%d", boardID)
|
||||
}
|
||||
if enc := q.Encode(); enc != "" {
|
||||
return path + "?" + enc
|
||||
}
|
||||
return path
|
||||
},
|
||||
"pageURL": func(boardID uint, sort string, page int) string {
|
||||
q := url.Values{}
|
||||
if sort != "" && sort != "latest" {
|
||||
q.Set("sort", sort)
|
||||
}
|
||||
if page > 1 {
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
path := "/"
|
||||
if boardID > 0 {
|
||||
path = fmt.Sprintf("/board/%d", boardID)
|
||||
}
|
||||
if enc := q.Encode(); enc != "" {
|
||||
return path + "?" + enc
|
||||
}
|
||||
return path
|
||||
},
|
||||
"postURL": func(id uint) string {
|
||||
return fmt.Sprintf("/post/%d", id)
|
||||
},
|
||||
"queryEscape": url.QueryEscape,
|
||||
}
|
||||
}
|
||||
|
||||
var parseGlobs = []string{
|
||||
"*.tmpl",
|
||||
"base/*.tmpl",
|
||||
"home/*.tmpl",
|
||||
"post/*.tmpl",
|
||||
"shared/*.tmpl",
|
||||
"status/*.tmpl",
|
||||
"auth/*.tmpl",
|
||||
"admin/*.tmpl",
|
||||
"profile/*.tmpl",
|
||||
"user/*.tmpl",
|
||||
"favorites/*.tmpl",
|
||||
"messages/*.tmpl",
|
||||
"links/*.tmpl",
|
||||
"boards/*.tmpl",
|
||||
"page/*.tmpl",
|
||||
}
|
||||
|
||||
// Load 解析全部模板(进程内一次)
|
||||
func Load() (*template.Template, error) {
|
||||
loadOnce.Do(func() {
|
||||
root := template.New("root").Funcs(funcMap())
|
||||
var err error
|
||||
for _, g := range parseGlobs {
|
||||
root, err = root.ParseFS(apptemplates.FS, g)
|
||||
if err != nil {
|
||||
loadErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
tpl = root
|
||||
})
|
||||
return tpl, loadErr
|
||||
}
|
||||
|
||||
// Execute 渲染命名模板到 w
|
||||
func Execute(w io.Writer, name string, data any) error {
|
||||
t, err := Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
// ResetForTest 测试用重置
|
||||
func ResetForTest() {
|
||||
loadOnce = sync.Once{}
|
||||
tpl = nil
|
||||
loadErr = nil
|
||||
}
|
||||
2358
public/assets/site.css
Normal file
2358
public/assets/site.css
Normal file
File diff suppressed because it is too large
Load Diff
482
public/assets/site.js
Normal file
482
public/assets/site.js
Normal file
@@ -0,0 +1,482 @@
|
||||
// 姜十三论坛 SSR 渐进增强
|
||||
document.documentElement.dataset.j13Ssr = "1";
|
||||
|
||||
(function themeToggle() {
|
||||
var KEY = "j13-theme";
|
||||
var LABELS = { system: "跟随系统", light: "浅色", dark: "暗色" };
|
||||
var ORDER = ["system", "light", "dark"];
|
||||
|
||||
function normalizePref(raw) {
|
||||
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
||||
return "system";
|
||||
}
|
||||
|
||||
function apply(pref) {
|
||||
pref = normalizePref(pref);
|
||||
var root = document.documentElement;
|
||||
if (pref === "system") {
|
||||
root.removeAttribute("data-theme");
|
||||
root.style.removeProperty("color-scheme");
|
||||
} else {
|
||||
root.setAttribute("data-theme", pref);
|
||||
root.style.colorScheme = pref;
|
||||
}
|
||||
root.setAttribute("data-theme-pref", pref);
|
||||
try {
|
||||
localStorage.setItem(KEY, pref);
|
||||
} catch (e) {}
|
||||
var btn = document.getElementById("j13-theme-toggle");
|
||||
if (btn) {
|
||||
var label = btn.querySelector(".j13-theme-toggle__label");
|
||||
var text = LABELS[pref] || LABELS.system;
|
||||
if (label) {
|
||||
label.textContent = text;
|
||||
} else {
|
||||
btn.textContent = text;
|
||||
}
|
||||
btn.setAttribute("aria-label", "主题:" + text);
|
||||
btn.title = "当前:" + text + "(点击切换)";
|
||||
}
|
||||
}
|
||||
|
||||
function currentPref() {
|
||||
try {
|
||||
return normalizePref(localStorage.getItem(KEY) || "system");
|
||||
} catch (e) {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
apply(currentPref());
|
||||
|
||||
var btn = document.getElementById("j13-theme-toggle");
|
||||
if (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var cur = currentPref();
|
||||
var idx = ORDER.indexOf(cur);
|
||||
var next = ORDER[(idx + 1) % ORDER.length];
|
||||
apply(next);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
(function headerSearchUX() {
|
||||
var input = document.getElementById("j13-header-search-input");
|
||||
var filterBtn = document.getElementById("j13-search-filter-btn");
|
||||
var advanced = document.getElementById("search");
|
||||
if (filterBtn && advanced) {
|
||||
filterBtn.addEventListener("click", function () {
|
||||
advanced.open = true;
|
||||
filterBtn.classList.add("header-search-filter-btn--active");
|
||||
var kw = advanced.querySelector('input[name="keyword"]');
|
||||
if (kw) kw.focus();
|
||||
advanced.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
});
|
||||
}
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (!(e.ctrlKey || e.metaKey) || (e.key !== "k" && e.key !== "K")) return;
|
||||
var tag = (e.target && e.target.tagName) || "";
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target && e.target.isContentEditable)) {
|
||||
if (e.target === input) return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
(function mdEditors() {
|
||||
function insertAtCursor(textarea, text) {
|
||||
const start = textarea.selectionStart || 0;
|
||||
const end = textarea.selectionEnd || start;
|
||||
textarea.value =
|
||||
textarea.value.slice(0, start) + text + textarea.value.slice(end);
|
||||
const pos = start + text.length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
}
|
||||
|
||||
function wrapSelection(textarea, before, after, fallback) {
|
||||
const start = textarea.selectionStart || 0;
|
||||
const end = textarea.selectionEnd || start;
|
||||
let selected = textarea.value.slice(start, end);
|
||||
if (!selected) selected = fallback || "";
|
||||
const block = before + selected + after;
|
||||
textarea.value =
|
||||
textarea.value.slice(0, start) + block + textarea.value.slice(end);
|
||||
textarea.focus();
|
||||
if (fallback && !textarea.value.slice(start, end)) {
|
||||
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||
} else {
|
||||
textarea.setSelectionRange(start + block.length, start + block.length);
|
||||
}
|
||||
}
|
||||
|
||||
function prefixLines(textarea, prefix) {
|
||||
const start = textarea.selectionStart || 0;
|
||||
const end = textarea.selectionEnd || start;
|
||||
const value = textarea.value;
|
||||
let lineStart = value.lastIndexOf("\n", start - 1) + 1;
|
||||
let lineEnd = value.indexOf("\n", end);
|
||||
if (lineEnd < 0) lineEnd = value.length;
|
||||
const chunk = value.slice(lineStart, lineEnd);
|
||||
const next = chunk
|
||||
.split("\n")
|
||||
.map((ln) => (ln.trim() ? prefix + ln.replace(/^\s+/, "") : prefix.trimEnd()))
|
||||
.join("\n");
|
||||
textarea.value = value.slice(0, lineStart) + next + value.slice(lineEnd);
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(lineStart, lineStart + next.length);
|
||||
}
|
||||
|
||||
function wrapGate(textarea, openTag, closeTag, statusEl) {
|
||||
const start = textarea.selectionStart || 0;
|
||||
const end = textarea.selectionEnd || start;
|
||||
let selected = textarea.value.slice(start, end);
|
||||
if (!selected.trim()) selected = "在此填写隐藏内容";
|
||||
let inner = selected;
|
||||
if (!/^\s*</.test(inner)) {
|
||||
inner = "<p>" + selected.replace(/\n/g, "<br>\n") + "</p>";
|
||||
}
|
||||
const block = openTag + inner + closeTag;
|
||||
textarea.value =
|
||||
textarea.value.slice(0, start) + block + textarea.value.slice(end);
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + block.length, start + block.length);
|
||||
if (statusEl) statusEl.textContent = "已插入门控块";
|
||||
}
|
||||
|
||||
function initEditor(root) {
|
||||
const textarea = root.querySelector("[data-md-input]");
|
||||
if (!textarea) return;
|
||||
const statusEl = root.querySelector("[data-md-status]");
|
||||
const previewEl = root.querySelector("[data-md-preview]");
|
||||
const csrf = root.getAttribute("data-csrf") || "";
|
||||
const uploadURL = root.getAttribute("data-upload") || "/compose/upload";
|
||||
const previewURL = root.getAttribute("data-preview") || "/compose/preview";
|
||||
const unsaved = root.getAttribute("data-unsaved") === "1";
|
||||
let dirty = false;
|
||||
let previewOn = false;
|
||||
|
||||
function setStatus(msg) {
|
||||
if (statusEl) statusEl.textContent = msg || "";
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-md-cmd]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const cmd = btn.getAttribute("data-md-cmd");
|
||||
if (cmd === "preview") {
|
||||
previewOn = !previewOn;
|
||||
if (previewOn) {
|
||||
setStatus("预览中…");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("content", textarea.value);
|
||||
fd.append("_csrf", csrf);
|
||||
const res = await fetch(previewURL, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf },
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "预览失败");
|
||||
previewEl.innerHTML = data.html || "<p class=\"j13-muted\">(空)</p>";
|
||||
previewEl.hidden = false;
|
||||
textarea.hidden = true;
|
||||
btn.classList.add("is-active");
|
||||
setStatus("预览模式(再点返回编辑)");
|
||||
} catch (e) {
|
||||
previewOn = false;
|
||||
setStatus(e.message || "预览失败");
|
||||
}
|
||||
} else {
|
||||
previewEl.hidden = true;
|
||||
previewEl.innerHTML = "";
|
||||
textarea.hidden = false;
|
||||
btn.classList.remove("is-active");
|
||||
textarea.focus();
|
||||
setStatus("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (previewOn) return;
|
||||
switch (cmd) {
|
||||
case "bold":
|
||||
wrapSelection(textarea, "**", "**", "粗体");
|
||||
break;
|
||||
case "italic":
|
||||
wrapSelection(textarea, "*", "*", "斜体");
|
||||
break;
|
||||
case "strike":
|
||||
wrapSelection(textarea, "~~", "~~", "删除线");
|
||||
break;
|
||||
case "code":
|
||||
wrapSelection(textarea, "`", "`", "code");
|
||||
break;
|
||||
case "link": {
|
||||
const url = window.prompt("链接地址", "https://");
|
||||
if (!url) return;
|
||||
wrapSelection(textarea, "[", "](" + url + ")", "链接文字");
|
||||
break;
|
||||
}
|
||||
case "h2":
|
||||
prefixLines(textarea, "## ");
|
||||
break;
|
||||
case "h3":
|
||||
prefixLines(textarea, "### ");
|
||||
break;
|
||||
case "ul":
|
||||
prefixLines(textarea, "- ");
|
||||
break;
|
||||
case "ol":
|
||||
prefixLines(textarea, "1. ");
|
||||
break;
|
||||
case "quote":
|
||||
prefixLines(textarea, "> ");
|
||||
break;
|
||||
case "fence":
|
||||
wrapSelection(textarea, "```\n", "\n```", "code");
|
||||
break;
|
||||
case "gate-login":
|
||||
wrapGate(textarea, '<members-only data-gate="login">', "</members-only>", statusEl);
|
||||
break;
|
||||
case "gate-reply":
|
||||
wrapGate(textarea, '<reply-only data-gate="reply">', "</reply-only>", statusEl);
|
||||
break;
|
||||
case "gate-points": {
|
||||
let cost = window.prompt("积分价格(1–9999)", "10");
|
||||
if (cost == null) return;
|
||||
const n = parseInt(String(cost).trim(), 10);
|
||||
if (!n || n < 1 || n > 9999) {
|
||||
setStatus("价格须为 1–9999 的整数");
|
||||
return;
|
||||
}
|
||||
wrapGate(
|
||||
textarea,
|
||||
'<points-only data-gate="points" data-cost="' + n + '">',
|
||||
"</points-only>",
|
||||
statusEl
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
dirty = true;
|
||||
});
|
||||
});
|
||||
|
||||
const fileInput = root.querySelector("[data-md-upload]");
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
fileInput.value = "";
|
||||
if (!file || previewOn) return;
|
||||
setStatus("上传中…");
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
fd.append("_csrf", csrf);
|
||||
try {
|
||||
const res = await fetch(uploadURL, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf },
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "上传失败");
|
||||
if (!data.url) throw new Error("未返回图片地址");
|
||||
insertAtCursor(textarea, "\n\n\n\n");
|
||||
dirty = true;
|
||||
setStatus("已插入图片");
|
||||
} catch (e) {
|
||||
setStatus(e.message || "上传失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
textarea.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Tab" || previewOn) return;
|
||||
e.preventDefault();
|
||||
const start = textarea.selectionStart || 0;
|
||||
const end = textarea.selectionEnd || start;
|
||||
if (e.shiftKey) {
|
||||
const value = textarea.value;
|
||||
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
|
||||
const line = value.slice(lineStart, end);
|
||||
const next = line.replace(/^( {1,2}|\t)/gm, "");
|
||||
textarea.value = value.slice(0, lineStart) + next + value.slice(end);
|
||||
textarea.setSelectionRange(lineStart, lineStart + next.length);
|
||||
} else {
|
||||
insertAtCursor(textarea, " ");
|
||||
}
|
||||
dirty = true;
|
||||
});
|
||||
|
||||
textarea.addEventListener("input", () => {
|
||||
dirty = true;
|
||||
});
|
||||
|
||||
if (unsaved) {
|
||||
const form = root.closest("form");
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (!dirty) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
});
|
||||
if (form) {
|
||||
form.addEventListener("submit", () => {
|
||||
dirty = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-j13-md-editor]").forEach(initEditor);
|
||||
})();
|
||||
|
||||
(function postContentGates() {
|
||||
const article = document.querySelector(".j13-post[data-post-id]");
|
||||
if (!article) return;
|
||||
|
||||
const postId = article.getAttribute("data-post-id");
|
||||
const csrf = article.getAttribute("data-csrf") || "";
|
||||
const loggedIn = article.getAttribute("data-logged-in") === "1";
|
||||
const path = window.location.pathname + window.location.hash;
|
||||
|
||||
article.querySelectorAll('[data-locked="true"]').forEach((el) => {
|
||||
const gate = el.getAttribute("data-gate") || "";
|
||||
const shell = el.querySelector("[data-gate-shell]");
|
||||
if (!shell) return;
|
||||
|
||||
if (gate === "login" && !loggedIn) {
|
||||
const a = document.createElement("a");
|
||||
a.href = "/login?redirect=" + encodeURIComponent(path || "/");
|
||||
a.textContent = "登录查看";
|
||||
const p = document.createElement("p");
|
||||
p.appendChild(a);
|
||||
shell.appendChild(p);
|
||||
}
|
||||
if (gate === "reply" && loggedIn) {
|
||||
const a = document.createElement("a");
|
||||
a.href = "#comments";
|
||||
a.textContent = "去评论解锁";
|
||||
const p = document.createElement("p");
|
||||
p.appendChild(a);
|
||||
shell.appendChild(p);
|
||||
}
|
||||
if (gate === "points") {
|
||||
const key = el.getAttribute("data-block-key") || "";
|
||||
const cost = el.getAttribute("data-cost") || "";
|
||||
if (!loggedIn) {
|
||||
const a = document.createElement("a");
|
||||
a.href = "/login?redirect=" + encodeURIComponent(path || "/");
|
||||
a.textContent = "登录后解锁";
|
||||
const p = document.createElement("p");
|
||||
p.appendChild(a);
|
||||
shell.appendChild(p);
|
||||
return;
|
||||
}
|
||||
if (!key) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "j13-gate__unlock";
|
||||
btn.textContent = "花费 " + cost + " 积分解锁";
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "解锁中…";
|
||||
try {
|
||||
const res = await fetch("/post/" + postId + "/unlock", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrf,
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ block_key: key }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "解锁失败");
|
||||
const inner = data.unlock && data.unlock.inner_html;
|
||||
if (typeof inner !== "string") throw new Error("未返回内容");
|
||||
el.setAttribute("data-locked", "false");
|
||||
el.innerHTML = inner;
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "花费 " + cost + " 积分解锁";
|
||||
alert(e.message || "解锁失败");
|
||||
}
|
||||
});
|
||||
const p = document.createElement("p");
|
||||
p.appendChild(btn);
|
||||
shell.appendChild(p);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// 友链申请:选择文件后先上传并回填 LOGO URL
|
||||
(function () {
|
||||
const form = document.querySelector("form[data-logo-upload]");
|
||||
if (!form) return;
|
||||
const fileInput = form.querySelector("#fl-logo-file");
|
||||
const urlInput = form.querySelector("#fl-logo-url");
|
||||
const csrfInput = form.querySelector('input[name="_csrf"]');
|
||||
if (!fileInput || !urlInput || !csrfInput) return;
|
||||
const uploadURL = form.getAttribute("data-logo-upload") || "/links/logo";
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append("logo", file);
|
||||
fd.append("_csrf", csrfInput.value);
|
||||
try {
|
||||
const res = await fetch(uploadURL, { method: "POST", body: fd, credentials: "same-origin" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "上传失败");
|
||||
if (data.url) urlInput.value = data.url;
|
||||
} catch (e) {
|
||||
alert(e.message || "上传失败");
|
||||
fileInput.value = "";
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// 评论回复:点击「回复」填入 reply_to
|
||||
(function () {
|
||||
const form = document.getElementById("comment-form");
|
||||
if (!form) return;
|
||||
const replyInput = document.getElementById("comment-reply-to");
|
||||
const hint = document.getElementById("comment-reply-hint");
|
||||
const clearBtn = document.getElementById("comment-reply-clear");
|
||||
const content = document.getElementById("comment-content");
|
||||
function clearReply() {
|
||||
if (replyInput) replyInput.value = "";
|
||||
if (hint) {
|
||||
hint.hidden = true;
|
||||
hint.textContent = "";
|
||||
}
|
||||
if (clearBtn) clearBtn.hidden = true;
|
||||
}
|
||||
function setReply(id, floor, author) {
|
||||
if (!replyInput) return;
|
||||
replyInput.value = id;
|
||||
if (hint) {
|
||||
hint.hidden = false;
|
||||
hint.textContent = "回复 #" + floor + (author ? " " + author : "");
|
||||
}
|
||||
if (clearBtn) clearBtn.hidden = false;
|
||||
if (content) content.focus();
|
||||
form.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
document.querySelectorAll(".j13-comment__reply-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
setReply(
|
||||
btn.getAttribute("data-reply-to") || "",
|
||||
btn.getAttribute("data-reply-floor") || "",
|
||||
btn.getAttribute("data-reply-author") || ""
|
||||
);
|
||||
});
|
||||
});
|
||||
if (clearBtn) clearBtn.addEventListener("click", clearReply);
|
||||
})();
|
||||
8
public/embed.go
Normal file
8
public/embed.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package public
|
||||
|
||||
import "embed"
|
||||
|
||||
// Assets 为 web_src 构建产物(site.css / site.js)
|
||||
//
|
||||
//go:embed assets/*
|
||||
var Assets embed.FS
|
||||
32
routers/api/handlers.go
Normal file
32
routers/api/handlers.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Handlers 本分支仅挂载机器入口(health / SEO / thumb / OIDC)。
|
||||
// 论坛 CRUD JSON 已删除,对照见 main 与 docs/rebuild-spec/04-api.md。
|
||||
type Handlers struct {
|
||||
Cfg *config.Config
|
||||
Settings *services.ForumSettingsService
|
||||
Board *services.BoardService
|
||||
Post *services.PostService
|
||||
OIDC *services.OIDCService
|
||||
}
|
||||
|
||||
func (h *Handlers) currentUserID(c *gin.Context) uint {
|
||||
if v, ok := c.Get(auth.CtxUserID); ok {
|
||||
return v.(uint)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// APIHealth 探活
|
||||
func (h *Handlers) APIHealth(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
224
routers/api/oidc.go
Normal file
224
routers/api/oidc.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
)
|
||||
|
||||
// OIDCDiscovery OpenID Provider 元数据
|
||||
func (h *Handlers) OIDCDiscovery(c *gin.Context) {
|
||||
if h.OIDC == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||
return
|
||||
}
|
||||
doc, err := h.OIDC.Discovery()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// OIDCJWKS JSON Web Key Set
|
||||
func (h *Handlers) OIDCJWKS(c *gin.Context) {
|
||||
if h.OIDC == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||
return
|
||||
}
|
||||
doc, err := h.OIDC.JWKS()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// OIDCAuthorize 授权端点:已登录则静默发码;未登录跳转论坛登录
|
||||
func (h *Handlers) OIDCAuthorize(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.String(http.StatusServiceUnavailable, "OIDC 未配置,请在管理后台「系统设置 → OIDC / SSO」启用并创建 OAuth 应用")
|
||||
return
|
||||
}
|
||||
|
||||
req := services.AuthorizeRequest{
|
||||
ClientID: c.Query("client_id"),
|
||||
RedirectURI: c.Query("redirect_uri"),
|
||||
ResponseType: c.Query("response_type"),
|
||||
Scope: c.Query("scope"),
|
||||
State: c.Query("state"),
|
||||
Nonce: c.Query("nonce"),
|
||||
CodeChallenge: c.Query("code_challenge"),
|
||||
CodeChallengeMethod: c.Query("code_challenge_method"),
|
||||
}
|
||||
|
||||
if err := h.OIDC.ValidateAuthorize(req); err != nil {
|
||||
// redirect_uri 未通过校验时不能重定向,避免开放重定向
|
||||
if errors.Is(err, services.ErrOIDCInvalidRedirect) || errors.Is(err, services.ErrOIDCInvalidClient) {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
from := c.Request.URL.RequestURI()
|
||||
c.Redirect(http.StatusFound, "/login?from="+url.QueryEscape(from))
|
||||
return
|
||||
}
|
||||
|
||||
callback, err := h.OIDC.IssueAuthCode(uid, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrOIDCUserBanned) {
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
|
||||
return
|
||||
}
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "server_error", err.Error())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, callback)
|
||||
}
|
||||
|
||||
func (h *Handlers) oidcErrorRedirect(c *gin.Context, redirectURI, state, code, desc string) {
|
||||
if redirectURI == "" {
|
||||
c.String(http.StatusBadRequest, desc)
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, desc)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("error", code)
|
||||
q.Set("error_description", desc)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
c.Redirect(http.StatusFound, u.String())
|
||||
}
|
||||
|
||||
// OIDCToken 令牌端点
|
||||
func (h *Handlers) OIDCToken(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "temporarily_unavailable", "error_description": "OIDC 未配置"})
|
||||
return
|
||||
}
|
||||
|
||||
clientID, clientSecret := c.PostForm("client_id"), c.PostForm("client_secret")
|
||||
if clientID == "" && clientSecret == "" {
|
||||
if id, secret, ok := parseBasicAuth(c.GetHeader("Authorization")); ok {
|
||||
clientID, clientSecret = id, secret
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := h.OIDC.ExchangeCode(services.TokenRequest{
|
||||
GrantType: c.PostForm("grant_type"),
|
||||
Code: c.PostForm("code"),
|
||||
RedirectURI: c.PostForm("redirect_uri"),
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
CodeVerifier: c.PostForm("code_verifier"),
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
code := "invalid_grant"
|
||||
switch {
|
||||
case errors.Is(err, services.ErrOIDCInvalidClient):
|
||||
status = http.StatusUnauthorized
|
||||
code = "invalid_client"
|
||||
case errors.Is(err, services.ErrOIDCInvalidRequest):
|
||||
code = "invalid_request"
|
||||
case errors.Is(err, services.ErrOIDCPKCEFailed):
|
||||
code = "invalid_grant"
|
||||
}
|
||||
c.JSON(status, gin.H{"error": code, "error_description": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// OIDCUserInfo 用户信息端点
|
||||
func (h *Handlers) OIDCUserInfo(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未配置"})
|
||||
return
|
||||
}
|
||||
token := extractBearer(c)
|
||||
if token == "" {
|
||||
c.Header("WWW-Authenticate", `Bearer`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
info, err := h.OIDC.UserInfo(token)
|
||||
if err != nil {
|
||||
c.Header("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// OIDCLogout RP-Initiated Logout:清除论坛会话并可选跳回客户端
|
||||
func (h *Handlers) OIDCLogout(c *gin.Context) {
|
||||
postLogout := c.Query("post_logout_redirect_uri")
|
||||
if postLogout == "" {
|
||||
postLogout = c.PostForm("post_logout_redirect_uri")
|
||||
}
|
||||
state := c.Query("state")
|
||||
if state == "" {
|
||||
state = c.PostForm("state")
|
||||
}
|
||||
|
||||
c.SetCookie(auth.CookieName, "", -1, "/", "", false, true)
|
||||
|
||||
if h.OIDC == nil {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
target, err := h.OIDC.ResolveLogoutRedirect(postLogout, state)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, target)
|
||||
}
|
||||
|
||||
func extractBearer(c *gin.Context) string {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||
}
|
||||
return c.Query("access_token")
|
||||
}
|
||||
|
||||
func parseBasicAuth(header string) (user, pass string, ok bool) {
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(prefix):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(string(raw), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
// client_id / client_secret 可能被 URL 编码
|
||||
uid, err1 := url.QueryUnescape(parts[0])
|
||||
sec, err2 := url.QueryUnescape(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
return uid, sec, true
|
||||
}
|
||||
132
routers/api/seo.go
Normal file
132
routers/api/seo.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const seoSitemapLimit = 5000
|
||||
|
||||
// RobotsTxt 搜索引擎抓取规则
|
||||
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||
base := h.publicBaseURL(c)
|
||||
var b strings.Builder
|
||||
b.WriteString("User-agent: *\n")
|
||||
b.WriteString("Allow: /\n")
|
||||
b.WriteString("Disallow: /api/\n")
|
||||
b.WriteString("Disallow: /admin\n")
|
||||
b.WriteString("Disallow: /install\n")
|
||||
b.WriteString("Disallow: /login\n")
|
||||
b.WriteString("Disallow: /compose\n")
|
||||
b.WriteString("Disallow: /oauth/\n")
|
||||
if base != "" {
|
||||
b.WriteString("\nSitemap: ")
|
||||
b.WriteString(base)
|
||||
b.WriteString("/sitemap.xml\n")
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
// SitemapXML 公开页面站点地图(与 SSR 同源路径)
|
||||
func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
base := h.publicBaseURL(c)
|
||||
if base == "" {
|
||||
c.String(http.StatusServiceUnavailable, "未配置站点 ROOT_URL,无法生成 sitemap")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
permalink := h.Settings.Permalink()
|
||||
urls := []services.SitemapURL{
|
||||
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
|
||||
}
|
||||
|
||||
if boards, err := h.Board.List(); err == nil {
|
||||
for _, board := range boards {
|
||||
urls = append(urls, services.SitemapURL{
|
||||
Loc: base + services.QueryBoardHome(board.ID, permalink),
|
||||
LastMod: board.UpdatedAt.UTC(),
|
||||
ChangeFreq: "daily",
|
||||
Priority: "0.7",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if posts, e1 := h.Post.ListSitemap(seoSitemapLimit); e1 == nil {
|
||||
for _, p := range posts {
|
||||
lm := p.UpdatedAt
|
||||
if lm.IsZero() {
|
||||
lm = p.CreatedAt
|
||||
}
|
||||
urls = append(urls, services.SitemapURL{
|
||||
Loc: base + permalink.PostPath(p.ID),
|
||||
LastMod: lm.UTC(),
|
||||
ChangeFreq: "weekly",
|
||||
Priority: "0.8",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
|
||||
for _, u := range urls {
|
||||
b.WriteString("<url><loc>")
|
||||
b.WriteString(xmlEscape(u.Loc))
|
||||
b.WriteString("</loc>")
|
||||
if !u.LastMod.IsZero() {
|
||||
b.WriteString("<lastmod>")
|
||||
b.WriteString(u.LastMod.Format("2006-01-02"))
|
||||
b.WriteString("</lastmod>")
|
||||
}
|
||||
if u.ChangeFreq != "" {
|
||||
b.WriteString("<changefreq>")
|
||||
b.WriteString(u.ChangeFreq)
|
||||
b.WriteString("</changefreq>")
|
||||
}
|
||||
if u.Priority != "" {
|
||||
b.WriteString("<priority>")
|
||||
b.WriteString(u.Priority)
|
||||
b.WriteString("</priority>")
|
||||
}
|
||||
b.WriteString("</url>")
|
||||
}
|
||||
b.WriteString("</urlset>")
|
||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
func (h *Handlers) publicBaseURL(c *gin.Context) string {
|
||||
return h.Settings.SitePublicBaseURL(requestOrigin(c))
|
||||
}
|
||||
|
||||
func requestOrigin(c *gin.Context) string {
|
||||
proto := c.GetHeader("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
32
routers/api/thumb.go
Normal file
32
routers/api/thumb.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
)
|
||||
|
||||
// ServeImageThumb 帖子图片缩略图(按需生成并缓存)
|
||||
// GET /media/thumb/posts/xxx.webp → 最长边 1280 的 WebP 预览
|
||||
func (h *Handlers) ServeImageThumb(c *gin.Context) {
|
||||
rel := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
uploadsRoot := filepath.Join(h.Cfg.DataDir, "uploads")
|
||||
thumbPath, err := services.EnsureUploadThumb(uploadsRoot, rel)
|
||||
if err != nil {
|
||||
// 生成失败时回退原图,避免正文裂图
|
||||
orig := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
|
||||
if st, e := os.Stat(orig); e == nil && !st.IsDir() {
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.File(orig)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=604800, immutable")
|
||||
c.File(thumbPath)
|
||||
}
|
||||
145
routers/install/install.go
Normal file
145
routers/install/install.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Deps 安装依赖
|
||||
type Deps struct {
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
AssetVersion string
|
||||
Auth *services.AuthService
|
||||
Settings *services.ForumSettingsService
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Description string
|
||||
SiteName string
|
||||
Slogan string
|
||||
LogoMark string
|
||||
LogoURL string
|
||||
FaviconURL string
|
||||
OGImageURL string
|
||||
AssetVersion string
|
||||
ShowFriendLinksFooter bool
|
||||
FooterPages []struct {
|
||||
Title string
|
||||
Slug string
|
||||
}
|
||||
CSRF string
|
||||
Error string
|
||||
Flash string
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
AdminNickname string
|
||||
}
|
||||
|
||||
// Register 未安装时的路由(仅 /install)
|
||||
func Register(r *gin.Engine, deps Deps) {
|
||||
r.GET("/install", deps.Get)
|
||||
r.POST("/install", deps.Post)
|
||||
}
|
||||
|
||||
// Get 安装页
|
||||
func (d Deps) Get(c *gin.Context) {
|
||||
if services.IsInstalled(d.DataDir) {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
ctx := webctx.New(c, d.JWTSecret)
|
||||
brand := d.Settings.SiteBranding()
|
||||
name := brand.Name
|
||||
if name == "" {
|
||||
name = "姜十三论坛"
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "install", pageData{
|
||||
Title: "安装 · " + name, SiteName: name, LogoMark: "姜",
|
||||
AssetVersion: d.assetVersion(),
|
||||
CSRF: ctx.EnsureCSRF(), AdminUsername: "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// Post 提交安装
|
||||
func (d Deps) Post(c *gin.Context) {
|
||||
if services.IsInstalled(d.DataDir) {
|
||||
c.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
ctx := webctx.New(c, d.JWTSecret)
|
||||
brand := d.Settings.SiteBranding()
|
||||
siteName := strings.TrimSpace(c.PostForm("site_name"))
|
||||
if siteName == "" {
|
||||
siteName = "姜十三论坛"
|
||||
}
|
||||
data := pageData{
|
||||
Title: "安装 · " + siteName, SiteName: siteName, LogoMark: "姜",
|
||||
AssetVersion: d.assetVersion(),
|
||||
CSRF: ctx.EnsureCSRF(),
|
||||
AdminUsername: strings.TrimSpace(c.PostForm("admin_username")),
|
||||
AdminEmail: strings.TrimSpace(c.PostForm("admin_email")),
|
||||
AdminNickname: strings.TrimSpace(c.PostForm("admin_nickname")),
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
data.Error = "无效请求,请重试"
|
||||
ctx.HTML(http.StatusBadRequest, "install", data)
|
||||
return
|
||||
}
|
||||
pass := c.PostForm("admin_password")
|
||||
pass2 := c.PostForm("admin_password2")
|
||||
if pass != pass2 {
|
||||
data.Error = "两次密码不一致"
|
||||
ctx.HTML(http.StatusBadRequest, "install", data)
|
||||
return
|
||||
}
|
||||
if _, err := d.Auth.CreateAdmin(data.AdminUsername, pass, data.AdminNickname, data.AdminEmail); err != nil {
|
||||
data.Error = err.Error()
|
||||
ctx.HTML(http.StatusBadRequest, "install", data)
|
||||
return
|
||||
}
|
||||
b := d.Settings.SiteBranding()
|
||||
b.Name = siteName
|
||||
_ = d.Settings.UpdateSiteBranding(b)
|
||||
if err := services.WriteInstallLock(d.DataDir); err != nil {
|
||||
data.Error = "写入安装锁失败: " + err.Error()
|
||||
ctx.HTML(http.StatusInternalServerError, "install", data)
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "post-install", pageData{
|
||||
Title: "安装完成", SiteName: siteName, LogoMark: "姜",
|
||||
AssetVersion: d.assetVersion(),
|
||||
})
|
||||
_ = brand
|
||||
}
|
||||
|
||||
func (d Deps) assetVersion() string {
|
||||
v := strings.TrimSpace(d.AssetVersion)
|
||||
if v == "" {
|
||||
return "dev"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Guard 未安装则只允许 install / assets / health
|
||||
func Guard(dataDir string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if services.IsInstalled(dataDir) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if path == "/install" || path == "/health" ||
|
||||
strings.HasPrefix(path, "/ssr-assets/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/install")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
142
routers/setup.go
Normal file
142
routers/setup.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
webpublic "git.iioio.com/freefire/jiang13-forum/public"
|
||||
"git.iioio.com/freefire/jiang13-forum/routers/api"
|
||||
"git.iioio.com/freefire/jiang13-forum/routers/install"
|
||||
webpages "git.iioio.com/freefire/jiang13-forum/routers/web"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.Logger())
|
||||
|
||||
if err := services.EnsureInstallLockFromExistingData(cfg.DataDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 安装锁检查失败: %v\n", err)
|
||||
}
|
||||
|
||||
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) {
|
||||
// 模板带 ?v=AssetVersion;部署换版本即穿透,可长缓存
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
ssrFiles.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
|
||||
r.Use(install.Guard(cfg.DataDir))
|
||||
|
||||
filter := services.NewSensitiveFilter()
|
||||
settingsSvc := services.NewForumSettingsService()
|
||||
services.EnsureFilterWordsInSettings(settingsSvc, cfg.FilterWordsPath(), filter)
|
||||
|
||||
authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
|
||||
userSvc := services.NewUserService(filter, settingsSvc)
|
||||
boardSvc := services.NewBoardService()
|
||||
boardSvc.EnsureDefaultBoard()
|
||||
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
|
||||
}
|
||||
|
||||
uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc)
|
||||
if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err)
|
||||
_ = uploadStore.Apply(services.StorageConfig{Type: "local"})
|
||||
}
|
||||
go func() {
|
||||
if n, err := uploadStore.SyncMediaIndex(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 媒体索引同步失败: %v\n", err)
|
||||
} else if n > 0 {
|
||||
fmt.Fprintf(os.Stderr, "媒体索引已同步 %d 条\n", n)
|
||||
}
|
||||
}()
|
||||
|
||||
sitePageSvc := services.NewSitePageService(filter)
|
||||
|
||||
h := &api.Handlers{
|
||||
Cfg: cfg, Settings: settingsSvc,
|
||||
Board: boardSvc, Post: postSvc,
|
||||
OIDC: oidcSvc,
|
||||
}
|
||||
authMW := auth.NewAuthMiddleware(authSvc)
|
||||
|
||||
assetVer := strings.TrimSpace(cfg.Version)
|
||||
if assetVer == "" {
|
||||
assetVer = "dev"
|
||||
}
|
||||
|
||||
install.Register(r, install.Deps{
|
||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||
Auth: authSvc, Settings: settingsSvc,
|
||||
AssetVersion: assetVer,
|
||||
})
|
||||
|
||||
webpages.Register(r, webpages.Deps{
|
||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||
Settings: settingsSvc, Auth: authSvc, User: userSvc,
|
||||
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
||||
Message: messageSvc, Filter: filter,
|
||||
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
|
||||
Points: services.NewPointsService(),
|
||||
Badge: services.NewBadgeService(),
|
||||
FriendLink: friendLinkApplySvc,
|
||||
Mail: mailSvc,
|
||||
SitePage: sitePageSvc,
|
||||
Report: reportSvc,
|
||||
Captcha: captchaSvc,
|
||||
Notify: notifySvc,
|
||||
Backup: backupSvc,
|
||||
AssetVersion: assetVer,
|
||||
}, authMW)
|
||||
|
||||
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
|
||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||
r.GET("/health", h.APIHealth)
|
||||
r.GET("/robots.txt", h.RobotsTxt)
|
||||
r.GET("/sitemap.xml", h.SitemapXML)
|
||||
|
||||
// OIDC Provider(外部机器 / Gitea SSO)
|
||||
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
|
||||
r.GET("/oauth/jwks", h.OIDCJWKS)
|
||||
r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize)
|
||||
r.POST("/oauth/token", h.OIDCToken)
|
||||
r.GET("/oauth/userinfo", h.OIDCUserInfo)
|
||||
r.POST("/oauth/userinfo", h.OIDCUserInfo)
|
||||
r.GET("/oauth/logout", h.OIDCLogout)
|
||||
r.POST("/oauth/logout", h.OIDCLogout)
|
||||
|
||||
// 机器入口:健康检查 / SEO / OIDC / 缩略图;论坛 UI 仅走 routers/web
|
||||
r.NoRoute(webpages.Deps{
|
||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||
Settings: settingsSvc, Auth: authSvc,
|
||||
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
||||
AssetVersion: assetVer,
|
||||
}.NotFound)
|
||||
|
||||
return r, nil
|
||||
}
|
||||
843
routers/web/admin.go
Normal file
843
routers/web/admin.go
Normal file
@@ -0,0 +1,843 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminChrome 后台布局公共字段
|
||||
type AdminChrome struct {
|
||||
PageChrome
|
||||
NavActive string
|
||||
}
|
||||
|
||||
func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome {
|
||||
site := d.Settings.SiteBranding().Name
|
||||
if title == "" {
|
||||
title = "管理后台 · " + site
|
||||
} else {
|
||||
title = title + " · " + site
|
||||
}
|
||||
base := d.chrome(ctx, title, "", "")
|
||||
return AdminChrome{PageChrome: base, NavActive: nav}
|
||||
}
|
||||
|
||||
type adminDashData struct {
|
||||
AdminChrome
|
||||
UserCount int64
|
||||
PostCount int64
|
||||
PendingPosts int64
|
||||
PendingComments int64
|
||||
PendingReports int64
|
||||
BoardCount int64
|
||||
}
|
||||
|
||||
// AdminDashboard 概览
|
||||
func (d Deps) AdminDashboard(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
var users, posts, boards int64
|
||||
_ = models.DB.Model(&models.User{}).Count(&users).Error
|
||||
_ = models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&posts).Error
|
||||
_ = models.DB.Model(&models.Board{}).Count(&boards).Error
|
||||
pendingPosts, _ := d.Post.PendingPostCount()
|
||||
pendingComments, _ := d.Comment.PendingCommentCount()
|
||||
var pendingReports int64
|
||||
if d.Report != nil {
|
||||
pendingReports, _ = d.Report.PendingCount()
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{
|
||||
AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"),
|
||||
UserCount: users,
|
||||
PostCount: posts,
|
||||
PendingPosts: pendingPosts,
|
||||
PendingComments: pendingComments,
|
||||
PendingReports: pendingReports,
|
||||
BoardCount: boards,
|
||||
})
|
||||
}
|
||||
|
||||
type adminBoardRow struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
ColorIndex int
|
||||
SortOrder int
|
||||
PostCount int
|
||||
}
|
||||
|
||||
type adminBoardsData struct {
|
||||
AdminChrome
|
||||
Boards []adminBoardRow
|
||||
Form adminBoardForm
|
||||
}
|
||||
|
||||
type adminBoardForm struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
ColorIndex int
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// AdminBoardsGet 板块列表
|
||||
func (d Deps) AdminBoardsGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminBoards(ctx, "", adminBoardForm{})
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminBoards(ctx *webctx.Context, errMsg string, form adminBoardForm) {
|
||||
list, _ := d.Board.ListWithStats()
|
||||
rows := make([]adminBoardRow, 0, len(list))
|
||||
for _, b := range list {
|
||||
rows = append(rows, adminBoardRow{
|
||||
ID: b.ID, Name: b.Name, Description: b.Description,
|
||||
Icon: b.Icon, ColorIndex: b.ColorIndex, SortOrder: b.SortOrder,
|
||||
PostCount: b.PostCount,
|
||||
})
|
||||
}
|
||||
data := adminBoardsData{
|
||||
AdminChrome: d.adminChrome(ctx, "板块", "boards"),
|
||||
Boards: rows,
|
||||
Form: form,
|
||||
}
|
||||
data.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "admin/boards", data)
|
||||
}
|
||||
|
||||
// AdminBoardCreate 新建板块
|
||||
func (d Deps) AdminBoardCreate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
|
||||
return
|
||||
}
|
||||
form := adminBoardFormFrom(c)
|
||||
if strings.TrimSpace(form.Name) == "" {
|
||||
d.renderAdminBoards(ctx, "请填写板块名称", form)
|
||||
return
|
||||
}
|
||||
if _, err := d.Board.Create(form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
|
||||
d.renderAdminBoards(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已创建")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
// AdminBoardUpdate 更新板块
|
||||
func (d Deps) AdminBoardUpdate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
form := adminBoardFormFrom(c)
|
||||
form.ID = uint(id)
|
||||
if strings.TrimSpace(form.Name) == "" {
|
||||
d.renderAdminBoards(ctx, "请填写板块名称", form)
|
||||
return
|
||||
}
|
||||
if err := d.Board.Update(uint(id), form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
|
||||
d.renderAdminBoards(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已更新")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
// AdminBoardDelete 删除板块
|
||||
func (d Deps) AdminBoardDelete(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/boards")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Board.Delete(uint(id)); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/boards")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已删除")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
func adminBoardFormFrom(c *gin.Context) adminBoardForm {
|
||||
color, _ := strconv.Atoi(c.PostForm("color_index"))
|
||||
sort, _ := strconv.Atoi(c.PostForm("sort_order"))
|
||||
return adminBoardForm{
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
Icon: strings.TrimSpace(c.PostForm("icon")),
|
||||
ColorIndex: color,
|
||||
SortOrder: sort,
|
||||
}
|
||||
}
|
||||
|
||||
type adminModPostRow struct {
|
||||
ID uint
|
||||
Title string
|
||||
AuthorName string
|
||||
BoardName string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type adminModCommentRow struct {
|
||||
ID uint
|
||||
PostID uint
|
||||
PostTitle string
|
||||
Floor int
|
||||
AuthorName string
|
||||
Excerpt string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type adminModData struct {
|
||||
AdminChrome
|
||||
Posts []adminModPostRow
|
||||
Comments []adminModCommentRow
|
||||
}
|
||||
|
||||
// AdminModerationGet 待审帖/评
|
||||
func (d Deps) AdminModerationGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
posts, _, _ := d.Post.List(services.PostListQuery{
|
||||
Page: 1, Size: 50,
|
||||
ViewerIsAdmin: true,
|
||||
Status: models.ContentStatusPending,
|
||||
Sort: "latest",
|
||||
})
|
||||
postRows := make([]adminModPostRow, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
author := ""
|
||||
if p.User.ID > 0 {
|
||||
author = p.User.Nickname
|
||||
if author == "" {
|
||||
author = p.User.Username
|
||||
}
|
||||
}
|
||||
board := ""
|
||||
if p.Board.ID > 0 {
|
||||
board = p.Board.Name
|
||||
}
|
||||
postRows = append(postRows, adminModPostRow{
|
||||
ID: p.ID, Title: p.Title, AuthorName: author, BoardName: board,
|
||||
CreatedAt: p.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
comments, _, _ := d.Comment.ListPending(1, 50)
|
||||
commentRows := make([]adminModCommentRow, 0, len(comments))
|
||||
for _, cm := range comments {
|
||||
author := "游客"
|
||||
if cm.UserID > 0 {
|
||||
author = cm.User.Nickname
|
||||
if author == "" {
|
||||
author = cm.User.Username
|
||||
}
|
||||
} else if cm.GuestNick != "" {
|
||||
author = cm.GuestNick
|
||||
}
|
||||
excerpt := strings.TrimSpace(stripTagsRough(cm.Content))
|
||||
runes := []rune(excerpt)
|
||||
if len(runes) > 80 {
|
||||
excerpt = string(runes[:80]) + "…"
|
||||
}
|
||||
title := ""
|
||||
if cm.Post.ID > 0 {
|
||||
title = cm.Post.Title
|
||||
}
|
||||
commentRows = append(commentRows, adminModCommentRow{
|
||||
ID: cm.ID, PostID: cm.PostID, PostTitle: title, Floor: cm.Floor,
|
||||
AuthorName: author, Excerpt: excerpt,
|
||||
CreatedAt: cm.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/moderation", adminModData{
|
||||
AdminChrome: d.adminChrome(ctx, "内容审核", "moderation"),
|
||||
Posts: postRows,
|
||||
Comments: commentRows,
|
||||
})
|
||||
}
|
||||
|
||||
func stripTagsRough(s string) string {
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AdminPostApprove 通过帖子
|
||||
func (d Deps) AdminPostApprove(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
} else {
|
||||
ctx.SetFlash("帖子已通过")
|
||||
}
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminPostReject 拒绝帖子
|
||||
func (d Deps) AdminPostReject(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||
if reason == "" {
|
||||
ctx.SetFlash("请填写拒绝原因")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
post, err := d.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if err := d.Post.SetStatus(post.ID, models.ContentStatusRejected); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
pid := post.ID
|
||||
if d.Message != nil {
|
||||
_, _ = d.Message.SendSystem(
|
||||
post.UserID,
|
||||
"帖子《"+post.Title+"》未通过审核",
|
||||
services.FormatRejectContent(post.Title, post.ID, reason),
|
||||
models.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
ctx.SetFlash("已拒绝该帖")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminCommentApprove 通过评论
|
||||
func (d Deps) AdminCommentApprove(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
} else {
|
||||
if d.Notify != nil {
|
||||
if comment, err := d.Comment.GetByID(uint(id)); err == nil {
|
||||
comment.Status = models.ContentStatusPublished
|
||||
d.Notify.AsyncNotifyCommentPublished(comment)
|
||||
d.Notify.AsyncNotifyCommentMentions(comment)
|
||||
}
|
||||
}
|
||||
ctx.SetFlash("评论已通过")
|
||||
}
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminCommentReject 拒绝评论
|
||||
func (d Deps) AdminCommentReject(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||
if reason == "" {
|
||||
ctx.SetFlash("请填写拒绝原因")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
cm, err := d.Comment.GetByID(uint(id))
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if err := d.Comment.SetStatus(cm.ID, models.ContentStatusRejected); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if d.Message != nil && cm.UserID > 0 {
|
||||
pid := cm.PostID
|
||||
title := cm.Post.Title
|
||||
_, _ = d.Message.SendSystem(
|
||||
cm.UserID,
|
||||
fmt.Sprintf("评论未通过审核(帖 #%d)", cm.PostID),
|
||||
services.FormatCommentRejectContent(title, cm.PostID, cm.Floor, reason),
|
||||
models.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
ctx.SetFlash("已拒绝该评论")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
type adminAsideWidgetRow struct {
|
||||
ID string
|
||||
Label string
|
||||
Enabled bool
|
||||
Index int
|
||||
CanMoveUp bool
|
||||
CanMoveDown bool
|
||||
}
|
||||
|
||||
type adminSettingsData struct {
|
||||
AdminChrome
|
||||
Brand services.SiteBranding
|
||||
RatePost int
|
||||
RateComment int
|
||||
RateReg int
|
||||
RateLogin int
|
||||
RateWindow int
|
||||
Limits services.ForumLimits
|
||||
FilterWords string
|
||||
FilterCount int
|
||||
Mail services.MailConfig
|
||||
MailReady bool
|
||||
TestTo string
|
||||
AsideWidgets []adminAsideWidgetRow
|
||||
CanBackup bool
|
||||
}
|
||||
|
||||
// AdminSettingsGet 设置页
|
||||
func (d Deps) AdminSettingsGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminSettings(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
|
||||
words := d.Settings.FilterWordsContent()
|
||||
lim := d.Settings.Limits()
|
||||
mail := d.Settings.MailConfigPublic()
|
||||
widgets := d.Settings.AsideWidgets()
|
||||
asideRows := make([]adminAsideWidgetRow, 0, len(widgets))
|
||||
last := len(widgets) - 1
|
||||
for i, w := range widgets {
|
||||
asideRows = append(asideRows, adminAsideWidgetRow{
|
||||
ID: w.ID, Label: asideWidgetLabel(w.ID), Enabled: w.Enabled, Index: i,
|
||||
CanMoveUp: i > 0, CanMoveDown: i < last,
|
||||
})
|
||||
}
|
||||
data := adminSettingsData{
|
||||
AdminChrome: d.adminChrome(ctx, "站点设置", "settings"),
|
||||
Brand: d.Settings.SiteBranding(),
|
||||
RatePost: lim.RateLimitPost,
|
||||
RateComment: lim.RateLimitComment,
|
||||
RateReg: lim.RateLimitRegister,
|
||||
RateLogin: lim.RateLimitLogin,
|
||||
RateWindow: lim.RateLimitWindowSec,
|
||||
Limits: lim,
|
||||
FilterWords: words,
|
||||
FilterCount: services.CountFilterWords(words),
|
||||
Mail: mail,
|
||||
MailReady: d.Settings.MailReady(),
|
||||
AsideWidgets: asideRows,
|
||||
CanBackup: models.DialectorName() == "sqlite",
|
||||
}
|
||||
data.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "admin/settings", data)
|
||||
}
|
||||
|
||||
func asideWidgetLabel(id string) string {
|
||||
switch id {
|
||||
case services.AsideWidgetTagCloud:
|
||||
return "标签云"
|
||||
case services.AsideWidgetRecentComments:
|
||||
return "最新评论"
|
||||
case services.AsideWidgetRecentUsers:
|
||||
return "最新用户"
|
||||
case services.AsideWidgetFriendLinks:
|
||||
return "友链"
|
||||
default:
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
// AdminSettingsBrandPost 品牌
|
||||
func (d Deps) AdminSettingsBrandPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
cur := d.Settings.SiteBranding()
|
||||
in := services.SiteBranding{
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Slogan: strings.TrimSpace(c.PostForm("slogan")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
Keywords: strings.TrimSpace(c.PostForm("keywords")),
|
||||
LogoMark: strings.TrimSpace(c.PostForm("logo_mark")),
|
||||
Logo: cur.Logo,
|
||||
Favicon: cur.Favicon,
|
||||
OGImage: cur.OGImage,
|
||||
ICPBeian: strings.TrimSpace(c.PostForm("icp_beian")),
|
||||
ICPBeianURL: strings.TrimSpace(c.PostForm("icp_beian_url")),
|
||||
FriendLinks: cur.FriendLinks,
|
||||
}
|
||||
if err := d.Settings.UpdateSiteBranding(in); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("品牌设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsBrandUploadPost 上传 Logo / Favicon / OG 图
|
||||
func (d Deps) AdminSettingsBrandUploadPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.Store == nil {
|
||||
d.renderAdminSettings(ctx, "上传存储未就绪")
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(c.PostForm("kind"))
|
||||
if kind != "logo" && kind != "favicon" && kind != "og_image" {
|
||||
d.renderAdminSettings(ctx, "请选择 Logo、Favicon 或 OG 图")
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
d.renderAdminSettings(ctx, "请选择图片文件")
|
||||
return
|
||||
}
|
||||
const maxBytes = 2 * 1024 * 1024
|
||||
if file.Size > maxBytes {
|
||||
d.renderAdminSettings(ctx, "图片不能超过 2MB")
|
||||
return
|
||||
}
|
||||
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, kind)
|
||||
if err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
prev := d.Settings.SiteBranding()
|
||||
switch kind {
|
||||
case "logo":
|
||||
_ = d.Settings.SetSiteLogo(url)
|
||||
d.Store.DeleteByURL(prev.Logo)
|
||||
ctx.SetFlash("Logo 已上传")
|
||||
case "favicon":
|
||||
_ = d.Settings.SetSiteFavicon(url)
|
||||
d.Store.DeleteByURL(prev.Favicon)
|
||||
ctx.SetFlash("Favicon 已上传")
|
||||
case "og_image":
|
||||
_ = d.Settings.SetSiteOGImage(url)
|
||||
d.Store.DeleteByURL(prev.OGImage)
|
||||
ctx.SetFlash("默认 OG 图已上传")
|
||||
}
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsBrandClearPost 清除品牌图片
|
||||
func (d Deps) AdminSettingsBrandClearPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(c.PostForm("kind"))
|
||||
brand := d.Settings.SiteBranding()
|
||||
switch kind {
|
||||
case "logo":
|
||||
_ = d.Settings.SetSiteLogo("")
|
||||
if d.Store != nil {
|
||||
d.Store.DeleteByURL(brand.Logo)
|
||||
}
|
||||
ctx.SetFlash("已清除 Logo")
|
||||
case "favicon":
|
||||
_ = d.Settings.SetSiteFavicon("")
|
||||
if d.Store != nil {
|
||||
d.Store.DeleteByURL(brand.Favicon)
|
||||
}
|
||||
ctx.SetFlash("已清除 Favicon")
|
||||
case "og_image":
|
||||
_ = d.Settings.SetSiteOGImage("")
|
||||
if d.Store != nil {
|
||||
d.Store.DeleteByURL(brand.OGImage)
|
||||
}
|
||||
ctx.SetFlash("已清除 OG 图")
|
||||
default:
|
||||
d.renderAdminSettings(ctx, "无效的资源类型")
|
||||
return
|
||||
}
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsLimitsPost 限流
|
||||
func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
postN, _ := strconv.Atoi(c.PostForm("rate_limit_post"))
|
||||
commentN, _ := strconv.Atoi(c.PostForm("rate_limit_comment"))
|
||||
regN, _ := strconv.Atoi(c.PostForm("rate_limit_register"))
|
||||
loginN, _ := strconv.Atoi(c.PostForm("rate_limit_login"))
|
||||
windowN, _ := strconv.Atoi(c.PostForm("rate_limit_window_sec"))
|
||||
if err := d.Settings.UpdateRateLimits(postN, commentN, regN, loginN, windowN); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("限流设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsContentLimitsPost 字数与编辑窗等
|
||||
func (d Deps) AdminSettingsContentLimitsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
atoi := func(name string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
|
||||
return n
|
||||
}
|
||||
err := d.Settings.UpdateContentLimits(
|
||||
atoi("post_edit_window_hours"), atoi("comment_edit_window_minutes"),
|
||||
atoi("post_title_max"), atoi("post_tags_max"), atoi("post_content_max"), atoi("comment_max"),
|
||||
atoi("search_keyword_min"), atoi("search_keyword_max"), atoi("page_size_default"),
|
||||
atoi("password_min_len"), atoi("avatar_max_mb"), atoi("signature_max"),
|
||||
c.PostForm("open_posts_in_new_tab") == "1" || c.PostForm("open_posts_in_new_tab") == "on",
|
||||
c.PostForm("open_content_links_in_new_tab") == "1" || c.PostForm("open_content_links_in_new_tab") == "on",
|
||||
strings.TrimSpace(c.PostForm("feed_list_style")),
|
||||
)
|
||||
if err != nil {
|
||||
d.renderAdminSettings(ctx, "内容限制无效,请检查数值范围")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("内容限制已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsPermalinkPost 伪静态
|
||||
func (d Deps) AdminSettingsPermalinkPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
enabled := c.PostForm("permalink_enabled") == "1" || c.PostForm("permalink_enabled") == "on"
|
||||
ext := strings.TrimSpace(c.PostForm("permalink_ext"))
|
||||
if err := d.Settings.UpdatePermalinkSettings(enabled, ext); err != nil {
|
||||
d.renderAdminSettings(ctx, "伪静态后缀无效(1–16 位字母数字)")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("伪静态设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsFilterWordsPost 敏感词
|
||||
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
content := c.PostForm("filter_words")
|
||||
if err := d.Settings.UpdateFilterWords(content, d.Filter); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05")))
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsMailPost 保存 SMTP
|
||||
func (d Deps) AdminSettingsMailPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
port, _ := strconv.Atoi(c.PostForm("port"))
|
||||
cfg := services.MailConfig{
|
||||
Enabled: c.PostForm("enabled") == "1" || c.PostForm("enabled") == "on",
|
||||
Host: strings.TrimSpace(c.PostForm("host")),
|
||||
Port: port,
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Password: c.PostForm("password"),
|
||||
From: strings.TrimSpace(c.PostForm("from")),
|
||||
FromName: strings.TrimSpace(c.PostForm("from_name")),
|
||||
Encryption: strings.TrimSpace(c.PostForm("encryption")),
|
||||
}
|
||||
if err := d.Settings.UpdateMailConfig(cfg); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("邮件设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsMailTestPost 发送测试信
|
||||
func (d Deps) AdminSettingsMailTestPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
to := strings.TrimSpace(c.PostForm("test_to"))
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if err := services.ValidateEmail(to); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if !d.Settings.MailReady() {
|
||||
d.renderAdminSettings(ctx, services.ErrMailNotConfigured.Error())
|
||||
return
|
||||
}
|
||||
if d.Mail == nil {
|
||||
d.renderAdminSettings(ctx, "邮件服务未就绪")
|
||||
return
|
||||
}
|
||||
siteName := d.Settings.SiteBranding().Name
|
||||
err := d.Mail.Send(services.NormalizeEmail(to), "邮件配置测试",
|
||||
fmt.Sprintf("这是一封来自%s的测试邮件,说明 SMTP 配置正常。", siteName))
|
||||
if err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("测试邮件已发送至 " + to)
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsAsideWidgetsPost 侧栏组件开关与排序
|
||||
func (d Deps) AdminSettingsAsideWidgetsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
order := c.PostFormArray("order")
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, id := range c.PostFormArray("enabled") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
enabledSet[id] = true
|
||||
}
|
||||
}
|
||||
widgets := make([]services.AsideWidget, 0, len(order))
|
||||
for _, id := range order {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
widgets = append(widgets, services.AsideWidget{ID: id, Enabled: enabledSet[id]})
|
||||
}
|
||||
widgets = services.NormalizeAsideWidgets(widgets)
|
||||
if move := strings.TrimSpace(c.PostForm("move")); move != "" {
|
||||
widgets = moveAsideWidget(widgets, move)
|
||||
}
|
||||
if err := d.Settings.UpdateAsideWidgets(widgets); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("侧栏组件已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsBackupPost 一键导出 SQLite 备份并跳转下载
|
||||
func (d Deps) AdminSettingsBackupPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.Backup == nil {
|
||||
d.renderAdminSettings(ctx, "备份服务未就绪")
|
||||
return
|
||||
}
|
||||
path, err := d.Backup.ExportSQLite()
|
||||
if err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
ctx.Redirect("/admin/settings/backup/download/" + name)
|
||||
}
|
||||
|
||||
// AdminSettingsBackupDownload 下载 data 目录下的备份文件
|
||||
func (d Deps) AdminSettingsBackupDownload(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") {
|
||||
c.String(http.StatusBadRequest, "无效的备份文件名")
|
||||
return
|
||||
}
|
||||
path := filepath.Join(d.DataDir, name)
|
||||
c.FileAttachment(path, name)
|
||||
}
|
||||
|
||||
// moveAsideWidget 解析 move 值「up:id」或「down:id」并交换相邻项
|
||||
func moveAsideWidget(widgets []services.AsideWidget, move string) []services.AsideWidget {
|
||||
parts := strings.SplitN(move, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return widgets
|
||||
}
|
||||
dir, id := parts[0], strings.TrimSpace(parts[1])
|
||||
idx := -1
|
||||
for i, w := range widgets {
|
||||
if w.ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return widgets
|
||||
}
|
||||
switch dir {
|
||||
case "up":
|
||||
if idx == 0 {
|
||||
return widgets
|
||||
}
|
||||
widgets[idx-1], widgets[idx] = widgets[idx], widgets[idx-1]
|
||||
case "down":
|
||||
if idx >= len(widgets)-1 {
|
||||
return widgets
|
||||
}
|
||||
widgets[idx], widgets[idx+1] = widgets[idx+1], widgets[idx]
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
319
routers/web/admin_badges.go
Normal file
319
routers/web/admin_badges.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type adminBadgeRow struct {
|
||||
ID uint
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
Kind string
|
||||
KindLabel string
|
||||
Metric string
|
||||
Threshold int
|
||||
SortOrder int
|
||||
Enabled bool
|
||||
IsLimited bool
|
||||
}
|
||||
|
||||
type adminBadgeForm struct {
|
||||
ID uint
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
Kind string
|
||||
Metric string
|
||||
Threshold int
|
||||
SortOrder int
|
||||
Enabled bool
|
||||
IsEdit bool
|
||||
}
|
||||
|
||||
type adminBadgesData struct {
|
||||
AdminChrome
|
||||
Badges []adminBadgeRow
|
||||
LimitedBadges []adminBadgeRow
|
||||
Form adminBadgeForm
|
||||
AwardUser string
|
||||
AwardBadgeID uint
|
||||
}
|
||||
|
||||
// AdminBadgesGet 徽章定义列表
|
||||
func (d Deps) AdminBadgesGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminBadges(ctx, "", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminBadges(ctx *webctx.Context, errMsg string, form adminBadgeForm, awardUser string, awardBadgeID uint) {
|
||||
chrome := d.adminChrome(ctx, "徽章", "badges")
|
||||
chrome.Error = errMsg
|
||||
if form.Kind == "" {
|
||||
form.Kind = models.BadgeKindLimited
|
||||
}
|
||||
data := adminBadgesData{
|
||||
AdminChrome: chrome,
|
||||
Form: form,
|
||||
AwardUser: awardUser,
|
||||
AwardBadgeID: awardBadgeID,
|
||||
}
|
||||
if d.Badge != nil {
|
||||
list, err := d.Badge.ListDefs(true)
|
||||
if err != nil {
|
||||
chrome.Error = err.Error()
|
||||
data.AdminChrome = chrome
|
||||
} else {
|
||||
data.Badges = make([]adminBadgeRow, 0, len(list))
|
||||
data.LimitedBadges = make([]adminBadgeRow, 0)
|
||||
for _, b := range list {
|
||||
row := adminBadgeRowFrom(b)
|
||||
data.Badges = append(data.Badges, row)
|
||||
if row.IsLimited && row.Enabled {
|
||||
data.LimitedBadges = append(data.LimitedBadges, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/badges", data)
|
||||
}
|
||||
|
||||
func adminBadgeRowFrom(b models.BadgeDef) adminBadgeRow {
|
||||
label := "自动"
|
||||
if b.Kind == models.BadgeKindLimited {
|
||||
label = "限定"
|
||||
}
|
||||
return adminBadgeRow{
|
||||
ID: b.ID, Code: b.Code, Name: b.Name, Description: b.Description, Icon: b.Icon,
|
||||
Kind: b.Kind, KindLabel: label, Metric: b.Metric, Threshold: b.Threshold,
|
||||
SortOrder: b.SortOrder, Enabled: b.Enabled, IsLimited: b.Kind == models.BadgeKindLimited,
|
||||
}
|
||||
}
|
||||
|
||||
func adminBadgeFormFrom(c *gin.Context) adminBadgeForm {
|
||||
th, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("threshold")))
|
||||
so, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("sort_order")))
|
||||
kind := strings.TrimSpace(c.PostForm("kind"))
|
||||
if kind == "" {
|
||||
kind = models.BadgeKindLimited
|
||||
}
|
||||
return adminBadgeForm{
|
||||
Code: strings.TrimSpace(c.PostForm("code")),
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
Icon: strings.TrimSpace(c.PostForm("icon")),
|
||||
Kind: kind,
|
||||
Metric: strings.TrimSpace(c.PostForm("metric")),
|
||||
Threshold: th,
|
||||
SortOrder: so,
|
||||
Enabled: c.PostForm("enabled") == "1" || c.PostForm("enabled") == "on",
|
||||
}
|
||||
}
|
||||
|
||||
func (f adminBadgeForm) toDef() models.BadgeDef {
|
||||
return models.BadgeDef{
|
||||
Code: f.Code, Name: f.Name, Description: f.Description, Icon: f.Icon,
|
||||
Kind: f.Kind, Metric: f.Metric, Threshold: f.Threshold,
|
||||
SortOrder: f.SortOrder, Enabled: f.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminBadgeCreate 新建徽章定义
|
||||
func (d Deps) AdminBadgeCreate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
form := adminBadgeFormFrom(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBadges(ctx, "无效请求,请重试", form, "", 0)
|
||||
return
|
||||
}
|
||||
if d.Badge == nil {
|
||||
d.renderAdminBadges(ctx, "徽章服务未就绪", form, "", 0)
|
||||
return
|
||||
}
|
||||
def := form.toDef()
|
||||
if err := d.Badge.UpsertDef(&def); err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), form, "", 0)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("徽章已保存")
|
||||
ctx.Redirect("/admin/badges")
|
||||
}
|
||||
|
||||
// AdminBadgeEditGet 编辑表单
|
||||
func (d Deps) AdminBadgeEditGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if d.Badge == nil {
|
||||
d.renderAdminBadges(ctx, "徽章服务未就绪", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
list, err := d.Badge.ListDefs(true)
|
||||
if err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), adminBadgeForm{}, "", 0)
|
||||
return
|
||||
}
|
||||
for _, b := range list {
|
||||
if b.ID == uint(id64) {
|
||||
d.renderAdminBadges(ctx, "", adminBadgeForm{
|
||||
ID: b.ID, Code: b.Code, Name: b.Name, Description: b.Description, Icon: b.Icon,
|
||||
Kind: b.Kind, Metric: b.Metric, Threshold: b.Threshold, SortOrder: b.SortOrder,
|
||||
Enabled: b.Enabled, IsEdit: true,
|
||||
}, "", 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.SetFlash("徽章不存在")
|
||||
ctx.Redirect("/admin/badges")
|
||||
}
|
||||
|
||||
// AdminBadgeUpdate 更新徽章定义
|
||||
func (d Deps) AdminBadgeUpdate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
form := adminBadgeFormFrom(c)
|
||||
form.ID = uint(id64)
|
||||
form.IsEdit = true
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBadges(ctx, "无效请求,请重试", form, "", 0)
|
||||
return
|
||||
}
|
||||
if d.Badge == nil {
|
||||
d.renderAdminBadges(ctx, "徽章服务未就绪", form, "", 0)
|
||||
return
|
||||
}
|
||||
// 编辑时 code 不可改:从已有定义取 code,避免误覆盖其它徽章
|
||||
list, err := d.Badge.ListDefs(true)
|
||||
if err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), form, "", 0)
|
||||
return
|
||||
}
|
||||
var found *models.BadgeDef
|
||||
for i := range list {
|
||||
if list[i].ID == uint(id64) {
|
||||
found = &list[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
d.renderAdminBadges(ctx, "徽章不存在", form, "", 0)
|
||||
return
|
||||
}
|
||||
form.Code = found.Code
|
||||
def := form.toDef()
|
||||
if err := d.Badge.UpsertDef(&def); err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), form, "", 0)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("徽章已更新")
|
||||
ctx.Redirect("/admin/badges")
|
||||
}
|
||||
|
||||
// AdminBadgeDelete 删除徽章定义
|
||||
func (d Deps) AdminBadgeDelete(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBadges(ctx, "无效请求,请重试", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
|
||||
return
|
||||
}
|
||||
if d.Badge == nil {
|
||||
d.renderAdminBadges(ctx, "徽章服务未就绪", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Badge.DeleteDef(uint(id64)); err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("徽章已删除")
|
||||
ctx.Redirect("/admin/badges")
|
||||
}
|
||||
|
||||
func (d Deps) resolveAwardUser(raw string) (uint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, errors.New("请填写用户名或用户 ID")
|
||||
}
|
||||
if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 {
|
||||
u, e := d.User.GetByID(uint(id))
|
||||
if e != nil || u == nil {
|
||||
return 0, errors.New("用户不存在")
|
||||
}
|
||||
return u.ID, nil
|
||||
}
|
||||
u, err := d.User.GetByUsername(raw)
|
||||
if err != nil || u == nil {
|
||||
return 0, errors.New("用户不存在")
|
||||
}
|
||||
return u.ID, nil
|
||||
}
|
||||
|
||||
// AdminBadgeAwardPost 颁发限定徽章
|
||||
func (d Deps) AdminBadgeAwardPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
userRaw := strings.TrimSpace(c.PostForm("user"))
|
||||
badgeID, _ := strconv.ParseUint(strings.TrimSpace(c.PostForm("badge_id")), 10, 64)
|
||||
form := adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBadges(ctx, "无效请求,请重试", form, userRaw, uint(badgeID))
|
||||
return
|
||||
}
|
||||
if d.Badge == nil || d.User == nil {
|
||||
d.renderAdminBadges(ctx, "服务未就绪", form, userRaw, uint(badgeID))
|
||||
return
|
||||
}
|
||||
uid, err := d.resolveAwardUser(userRaw)
|
||||
if err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID))
|
||||
return
|
||||
}
|
||||
if badgeID == 0 {
|
||||
d.renderAdminBadges(ctx, "请选择徽章", form, userRaw, 0)
|
||||
return
|
||||
}
|
||||
if err := d.Badge.AwardLimited(uid, uint(badgeID), ctx.UserID()); err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID))
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已颁发徽章")
|
||||
ctx.Redirect("/admin/badges")
|
||||
}
|
||||
|
||||
// AdminBadgeRevokePost 收回徽章
|
||||
func (d Deps) AdminBadgeRevokePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
userRaw := strings.TrimSpace(c.PostForm("user"))
|
||||
badgeID, _ := strconv.ParseUint(strings.TrimSpace(c.PostForm("badge_id")), 10, 64)
|
||||
form := adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBadges(ctx, "无效请求,请重试", form, userRaw, uint(badgeID))
|
||||
return
|
||||
}
|
||||
if d.Badge == nil || d.User == nil {
|
||||
d.renderAdminBadges(ctx, "服务未就绪", form, userRaw, uint(badgeID))
|
||||
return
|
||||
}
|
||||
uid, err := d.resolveAwardUser(userRaw)
|
||||
if err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID))
|
||||
return
|
||||
}
|
||||
if badgeID == 0 {
|
||||
d.renderAdminBadges(ctx, "请选择徽章", form, userRaw, 0)
|
||||
return
|
||||
}
|
||||
if err := d.Badge.Revoke(uid, uint(badgeID)); err != nil {
|
||||
d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID))
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已收回徽章")
|
||||
ctx.Redirect("/admin/badges")
|
||||
}
|
||||
234
routers/web/admin_friend_links.go
Normal file
234
routers/web/admin_friend_links.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type adminFriendLinkBrandRow struct {
|
||||
Name string
|
||||
URL string
|
||||
Logo string
|
||||
}
|
||||
|
||||
type adminFriendLinkApplyRow struct {
|
||||
ID uint
|
||||
UserID uint
|
||||
Username string
|
||||
Name string
|
||||
URL string
|
||||
Logo string
|
||||
Status string
|
||||
StatusLabel string
|
||||
OnHome bool
|
||||
Reciprocal string
|
||||
RecipOK bool
|
||||
RecipNote string
|
||||
CreatedAt string
|
||||
CanReview bool
|
||||
CanRecheck bool
|
||||
}
|
||||
|
||||
type adminFriendLinksData struct {
|
||||
AdminChrome
|
||||
BrandLinks []adminFriendLinkBrandRow
|
||||
Applies []adminFriendLinkApplyRow
|
||||
PendingCount int64
|
||||
NavShow bool
|
||||
FooterShow bool
|
||||
AsideShow bool
|
||||
ReciprocalCheck bool
|
||||
BrandName string
|
||||
BrandURL string
|
||||
BrandLogo string
|
||||
}
|
||||
|
||||
// AdminFriendLinksGet 友链管理
|
||||
func (d Deps) AdminFriendLinksGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminFriendLinks(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminFriendLinks(ctx *webctx.Context, errMsg string) {
|
||||
chrome := d.adminChrome(ctx, "友链", "friend-links")
|
||||
chrome.Error = errMsg
|
||||
data := adminFriendLinksData{
|
||||
AdminChrome: chrome,
|
||||
NavShow: d.Settings.NavShowFriendLinks(),
|
||||
FooterShow: d.Settings.FooterShowFriendLinks(),
|
||||
AsideShow: d.Settings.AsideShowFriendLinks(),
|
||||
ReciprocalCheck: d.Settings.FriendLinkReciprocalCheckEnabled(),
|
||||
}
|
||||
brand := d.Settings.SiteBranding()
|
||||
for _, l := range brand.FriendLinks {
|
||||
data.BrandLinks = append(data.BrandLinks, adminFriendLinkBrandRow{Name: l.Name, URL: l.URL, Logo: l.Logo})
|
||||
}
|
||||
if d.FriendLink != nil {
|
||||
data.PendingCount, _ = d.FriendLink.PendingCount()
|
||||
rows, _, _ := d.FriendLink.ListAdmin(services.FriendLinkApplyListQuery{Page: 1, Size: 50, Status: "all"})
|
||||
data.Applies = make([]adminFriendLinkApplyRow, 0, len(rows))
|
||||
for _, a := range rows {
|
||||
uname := ""
|
||||
if a.User.ID > 0 {
|
||||
uname = a.User.Username
|
||||
}
|
||||
data.Applies = append(data.Applies, adminFriendLinkApplyRow{
|
||||
ID: a.ID,
|
||||
UserID: a.UserID,
|
||||
Username: uname,
|
||||
Name: a.Name,
|
||||
URL: a.URL,
|
||||
Logo: a.Logo,
|
||||
Status: a.Status,
|
||||
StatusLabel: friendLinkApplyStatusLabel(a.Status),
|
||||
OnHome: a.LinkOnHomepage,
|
||||
Reciprocal: a.ReciprocalPageURL,
|
||||
RecipOK: a.ReciprocalVerified,
|
||||
RecipNote: a.ReciprocalCheckNote,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"),
|
||||
CanReview: a.Status == models.FriendLinkApplyStatusPending,
|
||||
CanRecheck: data.ReciprocalCheck && strings.TrimSpace(a.ReciprocalPageURL) != "",
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/friend_links", data)
|
||||
}
|
||||
|
||||
// AdminFriendLinksSettingsPost nav/footer/回链开关
|
||||
func (d Deps) AdminFriendLinksSettingsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
_ = d.Settings.SetNavShowFriendLinks(c.PostForm("nav_show") == "1" || c.PostForm("nav_show") == "on")
|
||||
_ = d.Settings.SetFooterShowFriendLinks(c.PostForm("footer_show") == "1" || c.PostForm("footer_show") == "on")
|
||||
_ = d.Settings.SetAsideFriendLinksEnabled(c.PostForm("aside_show") == "1" || c.PostForm("aside_show") == "on")
|
||||
_ = d.Settings.SetFriendLinkReciprocalCheckEnabled(c.PostForm("reciprocal_check") == "1" || c.PostForm("reciprocal_check") == "on")
|
||||
ctx.SetFlash("友链入口设置已保存")
|
||||
ctx.Redirect("/admin/friend-links")
|
||||
}
|
||||
|
||||
// AdminFriendLinksBrandAddPost 添加品牌友链
|
||||
func (d Deps) AdminFriendLinksBrandAddPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
url := strings.TrimSpace(c.PostForm("url"))
|
||||
logo := strings.TrimSpace(c.PostForm("logo"))
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
brand := d.Settings.SiteBranding()
|
||||
next := append([]services.FriendLink{}, brand.FriendLinks...)
|
||||
next = append(next, services.FriendLink{Name: name, URL: url, Logo: logo})
|
||||
if err := d.Settings.UpdateSiteBranding(services.SiteBranding{
|
||||
Name: brand.Name, Slogan: brand.Slogan, Description: brand.Description,
|
||||
Keywords: brand.Keywords, LogoMark: brand.LogoMark, Logo: brand.Logo,
|
||||
Favicon: brand.Favicon, OGImage: brand.OGImage,
|
||||
ICPBeian: brand.ICPBeian, ICPBeianURL: brand.ICPBeianURL,
|
||||
FriendLinks: next,
|
||||
}); err != nil {
|
||||
d.renderAdminFriendLinks(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已添加品牌友链")
|
||||
ctx.Redirect("/admin/friend-links")
|
||||
}
|
||||
|
||||
// AdminFriendLinksBrandDeletePost 删除品牌友链(按 URL)
|
||||
func (d Deps) AdminFriendLinksBrandDeletePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
target := strings.TrimSpace(c.PostForm("url"))
|
||||
brand := d.Settings.SiteBranding()
|
||||
next := make([]services.FriendLink, 0, len(brand.FriendLinks))
|
||||
for _, l := range brand.FriendLinks {
|
||||
if l.URL == target {
|
||||
continue
|
||||
}
|
||||
next = append(next, l)
|
||||
}
|
||||
if err := d.Settings.UpdateSiteBranding(services.SiteBranding{
|
||||
Name: brand.Name, Slogan: brand.Slogan, Description: brand.Description,
|
||||
Keywords: brand.Keywords, LogoMark: brand.LogoMark, Logo: brand.Logo,
|
||||
Favicon: brand.Favicon, OGImage: brand.OGImage,
|
||||
ICPBeian: brand.ICPBeian, ICPBeianURL: brand.ICPBeianURL,
|
||||
FriendLinks: next,
|
||||
}); err != nil {
|
||||
d.renderAdminFriendLinks(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已删除品牌友链")
|
||||
ctx.Redirect("/admin/friend-links")
|
||||
}
|
||||
|
||||
// AdminFriendLinkApprovePost 通过申请
|
||||
func (d Deps) AdminFriendLinkApprovePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.FriendLink == nil {
|
||||
d.renderAdminFriendLinks(ctx, "友链服务未就绪")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if _, err := d.FriendLink.Approve(uint(id64)); err != nil {
|
||||
d.renderAdminFriendLinks(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已通过友链申请")
|
||||
ctx.Redirect("/admin/friend-links")
|
||||
}
|
||||
|
||||
// AdminFriendLinkRejectPost 拒绝申请
|
||||
func (d Deps) AdminFriendLinkRejectPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.FriendLink == nil {
|
||||
d.renderAdminFriendLinks(ctx, "友链服务未就绪")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
note := strings.TrimSpace(c.PostForm("note"))
|
||||
if _, err := d.FriendLink.Reject(uint(id64), note); err != nil {
|
||||
d.renderAdminFriendLinks(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已拒绝友链申请")
|
||||
ctx.Redirect("/admin/friend-links")
|
||||
}
|
||||
|
||||
// AdminFriendLinkRecheckPost 重新检测回链
|
||||
func (d Deps) AdminFriendLinkRecheckPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.FriendLink == nil {
|
||||
d.renderAdminFriendLinks(ctx, "友链服务未就绪")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if _, err := d.FriendLink.RecheckReciprocal(uint(id64), d.publicBaseURL(c)); err != nil {
|
||||
d.renderAdminFriendLinks(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已开始重新检测回链")
|
||||
ctx.Redirect("/admin/friend-links")
|
||||
}
|
||||
255
routers/web/admin_media.go
Normal file
255
routers/web/admin_media.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type adminMediaRow struct {
|
||||
ID uint
|
||||
Category string
|
||||
CategoryLabel string
|
||||
Name string
|
||||
URL string
|
||||
ThumbURL string
|
||||
SizeLabel string
|
||||
Modified string
|
||||
StorageType string
|
||||
}
|
||||
|
||||
type adminMediaData struct {
|
||||
AdminChrome
|
||||
Files []adminMediaRow
|
||||
Keyword string
|
||||
Category string
|
||||
Page int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
QuerySuffix string
|
||||
StorageType string
|
||||
CountAll int
|
||||
CountAvatars int
|
||||
CountPosts int
|
||||
CountSite int
|
||||
}
|
||||
|
||||
// AdminMediaGet 媒体库列表
|
||||
func (d Deps) AdminMediaGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminMedia(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminMedia(ctx *webctx.Context, errMsg string) {
|
||||
chrome := d.adminChrome(ctx, "媒体", "media")
|
||||
chrome.Error = errMsg
|
||||
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
keyword := strings.TrimSpace(ctx.C.Query("q"))
|
||||
category := strings.TrimSpace(ctx.C.DefaultQuery("category", "all"))
|
||||
if category == "" {
|
||||
category = "all"
|
||||
}
|
||||
size := 24
|
||||
data := adminMediaData{
|
||||
AdminChrome: chrome,
|
||||
Keyword: keyword,
|
||||
Category: category,
|
||||
Page: page,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
HasPrev: page > 1,
|
||||
Files: []adminMediaRow{},
|
||||
}
|
||||
q := url.Values{}
|
||||
if keyword != "" {
|
||||
q.Set("q", keyword)
|
||||
}
|
||||
if category != "" && category != "all" {
|
||||
q.Set("category", category)
|
||||
}
|
||||
data.QuerySuffix = q.Encode()
|
||||
|
||||
if d.Store == nil {
|
||||
chrome.Error = "上传存储未就绪"
|
||||
data.AdminChrome = chrome
|
||||
ctx.HTML(http.StatusOK, "admin/media", data)
|
||||
return
|
||||
}
|
||||
result, err := d.Store.ListMedia(category, keyword, page, size)
|
||||
if err != nil {
|
||||
chrome.Error = err.Error()
|
||||
data.AdminChrome = chrome
|
||||
ctx.HTML(http.StatusOK, "admin/media", data)
|
||||
return
|
||||
}
|
||||
data.Total = result.Total
|
||||
data.Page = result.Page
|
||||
data.HasPrev = result.Page > 1
|
||||
data.HasMore = result.Page < result.TotalPages
|
||||
data.PrevPage = result.Page - 1
|
||||
data.NextPage = result.Page + 1
|
||||
data.StorageType = result.StorageType
|
||||
data.CountAvatars = result.CategoryCounts[services.UploadCategoryAvatars]
|
||||
data.CountPosts = result.CategoryCounts[services.UploadCategoryPosts]
|
||||
data.CountSite = result.CategoryCounts[services.UploadCategorySite]
|
||||
data.CountAll = data.CountAvatars + data.CountPosts + data.CountSite
|
||||
data.Files = make([]adminMediaRow, 0, len(result.Files))
|
||||
for _, f := range result.Files {
|
||||
thumb := services.ThumbURLFromUpload(f.URL)
|
||||
if thumb == "" {
|
||||
thumb = f.URL
|
||||
}
|
||||
data.Files = append(data.Files, adminMediaRow{
|
||||
ID: f.ID, Category: f.Category, CategoryLabel: adminMediaCategoryLabel(f.Category),
|
||||
Name: f.Name, URL: f.URL, ThumbURL: thumb,
|
||||
SizeLabel: formatByteSize(f.Size), Modified: f.ModifiedAt.Local().Format("2006-01-02 15:04"),
|
||||
StorageType: f.StorageType,
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/media", data)
|
||||
}
|
||||
|
||||
func adminMediaCategoryLabel(cat string) string {
|
||||
switch cat {
|
||||
case services.UploadCategoryAvatars:
|
||||
return "头像"
|
||||
case services.UploadCategoryPosts:
|
||||
return "帖图"
|
||||
case services.UploadCategorySite:
|
||||
return "站点"
|
||||
default:
|
||||
return cat
|
||||
}
|
||||
}
|
||||
|
||||
func formatByteSize(n int64) string {
|
||||
if n < 1024 {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
if n < 1024*1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
||||
}
|
||||
|
||||
func (d Deps) mediaRedirectQuery(c *gin.Context) string {
|
||||
q := url.Values{}
|
||||
if v := strings.TrimSpace(c.Query("q")); v != "" {
|
||||
q.Set("q", v)
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("category")); v != "" && v != "all" {
|
||||
q.Set("category", v)
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("page")); v != "" && v != "1" {
|
||||
q.Set("page", v)
|
||||
}
|
||||
// POST 表单也可带回筛选
|
||||
if v := strings.TrimSpace(c.PostForm("q")); v != "" {
|
||||
q.Set("q", v)
|
||||
}
|
||||
if v := strings.TrimSpace(c.PostForm("category")); v != "" && v != "all" {
|
||||
q.Set("category", v)
|
||||
}
|
||||
if v := strings.TrimSpace(c.PostForm("page")); v != "" && v != "1" {
|
||||
q.Set("page", v)
|
||||
}
|
||||
s := q.Encode()
|
||||
if s == "" {
|
||||
return "/admin/media"
|
||||
}
|
||||
return "/admin/media?" + s
|
||||
}
|
||||
|
||||
// AdminMediaDeletePost 按 ID 删除单条
|
||||
func (d Deps) AdminMediaDeletePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminMedia(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.Store == nil {
|
||||
d.renderAdminMedia(ctx, "上传存储未就绪")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
n, err := d.Store.DeleteMediaByIDs([]uint{uint(id64)})
|
||||
if err != nil {
|
||||
d.renderAdminMedia(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
d.renderAdminMedia(ctx, "未找到可删除的媒体")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已删除 1 项媒体")
|
||||
ctx.Redirect(d.mediaRedirectQuery(c))
|
||||
}
|
||||
|
||||
// AdminMediaBatchDeletePost 批量删除
|
||||
func (d Deps) AdminMediaBatchDeletePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminMedia(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.Store == nil {
|
||||
d.renderAdminMedia(ctx, "上传存储未就绪")
|
||||
return
|
||||
}
|
||||
raw := c.PostFormArray("ids")
|
||||
ids := make([]uint, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
id64, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil || id64 == 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, uint(id64))
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
d.renderAdminMedia(ctx, "请选择要删除的文件")
|
||||
return
|
||||
}
|
||||
if len(ids) > 100 {
|
||||
d.renderAdminMedia(ctx, "单次最多删除 100 个文件")
|
||||
return
|
||||
}
|
||||
n, err := d.Store.DeleteMediaByIDs(ids)
|
||||
if err != nil {
|
||||
d.renderAdminMedia(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash(fmt.Sprintf("已删除 %d 项媒体", n))
|
||||
ctx.Redirect(d.mediaRedirectQuery(c))
|
||||
}
|
||||
|
||||
// AdminMediaSyncPost 扫盘回填索引
|
||||
func (d Deps) AdminMediaSyncPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminMedia(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.Store == nil {
|
||||
d.renderAdminMedia(ctx, "上传存储未就绪")
|
||||
return
|
||||
}
|
||||
n, err := d.Store.SyncMediaIndex()
|
||||
if err != nil {
|
||||
d.renderAdminMedia(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash(fmt.Sprintf("索引已同步(处理 %d 条)", n))
|
||||
ctx.Redirect("/admin/media")
|
||||
}
|
||||
198
routers/web/admin_pages.go
Normal file
198
routers/web/admin_pages.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type adminPageRow struct {
|
||||
ID uint
|
||||
Title string
|
||||
Slug string
|
||||
Published bool
|
||||
SortOrder int
|
||||
ShowInNav bool
|
||||
ShowInFooter bool
|
||||
}
|
||||
|
||||
type adminPageForm struct {
|
||||
ID uint
|
||||
Title string
|
||||
Slug string
|
||||
Content string
|
||||
Published bool
|
||||
SortOrder int
|
||||
ShowInNav bool
|
||||
ShowInFooter bool
|
||||
IsEdit bool
|
||||
}
|
||||
|
||||
type adminPagesData struct {
|
||||
AdminChrome
|
||||
Pages []adminPageRow
|
||||
Form adminPageForm
|
||||
}
|
||||
|
||||
// AdminPagesGet 站点单页列表
|
||||
func (d Deps) AdminPagesGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminPages(ctx, "", adminPageForm{})
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminPages(ctx *webctx.Context, errMsg string, form adminPageForm) {
|
||||
chrome := d.adminChrome(ctx, "站点单页", "pages")
|
||||
chrome.Error = errMsg
|
||||
data := adminPagesData{AdminChrome: chrome, Form: form}
|
||||
if d.SitePage != nil {
|
||||
list, _ := d.SitePage.ListAll()
|
||||
data.Pages = make([]adminPageRow, 0, len(list))
|
||||
for _, p := range list {
|
||||
data.Pages = append(data.Pages, adminPageRow{
|
||||
ID: p.ID, Title: p.Title, Slug: p.Slug, Published: p.Published,
|
||||
SortOrder: p.SortOrder, ShowInNav: p.ShowInNav, ShowInFooter: p.ShowInFooter,
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/pages", data)
|
||||
}
|
||||
|
||||
// AdminPageCreate 新建单页
|
||||
func (d Deps) AdminPageCreate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
form := adminPageFormFrom(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminPages(ctx, "无效请求,请重试", form)
|
||||
return
|
||||
}
|
||||
if d.SitePage == nil {
|
||||
d.renderAdminPages(ctx, "单页服务未就绪", form)
|
||||
return
|
||||
}
|
||||
if _, err := d.SitePage.Create(adminPageInputFrom(form)); err != nil {
|
||||
d.renderAdminPages(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("单页已创建")
|
||||
ctx.Redirect("/admin/pages")
|
||||
}
|
||||
|
||||
// AdminPageEditGet 编辑表单
|
||||
func (d Deps) AdminPageEditGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if d.SitePage == nil {
|
||||
d.renderAdminPages(ctx, "单页服务未就绪", adminPageForm{})
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
page, err := d.SitePage.GetByID(uint(id))
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/pages")
|
||||
return
|
||||
}
|
||||
d.renderAdminPages(ctx, "", adminPageForm{
|
||||
ID: page.ID, Title: page.Title, Slug: page.Slug, Content: page.Content,
|
||||
Published: page.Published, SortOrder: page.SortOrder,
|
||||
ShowInNav: page.ShowInNav, ShowInFooter: page.ShowInFooter, IsEdit: true,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminPageUpdate 更新单页
|
||||
func (d Deps) AdminPageUpdate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
form := adminPageFormFrom(c)
|
||||
form.ID = uint(id)
|
||||
form.IsEdit = true
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminPages(ctx, "无效请求,请重试", form)
|
||||
return
|
||||
}
|
||||
if d.SitePage == nil {
|
||||
d.renderAdminPages(ctx, "单页服务未就绪", form)
|
||||
return
|
||||
}
|
||||
if err := d.SitePage.Update(uint(id), adminPageInputFrom(form)); err != nil {
|
||||
d.renderAdminPages(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("单页已保存")
|
||||
ctx.Redirect("/admin/pages")
|
||||
}
|
||||
|
||||
// AdminPageDelete 删除
|
||||
func (d Deps) AdminPageDelete(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/pages")
|
||||
return
|
||||
}
|
||||
if d.SitePage == nil {
|
||||
ctx.SetFlash("单页服务未就绪")
|
||||
ctx.Redirect("/admin/pages")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.SitePage.Delete(uint(id)); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/pages")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("单页已删除")
|
||||
ctx.Redirect("/admin/pages")
|
||||
}
|
||||
|
||||
// AdminPagePublishPost 快捷发布/下架
|
||||
func (d Deps) AdminPagePublishPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/pages")
|
||||
return
|
||||
}
|
||||
if d.SitePage == nil {
|
||||
ctx.SetFlash("单页服务未就绪")
|
||||
ctx.Redirect("/admin/pages")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
published := c.PostForm("published") == "1" || c.PostForm("published") == "on"
|
||||
if err := d.SitePage.SetPublished(uint(id), published); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/pages")
|
||||
return
|
||||
}
|
||||
if published {
|
||||
ctx.SetFlash("已发布")
|
||||
} else {
|
||||
ctx.SetFlash("已下架")
|
||||
}
|
||||
ctx.Redirect("/admin/pages")
|
||||
}
|
||||
|
||||
func adminPageFormFrom(c *gin.Context) adminPageForm {
|
||||
sort, _ := strconv.Atoi(c.PostForm("sort_order"))
|
||||
return adminPageForm{
|
||||
Title: strings.TrimSpace(c.PostForm("title")),
|
||||
Slug: strings.TrimSpace(c.PostForm("slug")),
|
||||
Content: c.PostForm("content"),
|
||||
Published: c.PostForm("published") == "1" || c.PostForm("published") == "on",
|
||||
SortOrder: sort,
|
||||
ShowInNav: c.PostForm("show_in_nav") == "1" || c.PostForm("show_in_nav") == "on",
|
||||
ShowInFooter: c.PostForm("show_in_footer") == "1" || c.PostForm("show_in_footer") == "on",
|
||||
}
|
||||
}
|
||||
|
||||
func adminPageInputFrom(form adminPageForm) services.SitePageInput {
|
||||
return services.SitePageInput{
|
||||
Title: form.Title, Slug: form.Slug, Content: form.Content,
|
||||
Published: form.Published, SortOrder: form.SortOrder,
|
||||
ShowInNav: form.ShowInNav, ShowInFooter: form.ShowInFooter,
|
||||
}
|
||||
}
|
||||
285
routers/web/admin_post_ops.go
Normal file
285
routers/web/admin_post_ops.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (d Deps) adminPostRedirect(postID uint) string {
|
||||
return fmt.Sprintf("/post/%d", postID)
|
||||
}
|
||||
|
||||
func (d Deps) parseAdminPostID(c *gin.Context) (uint, string, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, "/", false
|
||||
}
|
||||
return uint(id), d.adminPostRedirect(uint(id)), true
|
||||
}
|
||||
|
||||
func (d Deps) requireAdminPostCSRF(ctx *webctx.Context, redir string) bool {
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect(redir)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func formBoolEnable(c *gin.Context) bool {
|
||||
v := strings.TrimSpace(c.PostForm("enable"))
|
||||
return v == "1" || strings.EqualFold(v, "true") || v == "on"
|
||||
}
|
||||
|
||||
// AdminPostPinPost 全局置顶切换
|
||||
func (d Deps) AdminPostPinPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
id, redir, ok := d.parseAdminPostID(c)
|
||||
if !ok {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if !d.requireAdminPostCSRF(ctx, redir) {
|
||||
return
|
||||
}
|
||||
enable := formBoolEnable(c)
|
||||
if err := d.Post.SetPinned(id, enable); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if enable {
|
||||
ctx.SetFlash("已全局置顶")
|
||||
} else {
|
||||
ctx.SetFlash("已取消全局置顶")
|
||||
}
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
// AdminPostBoardPinPost 版内置顶切换
|
||||
func (d Deps) AdminPostBoardPinPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
id, redir, ok := d.parseAdminPostID(c)
|
||||
if !ok {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if !d.requireAdminPostCSRF(ctx, redir) {
|
||||
return
|
||||
}
|
||||
enable := formBoolEnable(c)
|
||||
if err := d.Post.SetBoardPinned(id, enable); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if enable {
|
||||
ctx.SetFlash("已版内置顶")
|
||||
} else {
|
||||
ctx.SetFlash("已取消版内置顶")
|
||||
}
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
// AdminPostFeaturePost 精华切换
|
||||
func (d Deps) AdminPostFeaturePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
id, redir, ok := d.parseAdminPostID(c)
|
||||
if !ok {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if !d.requireAdminPostCSRF(ctx, redir) {
|
||||
return
|
||||
}
|
||||
enable := formBoolEnable(c)
|
||||
if err := d.Post.SetFeatured(id, enable); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if enable {
|
||||
ctx.SetFlash("已设为精华")
|
||||
} else {
|
||||
ctx.SetFlash("已取消精华")
|
||||
}
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
// AdminPostEditLockPost 禁止编辑切换
|
||||
func (d Deps) AdminPostEditLockPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
id, redir, ok := d.parseAdminPostID(c)
|
||||
if !ok {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if !d.requireAdminPostCSRF(ctx, redir) {
|
||||
return
|
||||
}
|
||||
enable := formBoolEnable(c)
|
||||
if err := d.Post.SetEditLocked(id, enable); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if enable {
|
||||
ctx.SetFlash("已禁止编辑")
|
||||
} else {
|
||||
ctx.SetFlash("已允许编辑")
|
||||
}
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
// AdminPostCommentsLockPost 禁止评论切换
|
||||
func (d Deps) AdminPostCommentsLockPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
id, redir, ok := d.parseAdminPostID(c)
|
||||
if !ok {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if !d.requireAdminPostCSRF(ctx, redir) {
|
||||
return
|
||||
}
|
||||
enable := formBoolEnable(c)
|
||||
if err := d.Post.SetCommentsLocked(id, enable); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if enable {
|
||||
ctx.SetFlash("已锁定评论")
|
||||
} else {
|
||||
ctx.SetFlash("已解锁评论")
|
||||
}
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
// AdminPostDeletePost 软删进回收站
|
||||
func (d Deps) AdminPostDeletePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
id, redir, ok := d.parseAdminPostID(c)
|
||||
if !ok {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if !d.requireAdminPostCSRF(ctx, redir) {
|
||||
return
|
||||
}
|
||||
if err := d.Post.Delete(ctx.UserID(), id, true); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("帖子已移入回收站")
|
||||
ctx.Redirect("/admin/trash")
|
||||
}
|
||||
|
||||
type adminTrashRow struct {
|
||||
ID uint
|
||||
Title string
|
||||
AuthorName string
|
||||
BoardName string
|
||||
DeletedLabel string
|
||||
}
|
||||
|
||||
type adminTrashData struct {
|
||||
AdminChrome
|
||||
Items []adminTrashRow
|
||||
Page int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
Keyword string
|
||||
}
|
||||
|
||||
// AdminTrashGet 回收站
|
||||
func (d Deps) AdminTrashGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminTrash(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminTrash(ctx *webctx.Context, errMsg string) {
|
||||
chrome := d.adminChrome(ctx, "回收站", "trash")
|
||||
chrome.Error = errMsg
|
||||
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
keyword := strings.TrimSpace(ctx.C.Query("keyword"))
|
||||
size := d.Settings.PageSizeDefault()
|
||||
data := adminTrashData{
|
||||
AdminChrome: chrome,
|
||||
Page: page,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
HasPrev: page > 1,
|
||||
Keyword: keyword,
|
||||
}
|
||||
list, total, err := d.Post.ListTrash(page, size, keyword)
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
ctx.HTML(http.StatusOK, "admin/trash", data)
|
||||
return
|
||||
}
|
||||
data.HasMore = int64(page*size) < total
|
||||
data.Items = make([]adminTrashRow, 0, len(list))
|
||||
for _, it := range list {
|
||||
author := strings.TrimSpace(it.User.Nickname)
|
||||
if author == "" {
|
||||
author = it.User.Username
|
||||
}
|
||||
bname := ""
|
||||
if it.Board.ID > 0 {
|
||||
bname = it.Board.Name
|
||||
}
|
||||
data.Items = append(data.Items, adminTrashRow{
|
||||
ID: it.ID, Title: it.Title, AuthorName: author, BoardName: bname,
|
||||
DeletedLabel: it.DeletedAt.Local().Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/trash", data)
|
||||
}
|
||||
|
||||
// AdminTrashRestorePost 恢复
|
||||
func (d Deps) AdminTrashRestorePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/trash")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Post.Restore(uint(id)); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/trash")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已恢复")
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||||
}
|
||||
|
||||
// AdminTrashPurgePost 彻底删除
|
||||
func (d Deps) AdminTrashPurgePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/trash")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Post.Purge(uint(id)); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/trash")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已彻底删除")
|
||||
ctx.Redirect("/admin/trash")
|
||||
}
|
||||
143
routers/web/admin_reports.go
Normal file
143
routers/web/admin_reports.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type adminReportRow struct {
|
||||
ID uint
|
||||
CreatedAt string
|
||||
Status string
|
||||
StatusLabel string
|
||||
ReasonLabel string
|
||||
Detail string
|
||||
ReporterName string
|
||||
TargetLabel string
|
||||
PostID uint
|
||||
IsComment bool
|
||||
Pending bool
|
||||
}
|
||||
|
||||
type adminReportsData struct {
|
||||
AdminChrome
|
||||
Reports []adminReportRow
|
||||
PendingCount int64
|
||||
StatusFilter string
|
||||
Page int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// AdminReportsGet 举报列表
|
||||
func (d Deps) AdminReportsGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminReports(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminReports(ctx *webctx.Context, errMsg string) {
|
||||
chrome := d.adminChrome(ctx, "举报", "reports")
|
||||
chrome.Error = errMsg
|
||||
status := strings.TrimSpace(ctx.C.DefaultQuery("status", "pending"))
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
data := adminReportsData{
|
||||
AdminChrome: chrome,
|
||||
StatusFilter: status,
|
||||
Page: page,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
HasPrev: page > 1,
|
||||
}
|
||||
if d.Report != nil {
|
||||
data.PendingCount, _ = d.Report.PendingCount()
|
||||
list, total, _ := d.Report.ListAdmin(services.ReportListQuery{
|
||||
Status: status, Page: page, Size: d.Settings.PageSizeDefault(),
|
||||
})
|
||||
data.HasMore = int64(page*d.Settings.PageSizeDefault()) < total
|
||||
data.Reports = make([]adminReportRow, 0, len(list))
|
||||
for _, r := range list {
|
||||
row := adminReportRow{
|
||||
ID: r.ID, CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
Status: r.Status, StatusLabel: reportStatusLabel(r.Status),
|
||||
ReasonLabel: services.ReportReasonLabel(r.Reason), Detail: r.Detail,
|
||||
PostID: r.PostID, Pending: r.Status == models.ReportStatusPending,
|
||||
}
|
||||
if r.Reporter.ID > 0 {
|
||||
row.ReporterName = r.Reporter.Username
|
||||
}
|
||||
isComment := r.CommentID != nil && *r.CommentID > 0
|
||||
row.IsComment = isComment
|
||||
title := ""
|
||||
if r.Post.ID > 0 {
|
||||
title = r.Post.Title
|
||||
}
|
||||
if isComment {
|
||||
floor := 0
|
||||
if r.Comment != nil {
|
||||
floor = r.Comment.Floor
|
||||
}
|
||||
row.TargetLabel = "评论 #" + strconv.Itoa(floor) + " · 《" + title + "》"
|
||||
} else {
|
||||
row.TargetLabel = "帖子 · 《" + title + "》"
|
||||
}
|
||||
data.Reports = append(data.Reports, row)
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/reports", data)
|
||||
}
|
||||
|
||||
// AdminReportHandlePost 处理举报
|
||||
func (d Deps) AdminReportHandlePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/reports")
|
||||
return
|
||||
}
|
||||
if d.Report == nil {
|
||||
ctx.SetFlash("举报服务未就绪")
|
||||
ctx.Redirect("/admin/reports")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
action := strings.TrimSpace(c.PostForm("action"))
|
||||
if _, err := d.Report.Handle(services.HandleReportInput{
|
||||
ReportID: uint(id),
|
||||
HandlerID: ctx.UserID(),
|
||||
Action: action,
|
||||
HandleNote: strings.TrimSpace(c.PostForm("handle_note")),
|
||||
RejectReason: strings.TrimSpace(c.PostForm("reject_reason")),
|
||||
}); err != nil {
|
||||
d.renderAdminReports(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("举报已处理")
|
||||
ctx.Redirect("/admin/reports?status=pending")
|
||||
}
|
||||
|
||||
func reportStatusLabel(s string) string {
|
||||
switch s {
|
||||
case models.ReportStatusPending:
|
||||
return "待处理"
|
||||
case models.ReportStatusResolved:
|
||||
return "已处理"
|
||||
case models.ReportStatusDismissed:
|
||||
return "已驳回"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
226
routers/web/admin_users.go
Normal file
226
routers/web/admin_users.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type adminUserRow struct {
|
||||
ID uint
|
||||
Username string
|
||||
Nickname string
|
||||
Email string
|
||||
Role string
|
||||
RoleLabel string
|
||||
Points int
|
||||
Level int
|
||||
Verified bool
|
||||
Banned bool
|
||||
IsAdmin bool
|
||||
Created string
|
||||
}
|
||||
|
||||
type adminUsersData struct {
|
||||
AdminChrome
|
||||
Users []adminUserRow
|
||||
Keyword string
|
||||
Filter string
|
||||
Page int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
QuerySuffix string
|
||||
LevelOptions []int
|
||||
}
|
||||
|
||||
// AdminUsersGet 用户列表
|
||||
func (d Deps) AdminUsersGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminUsers(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminUsers(ctx *webctx.Context, errMsg string) {
|
||||
chrome := d.adminChrome(ctx, "用户", "users")
|
||||
chrome.Error = errMsg
|
||||
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
keyword := strings.TrimSpace(ctx.C.Query("q"))
|
||||
filter := strings.TrimSpace(ctx.C.DefaultQuery("filter", "all"))
|
||||
if filter == "" {
|
||||
filter = "all"
|
||||
}
|
||||
size := d.Settings.PageSizeDefault()
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
data := adminUsersData{
|
||||
AdminChrome: chrome,
|
||||
Keyword: keyword,
|
||||
Filter: filter,
|
||||
Page: page,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
HasPrev: page > 1,
|
||||
LevelOptions: adminLevelOptions(),
|
||||
}
|
||||
q := url.Values{}
|
||||
if keyword != "" {
|
||||
q.Set("q", keyword)
|
||||
}
|
||||
if filter != "" && filter != "all" {
|
||||
q.Set("filter", filter)
|
||||
}
|
||||
data.QuerySuffix = q.Encode()
|
||||
|
||||
if d.User != nil {
|
||||
list, total, err := d.User.ListUsers(services.UserListQuery{
|
||||
Page: page, Size: size, Keyword: keyword, Filter: filter,
|
||||
})
|
||||
if err != nil {
|
||||
chrome.Error = err.Error()
|
||||
data.AdminChrome = chrome
|
||||
ctx.HTML(http.StatusOK, "admin/users", data)
|
||||
return
|
||||
}
|
||||
data.HasMore = int64(page*size) < total
|
||||
data.Users = make([]adminUserRow, 0, len(list))
|
||||
for _, u := range list {
|
||||
nick := strings.TrimSpace(u.Nickname)
|
||||
if nick == "" {
|
||||
nick = u.Username
|
||||
}
|
||||
data.Users = append(data.Users, adminUserRow{
|
||||
ID: u.ID, Username: u.Username, Nickname: nick, Email: u.Email,
|
||||
Role: string(u.Role), RoleLabel: adminUserRoleLabel(u.Role),
|
||||
Points: u.Points, Level: models.LevelFromExp(u.Exp),
|
||||
Verified: u.Verified, Banned: u.Banned,
|
||||
IsAdmin: u.Role == models.RoleAdmin,
|
||||
Created: u.CreatedAt.Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/users", data)
|
||||
}
|
||||
|
||||
func adminUserRoleLabel(role models.Role) string {
|
||||
switch role {
|
||||
case models.RoleAdmin:
|
||||
return "管理员"
|
||||
default:
|
||||
return "用户"
|
||||
}
|
||||
}
|
||||
|
||||
func adminLevelOptions() []int {
|
||||
n := models.MaxLevel()
|
||||
out := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminUserBanPost 禁言 / 解禁
|
||||
func (d Deps) AdminUserBanPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminUsers(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.User == nil {
|
||||
d.renderAdminUsers(ctx, "用户服务未就绪")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
banned := c.PostForm("banned") == "1" || c.PostForm("banned") == "on"
|
||||
if err := d.User.BanUser(uint(id64), banned); err != nil {
|
||||
d.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if banned {
|
||||
ctx.SetFlash("已禁言该用户")
|
||||
} else {
|
||||
ctx.SetFlash("已解除禁言")
|
||||
}
|
||||
ctx.Redirect("/admin/users")
|
||||
}
|
||||
|
||||
// AdminUserVerifyPost 认证开关
|
||||
func (d Deps) AdminUserVerifyPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminUsers(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
verified := c.PostForm("verified") == "1" || c.PostForm("verified") == "on"
|
||||
if err := services.SetVerified(uint(id64), verified); err != nil {
|
||||
d.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if verified {
|
||||
ctx.SetFlash("已设为认证用户")
|
||||
} else {
|
||||
ctx.SetFlash("已取消认证")
|
||||
}
|
||||
ctx.Redirect("/admin/users")
|
||||
}
|
||||
|
||||
// AdminUserPointsPost 调整积分
|
||||
func (d Deps) AdminUserPointsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminUsers(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.Points == nil {
|
||||
d.renderAdminUsers(ctx, "积分服务未就绪")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
delta, err := strconv.Atoi(strings.TrimSpace(c.PostForm("delta")))
|
||||
if err != nil || delta == 0 {
|
||||
d.renderAdminUsers(ctx, "请填写非零整数增减额")
|
||||
return
|
||||
}
|
||||
note := strings.TrimSpace(c.PostForm("note"))
|
||||
bal, err := d.Points.AdminAdjust(uint(id64), delta, note)
|
||||
if err != nil {
|
||||
d.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash(fmt.Sprintf("积分已调整,当前余额 %d", bal))
|
||||
ctx.Redirect("/admin/users")
|
||||
}
|
||||
|
||||
// AdminUserLevelPost 设定等级(同步 Exp 到门槛)
|
||||
func (d Deps) AdminUserLevelPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminUsers(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
level, err := strconv.Atoi(strings.TrimSpace(c.PostForm("level")))
|
||||
if err != nil {
|
||||
d.renderAdminUsers(ctx, "请选择有效等级")
|
||||
return
|
||||
}
|
||||
if err := services.SetUserLevel(uint(id64), level); err != nil {
|
||||
d.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash(fmt.Sprintf("已设为 Lv%d(经验门槛 %d)", level, models.ExpForLevel(level)))
|
||||
ctx.Redirect("/admin/users")
|
||||
}
|
||||
31
routers/web/aside_widgets_test.go
Normal file
31
routers/web/aside_widgets_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
)
|
||||
|
||||
func TestMoveAsideWidget(t *testing.T) {
|
||||
base := []services.AsideWidget{
|
||||
{ID: services.AsideWidgetTagCloud, Enabled: true},
|
||||
{ID: services.AsideWidgetRecentComments, Enabled: false},
|
||||
{ID: services.AsideWidgetRecentUsers, Enabled: false},
|
||||
{ID: services.AsideWidgetFriendLinks, Enabled: true},
|
||||
}
|
||||
|
||||
up := moveAsideWidget(append([]services.AsideWidget(nil), base...), "up:"+services.AsideWidgetRecentComments)
|
||||
if up[0].ID != services.AsideWidgetRecentComments || up[1].ID != services.AsideWidgetTagCloud {
|
||||
t.Fatalf("up move failed: %+v", up)
|
||||
}
|
||||
|
||||
down := moveAsideWidget(append([]services.AsideWidget(nil), base...), "down:"+services.AsideWidgetTagCloud)
|
||||
if down[0].ID != services.AsideWidgetRecentComments || down[1].ID != services.AsideWidgetTagCloud {
|
||||
t.Fatalf("down move failed: %+v", down)
|
||||
}
|
||||
|
||||
noop := moveAsideWidget(append([]services.AsideWidget(nil), base...), "up:"+services.AsideWidgetTagCloud)
|
||||
if noop[0].ID != services.AsideWidgetTagCloud {
|
||||
t.Fatalf("top up should noop: %+v", noop)
|
||||
}
|
||||
}
|
||||
322
routers/web/auth.go
Normal file
322
routers/web/auth.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type loginData struct {
|
||||
PageChrome
|
||||
Username string
|
||||
Redirect string
|
||||
}
|
||||
|
||||
type registerData struct {
|
||||
PageChrome
|
||||
Username string
|
||||
Nickname string
|
||||
Email string
|
||||
MailReady bool
|
||||
RequireEmailCode bool
|
||||
CaptchaID string
|
||||
CaptchaSVG template.HTML
|
||||
}
|
||||
|
||||
type registerForm struct {
|
||||
Username string
|
||||
Nickname string
|
||||
Email string
|
||||
}
|
||||
|
||||
// LoginGet 登录页
|
||||
func (d Deps) LoginGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
if c.Query("banned") == "1" {
|
||||
chrome.Error = "账号已被禁言"
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "auth/login", loginData{
|
||||
PageChrome: chrome,
|
||||
Redirect: c.Query("redirect"),
|
||||
})
|
||||
}
|
||||
|
||||
// LoginPost 登录提交
|
||||
func (d Deps) LoginPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderLogin(ctx, "无效请求,请重试", c.PostForm("username"), c.PostForm("redirect"))
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
|
||||
d.renderLogin(ctx, "操作过于频繁,请稍后再试", c.PostForm("username"), c.PostForm("redirect"))
|
||||
return
|
||||
}
|
||||
user := strings.TrimSpace(c.PostForm("username"))
|
||||
pass := c.PostForm("password")
|
||||
redir := strings.TrimSpace(c.PostForm("redirect"))
|
||||
if redir == "" || !strings.HasPrefix(redir, "/") || strings.HasPrefix(redir, "//") {
|
||||
redir = "/"
|
||||
}
|
||||
token, _, err := d.Auth.Login(user, pass, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
msg := "用户名或密码错误"
|
||||
if err == services.ErrUserBanned {
|
||||
msg = "账号已被禁言"
|
||||
}
|
||||
d.renderLogin(ctx, msg, user, redir)
|
||||
return
|
||||
}
|
||||
ctx.SetLoginCookie(token)
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
func (d Deps) renderLogin(ctx *webctx.Context, errMsg, username, redir string) {
|
||||
chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "auth/login", loginData{
|
||||
PageChrome: chrome,
|
||||
Username: username,
|
||||
Redirect: redir,
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutPost 退出
|
||||
func (d Deps) LogoutPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.CheckCSRF() {
|
||||
ctx.ClearLoginCookie()
|
||||
}
|
||||
ctx.Redirect("/")
|
||||
}
|
||||
|
||||
// RegisterGet 注册页
|
||||
func (d Deps) RegisterGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
d.renderRegister(ctx, "", registerForm{})
|
||||
}
|
||||
|
||||
// RegisterSendCode POST 发送注册邮箱验证码
|
||||
func (d Deps) RegisterSendCode(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
form := registerFormFrom(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderRegister(ctx, "无效请求,请重试", form)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
|
||||
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
|
||||
return
|
||||
}
|
||||
if d.EmailCode == nil || !d.Settings.MailReady() {
|
||||
d.renderRegister(ctx, "邮件服务未配置,无需验证码即可注册", form)
|
||||
return
|
||||
}
|
||||
if err := d.EmailCode.SendRegisterCode(form.Email); err != nil {
|
||||
d.renderRegister(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Flash = "验证码已发送,请查收邮箱"
|
||||
d.renderRegisterWithChrome(ctx, chrome, "", form)
|
||||
}
|
||||
|
||||
// RegisterPost 注册提交
|
||||
func (d Deps) RegisterPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
form := registerFormFrom(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderRegister(ctx, "无效请求,请重试", form)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
|
||||
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
|
||||
return
|
||||
}
|
||||
captchaID := strings.TrimSpace(c.PostForm("captcha_id"))
|
||||
captchaAns := strings.TrimSpace(c.PostForm("captcha"))
|
||||
if d.Captcha == nil || !d.Captcha.Verify(captchaID, captchaAns) {
|
||||
d.renderRegister(ctx, "验证码错误或已过期", form)
|
||||
return
|
||||
}
|
||||
mailReady := d.Settings.MailReady()
|
||||
if mailReady {
|
||||
code := strings.TrimSpace(c.PostForm("email_code"))
|
||||
if d.EmailCode == nil || !d.EmailCode.Verify(form.Email, code) {
|
||||
d.renderRegister(ctx, services.ErrEmailCodeInvalid.Error(), form)
|
||||
return
|
||||
}
|
||||
}
|
||||
pass := c.PostForm("password")
|
||||
pass2 := c.PostForm("password2")
|
||||
if pass != pass2 {
|
||||
d.renderRegister(ctx, "两次密码不一致", form)
|
||||
return
|
||||
}
|
||||
user, err := d.Auth.Register(form.Username, pass, form.Nickname, form.Email)
|
||||
if err != nil {
|
||||
d.renderRegister(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
ctx.SetFlash("注册成功,请登录")
|
||||
ctx.Redirect("/login")
|
||||
return
|
||||
}
|
||||
ctx.SetLoginCookie(sid)
|
||||
ctx.SetFlash("注册成功,欢迎加入")
|
||||
ctx.Redirect("/")
|
||||
}
|
||||
|
||||
func registerFormFrom(c *gin.Context) registerForm {
|
||||
return registerForm{
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Nickname: strings.TrimSpace(c.PostForm("nickname")),
|
||||
Email: strings.TrimSpace(c.PostForm("email")),
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) renderRegister(ctx *webctx.Context, errMsg string, form registerForm) {
|
||||
chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
d.renderRegisterWithChrome(ctx, chrome, errMsg, form)
|
||||
}
|
||||
|
||||
func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg string, form registerForm) {
|
||||
chrome.Error = errMsg
|
||||
mailReady := d.Settings.MailReady()
|
||||
data := registerData{
|
||||
PageChrome: chrome,
|
||||
Username: form.Username,
|
||||
Nickname: form.Nickname,
|
||||
Email: form.Email,
|
||||
MailReady: mailReady,
|
||||
RequireEmailCode: mailReady,
|
||||
}
|
||||
if d.Captcha != nil {
|
||||
if id, svg, err := d.Captcha.Generate(); err == nil {
|
||||
data.CaptchaID = id
|
||||
data.CaptchaSVG = template.HTML(svg)
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "auth/register", data)
|
||||
}
|
||||
|
||||
type forgotPasswordData struct {
|
||||
PageChrome
|
||||
Email string
|
||||
MailReady bool
|
||||
}
|
||||
|
||||
// ForgotPasswordGet 忘记密码页
|
||||
func (d Deps) ForgotPasswordGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
d.renderForgotPassword(ctx, "", "")
|
||||
}
|
||||
|
||||
// ForgotPasswordSendCode 发送重置验证码
|
||||
func (d Deps) ForgotPasswordSendCode(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(c.PostForm("email"))
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderForgotPassword(ctx, "无效请求,请重试", email)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
|
||||
d.renderForgotPassword(ctx, "操作过于频繁,请稍后再试", email)
|
||||
return
|
||||
}
|
||||
if d.EmailCode == nil || !d.Settings.MailReady() {
|
||||
d.renderForgotPassword(ctx, services.ErrMailNotConfigured.Error(), email)
|
||||
return
|
||||
}
|
||||
if err := d.EmailCode.SendResetCode(email); err != nil {
|
||||
d.renderForgotPassword(ctx, err.Error(), email)
|
||||
return
|
||||
}
|
||||
chrome := d.chrome(ctx, "忘记密码 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Flash = "若该邮箱已注册,验证码将发送到邮箱"
|
||||
d.renderForgotPasswordWithChrome(ctx, chrome, "", email)
|
||||
}
|
||||
|
||||
// ForgotPasswordPost 提交重置
|
||||
func (d Deps) ForgotPasswordPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(c.PostForm("email"))
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderForgotPassword(ctx, "无效请求,请重试", email)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
|
||||
d.renderForgotPassword(ctx, "操作过于频繁,请稍后再试", email)
|
||||
return
|
||||
}
|
||||
if !d.Settings.MailReady() || d.EmailCode == nil {
|
||||
d.renderForgotPassword(ctx, services.ErrMailNotConfigured.Error(), email)
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(c.PostForm("email_code"))
|
||||
if !d.EmailCode.VerifyPurpose(services.EmailCodePurposeReset, email, code) {
|
||||
d.renderForgotPassword(ctx, services.ErrEmailCodeInvalid.Error(), email)
|
||||
return
|
||||
}
|
||||
pass := c.PostForm("new_password")
|
||||
pass2 := c.PostForm("new_password2")
|
||||
if pass != pass2 {
|
||||
d.renderForgotPassword(ctx, "两次输入的新密码不一致", email)
|
||||
return
|
||||
}
|
||||
if err := d.User.ResetPasswordByEmail(email, pass); err != nil {
|
||||
d.renderForgotPassword(ctx, err.Error(), email)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("密码已重置,请使用新密码登录")
|
||||
ctx.Redirect("/login")
|
||||
}
|
||||
|
||||
func (d Deps) renderForgotPassword(ctx *webctx.Context, errMsg, email string) {
|
||||
chrome := d.chrome(ctx, "忘记密码 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
d.renderForgotPasswordWithChrome(ctx, chrome, errMsg, email)
|
||||
}
|
||||
|
||||
func (d Deps) renderForgotPasswordWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg, email string) {
|
||||
chrome.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "auth/forgot_password", forgotPasswordData{
|
||||
PageChrome: chrome,
|
||||
Email: email,
|
||||
MailReady: d.Settings.MailReady(),
|
||||
})
|
||||
}
|
||||
56
routers/web/boards.go
Normal file
56
routers/web/boards.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type boardsItemView struct {
|
||||
ID uint
|
||||
Name string
|
||||
Href string
|
||||
Description string
|
||||
Icon string
|
||||
ColorIndex int
|
||||
PostCount int
|
||||
}
|
||||
|
||||
type boardsPageData struct {
|
||||
PageChrome
|
||||
Items []boardsItemView
|
||||
}
|
||||
|
||||
// BoardsGet 公开板块索引
|
||||
func (d Deps) BoardsGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderBoards(ctx)
|
||||
}
|
||||
|
||||
func (d Deps) renderBoards(ctx *webctx.Context) {
|
||||
brand := d.Settings.SiteBranding()
|
||||
chrome := d.chrome(ctx, "板块 · "+brand.Name, "", "")
|
||||
list, _ := d.Board.ListWithStats()
|
||||
items := make([]boardsItemView, 0, len(list))
|
||||
pl := d.permalink()
|
||||
for _, b := range list {
|
||||
color := b.ColorIndex
|
||||
if color < 0 {
|
||||
color = int(b.ID % 8)
|
||||
}
|
||||
items = append(items, boardsItemView{
|
||||
ID: b.ID,
|
||||
Name: b.Name,
|
||||
Href: pl.BoardPath(b.ID),
|
||||
Description: b.Description,
|
||||
Icon: b.Icon,
|
||||
ColorIndex: color,
|
||||
PostCount: b.PostCount,
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "boards/list", boardsPageData{
|
||||
PageChrome: chrome,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
386
routers/web/chrome.go
Normal file
386
routers/web/chrome.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Deps 页面依赖
|
||||
type Deps struct {
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
AssetVersion string // 静态资源 ?v=;来自构建 ldflags
|
||||
Settings *services.ForumSettingsService
|
||||
Auth *services.AuthService
|
||||
User *services.UserService
|
||||
Board *services.BoardService
|
||||
Post *services.PostService
|
||||
Comment *services.CommentService
|
||||
Message *services.MessageService
|
||||
Filter *services.SensitiveFilter
|
||||
Limiter *services.RateLimiter
|
||||
EmailCode *services.EmailCodeService
|
||||
Store *services.UploadStore
|
||||
Points *services.PointsService
|
||||
Badge *services.BadgeService
|
||||
FriendLink *services.FriendLinkApplyService
|
||||
Mail *services.MailService
|
||||
SitePage *services.SitePageService
|
||||
Report *services.ReportService
|
||||
Captcha *services.CaptchaService
|
||||
Notify *services.NotifyService
|
||||
Backup *services.BackupService
|
||||
}
|
||||
|
||||
// SitePageLink 导航/页脚站点单页链接
|
||||
type SitePageLink struct {
|
||||
Title string
|
||||
Slug string
|
||||
}
|
||||
|
||||
// BoardView 侧栏
|
||||
type BoardView struct {
|
||||
ID uint
|
||||
Name string
|
||||
Href string
|
||||
PostCount int
|
||||
ColorSlot int // 0–7 板块色槽
|
||||
}
|
||||
|
||||
// RightAsideHotPost 右栏热门帖
|
||||
type RightAsideHotPost struct {
|
||||
ID uint
|
||||
Title string
|
||||
Href string
|
||||
}
|
||||
|
||||
// RightAsideTag 右栏标签
|
||||
type RightAsideTag struct {
|
||||
Name string
|
||||
Count int
|
||||
}
|
||||
|
||||
// RightAsideComment 右栏最新评论
|
||||
type RightAsideComment struct {
|
||||
PostID uint
|
||||
PostHref string
|
||||
Author string
|
||||
Excerpt string
|
||||
PostTitle string
|
||||
}
|
||||
|
||||
// RightAsideUser 右栏最新用户
|
||||
type RightAsideUser struct {
|
||||
ID uint
|
||||
Nickname string
|
||||
Href string
|
||||
}
|
||||
|
||||
// RightAsideFriendLink 右栏友链
|
||||
type RightAsideFriendLink struct {
|
||||
Name string
|
||||
URL string
|
||||
Logo string
|
||||
}
|
||||
|
||||
// RightAsideWidget 按 aside_widgets 顺序的一块(仅已开启)
|
||||
type RightAsideWidget struct {
|
||||
Kind string // tag_cloud | recent_comments | recent_users | friend_links
|
||||
Tags []RightAsideTag
|
||||
Comments []RightAsideComment
|
||||
Users []RightAsideUser
|
||||
FriendLinks []RightAsideFriendLink
|
||||
}
|
||||
|
||||
// RightAsideData 右栏:签到条 + 固定热门 + 可配置 widgets
|
||||
type RightAsideData struct {
|
||||
ShowCheckIn bool
|
||||
CheckedIn bool
|
||||
CheckInStreak int
|
||||
CheckInPoints int // 已签实得或预计可得
|
||||
LotteryDrawn bool
|
||||
LotteryPoints int
|
||||
LotteryCost int
|
||||
HotPosts []RightAsideHotPost
|
||||
Widgets []RightAsideWidget
|
||||
}
|
||||
|
||||
// PageChrome 布局公共字段
|
||||
type PageChrome struct {
|
||||
Title string
|
||||
Description string
|
||||
SiteName string
|
||||
Slogan string
|
||||
LogoMark string
|
||||
LogoURL string
|
||||
FaviconURL string
|
||||
OGImageURL string
|
||||
PermalinkSuffix string // 如 ".html";未启用为空
|
||||
OpenPostsInNewTab bool
|
||||
LoggedIn bool
|
||||
IsAdmin bool
|
||||
ViewerName string
|
||||
ViewerAvatar string
|
||||
ViewerMark string
|
||||
Boards []BoardView
|
||||
ActiveBoard uint
|
||||
Path string // 当前请求路径,供侧栏高亮
|
||||
Inner string // 保留字段;入口模板已固定组合,不再动态 template
|
||||
CSRF string
|
||||
Flash string
|
||||
Error string
|
||||
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
|
||||
ViewerPoints int // 登录用户当前积分;未登录为 0
|
||||
ShowFriendLinksNav bool
|
||||
ShowFriendLinksFooter bool
|
||||
NavPages []SitePageLink
|
||||
FooterPages []SitePageLink
|
||||
RightAside RightAsideData
|
||||
AssetVersion string
|
||||
}
|
||||
|
||||
func (d Deps) ctx(c *gin.Context) *webctx.Context {
|
||||
return webctx.New(c, d.JWTSecret)
|
||||
}
|
||||
|
||||
func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome {
|
||||
brand := d.Settings.SiteBranding()
|
||||
if title == "" {
|
||||
title = brand.DocumentTitle()
|
||||
}
|
||||
if desc == "" {
|
||||
desc = brand.MetaDescription()
|
||||
}
|
||||
name := "我的"
|
||||
if ctx.IsSigned() {
|
||||
name = strings.TrimSpace(ctx.Doer.Nickname)
|
||||
if name == "" {
|
||||
name = ctx.Doer.Username
|
||||
}
|
||||
}
|
||||
boards, _ := d.Board.ListWithStats()
|
||||
bv := make([]BoardView, 0, len(boards))
|
||||
pl := d.permalink()
|
||||
for _, b := range boards {
|
||||
slot := b.ColorIndex
|
||||
if slot < 0 {
|
||||
slot = int(b.ID % 8)
|
||||
}
|
||||
bv = append(bv, BoardView{
|
||||
ID: b.ID, Name: b.Name, Href: pl.BoardPath(b.ID),
|
||||
PostCount: b.PostCount, ColorSlot: slot % 8,
|
||||
})
|
||||
}
|
||||
var unread int64
|
||||
if ctx.IsSigned() && d.Message != nil {
|
||||
unread, _ = d.Message.UnreadCount(ctx.UserID())
|
||||
}
|
||||
viewerPoints := 0
|
||||
viewerAvatar := ""
|
||||
viewerMark := "我"
|
||||
if ctx.IsSigned() && ctx.Doer != nil {
|
||||
viewerPoints = ctx.Doer.Points
|
||||
viewerAvatar = strings.TrimSpace(ctx.Doer.Avatar)
|
||||
viewerMark = firstRuneOr(name, "我")
|
||||
}
|
||||
path := ""
|
||||
if ctx.C != nil && ctx.C.Request != nil && ctx.C.Request.URL != nil {
|
||||
path = ctx.C.Request.URL.Path
|
||||
}
|
||||
navPages, footerPages := d.sitePageLinks()
|
||||
assetVer := strings.TrimSpace(d.AssetVersion)
|
||||
if assetVer == "" {
|
||||
assetVer = "dev"
|
||||
}
|
||||
return PageChrome{
|
||||
Title: title,
|
||||
Description: desc,
|
||||
SiteName: brand.Name,
|
||||
Slogan: brand.Slogan,
|
||||
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
|
||||
LogoURL: strings.TrimSpace(brand.Logo),
|
||||
FaviconURL: strings.TrimSpace(brand.Favicon),
|
||||
OGImageURL: strings.TrimSpace(brand.DefaultShareImage()),
|
||||
PermalinkSuffix: d.Settings.Permalink().Suffix(),
|
||||
OpenPostsInNewTab: d.Settings.OpenPostsInNewTab(),
|
||||
LoggedIn: ctx.IsSigned(),
|
||||
IsAdmin: ctx.IsAdmin(),
|
||||
ViewerName: name,
|
||||
ViewerAvatar: viewerAvatar,
|
||||
ViewerMark: viewerMark,
|
||||
Boards: bv,
|
||||
Path: path,
|
||||
Inner: inner,
|
||||
CSRF: ctx.EnsureCSRF(),
|
||||
Flash: ctx.TakeFlash(),
|
||||
UnreadCount: unread,
|
||||
ViewerPoints: viewerPoints,
|
||||
ShowFriendLinksNav: d.Settings.NavShowFriendLinks(),
|
||||
ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(),
|
||||
NavPages: navPages,
|
||||
FooterPages: footerPages,
|
||||
RightAside: d.loadRightAside(ctx, brand),
|
||||
AssetVersion: assetVer,
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) sitePageLinks() (nav, footer []SitePageLink) {
|
||||
nav, footer = []SitePageLink{}, []SitePageLink{}
|
||||
if d.SitePage == nil {
|
||||
return
|
||||
}
|
||||
list, err := d.SitePage.ListPublished()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range list {
|
||||
link := SitePageLink{Title: p.Title, Slug: p.Slug}
|
||||
if p.ShowInNav {
|
||||
nav = append(nav, link)
|
||||
}
|
||||
if p.ShowInFooter {
|
||||
footer = append(footer, link)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
rightAsideHotLimit = 5
|
||||
rightAsideTagLimit = 24
|
||||
rightAsideCommentLimit = 8
|
||||
rightAsideUserLimit = 8
|
||||
)
|
||||
|
||||
func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) RightAsideData {
|
||||
out := RightAsideData{
|
||||
HotPosts: []RightAsideHotPost{},
|
||||
Widgets: []RightAsideWidget{},
|
||||
}
|
||||
if ctx.IsSigned() && d.Points != nil {
|
||||
out.ShowCheckIn = true
|
||||
if st, err := d.Points.GetCheckInStatus(ctx.UserID()); err == nil {
|
||||
out.CheckedIn = st.CheckedIn
|
||||
out.CheckInStreak = st.Streak
|
||||
out.CheckInPoints = st.TodayPoints
|
||||
}
|
||||
if st, err := d.Points.GetLotteryStatus(ctx.UserID()); err == nil {
|
||||
out.LotteryDrawn = st.Drawn
|
||||
out.LotteryPoints = st.Points
|
||||
out.LotteryCost = st.Cost
|
||||
}
|
||||
}
|
||||
if d.Post != nil {
|
||||
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
|
||||
pl := d.permalink()
|
||||
for _, it := range items {
|
||||
out.HotPosts = append(out.HotPosts, RightAsideHotPost{ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, w := range d.Settings.AsideWidgets() {
|
||||
if !w.Enabled {
|
||||
continue
|
||||
}
|
||||
block := RightAsideWidget{Kind: w.ID}
|
||||
switch w.ID {
|
||||
case services.AsideWidgetTagCloud:
|
||||
if d.Post != nil {
|
||||
if tags, err := d.Post.PopularTags(rightAsideTagLimit); err == nil {
|
||||
for _, t := range tags {
|
||||
block.Tags = append(block.Tags, RightAsideTag{Name: t.Name, Count: t.Count})
|
||||
}
|
||||
}
|
||||
}
|
||||
case services.AsideWidgetRecentComments:
|
||||
if d.Comment != nil {
|
||||
if list, err := d.Comment.ListRecentPublic(rightAsideCommentLimit); err == nil {
|
||||
pl := d.permalink()
|
||||
for _, c := range list {
|
||||
block.Comments = append(block.Comments, RightAsideComment{
|
||||
PostID: c.PostID, PostHref: pl.PostPath(c.PostID), Author: c.Author, Excerpt: c.Excerpt, PostTitle: c.PostTitle,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
case services.AsideWidgetRecentUsers:
|
||||
if d.User != nil {
|
||||
if list, err := d.User.ListRecentRegistered(rightAsideUserLimit); err == nil {
|
||||
pl := d.permalink()
|
||||
for _, u := range list {
|
||||
block.Users = append(block.Users, RightAsideUser{ID: u.ID, Nickname: u.Nickname, Href: pl.UserPath(u.ID)})
|
||||
}
|
||||
}
|
||||
}
|
||||
case services.AsideWidgetFriendLinks:
|
||||
for _, l := range brand.FriendLinks {
|
||||
block.FriendLinks = append(block.FriendLinks, RightAsideFriendLink{Name: l.Name, URL: l.URL, Logo: l.Logo})
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
out.Widgets = append(out.Widgets, block)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstRuneOr(s, fallback string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
for _, r := range s {
|
||||
return string(r)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func stripIDParam(raw, permalinkExt string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if permalinkExt != "" {
|
||||
raw = strings.TrimSuffix(raw, "."+permalinkExt)
|
||||
}
|
||||
raw = strings.TrimSuffix(raw, ".html")
|
||||
raw = strings.TrimSuffix(raw, ".htm")
|
||||
return raw
|
||||
}
|
||||
|
||||
func (d Deps) permalink() services.PermalinkConfig {
|
||||
if d.Settings == nil {
|
||||
return services.PermalinkConfig{}
|
||||
}
|
||||
return d.Settings.Permalink()
|
||||
}
|
||||
|
||||
func (d Deps) postHref(id uint) string {
|
||||
return d.permalink().PostPath(id)
|
||||
}
|
||||
|
||||
func (d Deps) boardHref(id uint) string {
|
||||
return d.permalink().BoardPath(id)
|
||||
}
|
||||
|
||||
func (d Deps) userHref(id uint) string {
|
||||
return d.permalink().UserPath(id)
|
||||
}
|
||||
|
||||
// redirectIfNotCanonical 伪静态开启时把无后缀/错误后缀 301 到规范路径
|
||||
func (d Deps) redirectIfNotCanonical(c *gin.Context, match services.PermalinkMatch) bool {
|
||||
if !match.OK || !d.permalink().Enabled {
|
||||
return false
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if !match.NeedsCanonicalRedirect(path) {
|
||||
return false
|
||||
}
|
||||
loc := match.Canonical
|
||||
if q := c.Request.URL.RawQuery; q != "" {
|
||||
loc = loc + "?" + q
|
||||
}
|
||||
c.Redirect(http.StatusMovedPermanently, loc)
|
||||
return true
|
||||
}
|
||||
337
routers/web/compose.go
Normal file
337
routers/web/compose.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type composeData struct {
|
||||
PageChrome
|
||||
IsEdit bool
|
||||
PostID uint
|
||||
FormAction string
|
||||
BoardID uint
|
||||
Title string
|
||||
Tags string
|
||||
Content string
|
||||
PostType string
|
||||
PollMulti bool
|
||||
PollMaxChoices int
|
||||
PollEndsAt string
|
||||
PollOptions string
|
||||
BountyPoints int
|
||||
LotteryWinners int
|
||||
Boards []BoardView
|
||||
TitleMax int
|
||||
TagsMax int
|
||||
ContentMax int
|
||||
}
|
||||
|
||||
// ComposeGet 发帖页
|
||||
func (d Deps) ComposeGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if err := d.ensureCanWrite(ctx); err != "" {
|
||||
ctx.SetFlash(err)
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
|
||||
d.renderCompose(ctx, "", composeForm{
|
||||
BoardID: uint(boardID),
|
||||
PostType: models.PostTypeNormal,
|
||||
PollMaxChoices: 1,
|
||||
BountyPoints: 1,
|
||||
LotteryWinners: 1,
|
||||
}, false, 0)
|
||||
}
|
||||
|
||||
// ComposePost 发帖提交
|
||||
func (d Deps) ComposePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), false, 0)
|
||||
return
|
||||
}
|
||||
if msg := d.ensureCanWrite(ctx); msg != "" {
|
||||
ctx.SetFlash(msg)
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
|
||||
d.renderCompose(ctx, "发帖过于频繁,请稍后再试", composeFormFrom(c), false, 0)
|
||||
return
|
||||
}
|
||||
form := composeFormFrom(c)
|
||||
htmlBody := services.ComposeBodyToHTML(form.Content)
|
||||
postType := normalizeComposePostType(form.PostType)
|
||||
post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, postType, ctx.SkipsModeration())
|
||||
if err != nil {
|
||||
d.renderCompose(ctx, err.Error(), form, false, 0)
|
||||
return
|
||||
}
|
||||
if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty || post.PostType == models.PostTypeLottery {
|
||||
var extras services.PostCreateExtras
|
||||
switch post.PostType {
|
||||
case models.PostTypePoll:
|
||||
pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions)
|
||||
if err != nil {
|
||||
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
|
||||
d.renderCompose(ctx, err.Error(), form, false, 0)
|
||||
return
|
||||
}
|
||||
extras = services.ParsePostExtrasFromForm(pollJSON, "", "")
|
||||
case models.PostTypeBounty:
|
||||
extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "")
|
||||
case models.PostTypeLottery:
|
||||
extras = services.ParsePostExtrasFromForm("", "", strconv.Itoa(form.LotteryWinners))
|
||||
}
|
||||
if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil {
|
||||
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
|
||||
d.renderCompose(ctx, err.Error(), form, false, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
if post.Status == models.ContentStatusPending {
|
||||
ctx.SetFlash("帖子已提交,等待审核")
|
||||
if d.Notify != nil {
|
||||
d.Notify.AsyncNotifyPendingPost(post)
|
||||
}
|
||||
} else {
|
||||
ctx.SetFlash("发帖成功")
|
||||
}
|
||||
ctx.Redirect(d.postHref(post.ID))
|
||||
}
|
||||
|
||||
// PostEditGet 编辑帖
|
||||
func (d Deps) PostEditGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
|
||||
if errMsg != "" {
|
||||
ctx.SetFlash(errMsg)
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
d.renderCompose(ctx, "", composeForm{
|
||||
BoardID: post.BoardID,
|
||||
Title: post.Title,
|
||||
Tags: post.Tags,
|
||||
Content: services.HTMLToComposePlain(post.Content),
|
||||
PostType: post.PostType,
|
||||
}, true, post.ID)
|
||||
}
|
||||
|
||||
// PostEditPost 编辑提交
|
||||
func (d Deps) PostEditPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
|
||||
if errMsg != "" {
|
||||
ctx.SetFlash(errMsg)
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), true, post.ID)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
|
||||
d.renderCompose(ctx, "操作过于频繁,请稍后再试", composeFormFrom(c), true, post.ID)
|
||||
return
|
||||
}
|
||||
form := composeFormFrom(c)
|
||||
form.PostType = post.PostType // 编辑不可改类型
|
||||
htmlBody := services.ComposeBodyToHTML(form.Content)
|
||||
if err := d.Post.Update(ctx.UserID(), post.ID, ctx.IsAdmin(), ctx.SkipsModeration(), form.Title, htmlBody, form.Tags, post.PostType, form.BoardID); err != nil {
|
||||
d.renderCompose(ctx, err.Error(), form, true, post.ID)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已保存")
|
||||
ctx.Redirect(d.postHref(post.ID))
|
||||
}
|
||||
|
||||
// ComposeUpload 帖图上传(JSON,供 compose 页 fetch)
|
||||
func (d Deps) ComposeUpload(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.IsSigned() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
|
||||
return
|
||||
}
|
||||
if ctx.Doer != nil && ctx.Doer.Banned {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
|
||||
return
|
||||
}
|
||||
if d.Store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"})
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
|
||||
return
|
||||
}
|
||||
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategoryPosts, fmt.Sprintf("%d", ctx.UserID()))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// ComposePreview 正文预览(与发帖同一套 ComposeBodyToHTML + 消毒)
|
||||
func (d Deps) ComposePreview(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.IsSigned() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
|
||||
return
|
||||
}
|
||||
content := c.PostForm("content")
|
||||
if content == "" {
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
content = body.Content
|
||||
}
|
||||
htmlBody := services.SanitizePostHTML(services.ComposeBodyToHTML(content))
|
||||
c.JSON(http.StatusOK, gin.H{"html": htmlBody})
|
||||
}
|
||||
|
||||
type composeForm struct {
|
||||
BoardID uint
|
||||
Title string
|
||||
Tags string
|
||||
Content string
|
||||
PostType string
|
||||
PollMulti bool
|
||||
PollMaxChoices int
|
||||
PollEndsAt string
|
||||
PollOptions string
|
||||
BountyPoints int
|
||||
LotteryWinners int
|
||||
}
|
||||
|
||||
func normalizeComposePostType(t string) string {
|
||||
switch t {
|
||||
case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty, models.PostTypeLottery:
|
||||
return t
|
||||
default:
|
||||
return models.PostTypeNormal
|
||||
}
|
||||
}
|
||||
|
||||
func composeFormFrom(c *gin.Context) composeForm {
|
||||
bid, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
||||
maxChoices, _ := strconv.Atoi(c.PostForm("poll_max_choices"))
|
||||
if maxChoices < 1 {
|
||||
maxChoices = 1
|
||||
}
|
||||
bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points"))
|
||||
lotteryWinners, _ := strconv.Atoi(c.PostForm("lottery_winner_count"))
|
||||
if lotteryWinners < 1 {
|
||||
lotteryWinners = 1
|
||||
}
|
||||
return composeForm{
|
||||
BoardID: uint(bid),
|
||||
Title: strings.TrimSpace(c.PostForm("title")),
|
||||
Tags: strings.TrimSpace(c.PostForm("tags")),
|
||||
Content: c.PostForm("content"),
|
||||
PostType: normalizeComposePostType(strings.TrimSpace(c.PostForm("post_type"))),
|
||||
PollMulti: c.PostForm("poll_multi") == "1" || c.PostForm("poll_multi") == "on",
|
||||
PollMaxChoices: maxChoices,
|
||||
PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")),
|
||||
PollOptions: c.PostForm("poll_options"),
|
||||
BountyPoints: bountyPoints,
|
||||
LotteryWinners: lotteryWinners,
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm, isEdit bool, postID uint) {
|
||||
title := "发帖"
|
||||
action := "/compose"
|
||||
if isEdit {
|
||||
title = "编辑帖子"
|
||||
action = fmt.Sprintf("/post/%d/edit", postID)
|
||||
}
|
||||
if form.PostType == "" {
|
||||
form.PostType = models.PostTypeNormal
|
||||
}
|
||||
if form.PollMaxChoices < 1 {
|
||||
form.PollMaxChoices = 1
|
||||
}
|
||||
if form.BountyPoints < 1 {
|
||||
form.BountyPoints = 1
|
||||
}
|
||||
if form.LotteryWinners < 1 {
|
||||
form.LotteryWinners = 1
|
||||
}
|
||||
chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Error = errMsg
|
||||
chrome.ActiveBoard = form.BoardID
|
||||
ctx.HTML(http.StatusOK, "compose", composeData{
|
||||
PageChrome: chrome,
|
||||
IsEdit: isEdit,
|
||||
PostID: postID,
|
||||
FormAction: action,
|
||||
BoardID: form.BoardID,
|
||||
Title: form.Title,
|
||||
Tags: form.Tags,
|
||||
Content: form.Content,
|
||||
PostType: form.PostType,
|
||||
PollMulti: form.PollMulti,
|
||||
PollMaxChoices: form.PollMaxChoices,
|
||||
PollEndsAt: form.PollEndsAt,
|
||||
PollOptions: form.PollOptions,
|
||||
BountyPoints: form.BountyPoints,
|
||||
LotteryWinners: form.LotteryWinners,
|
||||
Boards: chrome.Boards,
|
||||
TitleMax: d.Settings.PostTitleMax(),
|
||||
TagsMax: d.Settings.PostTagsMax(),
|
||||
ContentMax: d.Settings.PostContentMax(),
|
||||
})
|
||||
}
|
||||
|
||||
func (d Deps) ensureCanWrite(ctx *webctx.Context) string {
|
||||
if !ctx.IsSigned() {
|
||||
return "请先登录"
|
||||
}
|
||||
if ctx.Doer != nil && ctx.Doer.Banned {
|
||||
return "账号已被禁言,无法发帖"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d Deps) loadEditablePost(ctx *webctx.Context, idParam string) (*models.Post, string) {
|
||||
if msg := d.ensureCanWrite(ctx); msg != "" {
|
||||
return nil, msg
|
||||
}
|
||||
idStr := stripIDParam(idParam, d.Settings.Permalink().Ext)
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, "帖子不存在"
|
||||
}
|
||||
post, err := d.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
return nil, "帖子不存在"
|
||||
}
|
||||
if !ctx.IsAdmin() && post.UserID != ctx.UserID() {
|
||||
return nil, "无权编辑此帖"
|
||||
}
|
||||
if reason := d.Post.EditBlockReason(post, ctx.IsAdmin()); reason != "" {
|
||||
return nil, reason
|
||||
}
|
||||
return post, ""
|
||||
}
|
||||
71
routers/web/favorites.go
Normal file
71
routers/web/favorites.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type favItem struct {
|
||||
PostID uint
|
||||
Title string
|
||||
Href string
|
||||
AuthorName string
|
||||
BoardName string
|
||||
CreatedLabel string
|
||||
}
|
||||
|
||||
type favoritesData struct {
|
||||
PageChrome
|
||||
Items []favItem
|
||||
Page int
|
||||
PrevPage int
|
||||
NextPage int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
Total int64
|
||||
}
|
||||
|
||||
// FavoritesGet 我的收藏
|
||||
func (d Deps) FavoritesGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := d.Settings.PageSizeDefault()
|
||||
favs, total, err := d.Post.ListFavorites(ctx.UserID(), page, size)
|
||||
if err != nil {
|
||||
chrome := d.chrome(ctx, "收藏 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Error = err.Error()
|
||||
ctx.HTML(http.StatusOK, "favorites/list", favoritesData{PageChrome: chrome})
|
||||
return
|
||||
}
|
||||
items := make([]favItem, 0, len(favs))
|
||||
pl := d.permalink()
|
||||
for _, f := range favs {
|
||||
title := f.Post.Title
|
||||
author := strings.TrimSpace(f.Post.User.Nickname)
|
||||
if author == "" {
|
||||
author = f.Post.User.Username
|
||||
}
|
||||
board := ""
|
||||
if f.Post.Board.ID > 0 {
|
||||
board = f.Post.Board.Name
|
||||
}
|
||||
items = append(items, favItem{
|
||||
PostID: f.PostID, Title: title, Href: pl.PostPath(f.PostID), AuthorName: author, BoardName: board,
|
||||
CreatedLabel: formatTime(f.CreatedAt),
|
||||
})
|
||||
}
|
||||
chrome := d.chrome(ctx, "收藏 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
hasMore := int64(page*size) < total
|
||||
ctx.HTML(http.StatusOK, "favorites/list", favoritesData{
|
||||
PageChrome: chrome,
|
||||
Items: items,
|
||||
Page: page, PrevPage: page - 1, NextPage: page + 1,
|
||||
HasPrev: page > 1, HasMore: hasMore, Total: total,
|
||||
})
|
||||
}
|
||||
466
routers/web/home.go
Normal file
466
routers/web/home.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Register 注册已安装后的 web 路由
|
||||
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)
|
||||
g.GET("/post/:id", deps.PostView)
|
||||
g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet)
|
||||
g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost)
|
||||
g.GET("/post/:id/revisions", authMW.RequireAuth(), deps.PostRevisionsGet)
|
||||
g.GET("/post/:id/revisions/:rid", authMW.RequireAuth(), deps.PostRevisionDetailGet)
|
||||
g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
|
||||
g.GET("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditGet)
|
||||
g.POST("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditPost)
|
||||
g.POST("/post/:id/comments/:cid/delete", authMW.RequireAuth(), deps.CommentDeletePost)
|
||||
g.POST("/post/:id/comments/:cid/like", authMW.RequireAuth(), deps.PostCommentLike)
|
||||
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
|
||||
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
|
||||
g.POST("/post/:id/poll/vote", authMW.RequireAuth(), deps.PostPollVote)
|
||||
g.POST("/post/:id/poll/close", authMW.RequireAuth(), deps.PostPollClose)
|
||||
g.POST("/post/:id/question/resolve", authMW.RequireAuth(), deps.PostQuestionResolvePost)
|
||||
g.POST("/post/:id/bounty/award", authMW.RequireAuth(), deps.PostBountyAwardPost)
|
||||
g.POST("/post/:id/bounty/refund", authMW.RequireAuth(), deps.PostBountyRefundPost)
|
||||
g.POST("/post/:id/lottery/draw", authMW.RequireAuth(), deps.PostLotteryDrawPost)
|
||||
g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock)
|
||||
g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost)
|
||||
g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost)
|
||||
g.POST("/post/:id/admin/pin", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostPinPost)
|
||||
g.POST("/post/:id/admin/board-pin", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostBoardPinPost)
|
||||
g.POST("/post/:id/admin/feature", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostFeaturePost)
|
||||
g.POST("/post/:id/admin/edit-lock", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostEditLockPost)
|
||||
g.POST("/post/:id/admin/comments-lock", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostCommentsLockPost)
|
||||
g.POST("/post/:id/admin/delete", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostDeletePost)
|
||||
g.GET("/login", deps.LoginGet)
|
||||
g.POST("/login", deps.LoginPost)
|
||||
g.POST("/logout", deps.LogoutPost)
|
||||
g.GET("/register", deps.RegisterGet)
|
||||
g.POST("/register", deps.RegisterPost)
|
||||
g.POST("/register/send-code", deps.RegisterSendCode)
|
||||
g.GET("/forgot-password", deps.ForgotPasswordGet)
|
||||
g.POST("/forgot-password", deps.ForgotPasswordPost)
|
||||
g.POST("/forgot-password/send-code", deps.ForgotPasswordSendCode)
|
||||
g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet)
|
||||
g.POST("/compose", authMW.RequireAuth(), deps.ComposePost)
|
||||
g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload)
|
||||
g.POST("/compose/preview", authMW.RequireAuth(), deps.ComposePreview)
|
||||
g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") })
|
||||
|
||||
admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin())
|
||||
{
|
||||
admin.GET("", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
|
||||
admin.GET("/dashboard", deps.AdminDashboard)
|
||||
admin.GET("/boards", deps.AdminBoardsGet)
|
||||
admin.POST("/boards", deps.AdminBoardCreate)
|
||||
admin.POST("/boards/:id", deps.AdminBoardUpdate)
|
||||
admin.POST("/boards/:id/delete", deps.AdminBoardDelete)
|
||||
admin.GET("/moderation", deps.AdminModerationGet)
|
||||
admin.POST("/posts/:id/approve", deps.AdminPostApprove)
|
||||
admin.POST("/posts/:id/reject", deps.AdminPostReject)
|
||||
admin.POST("/comments/:id/approve", deps.AdminCommentApprove)
|
||||
admin.POST("/comments/:id/reject", deps.AdminCommentReject)
|
||||
admin.GET("/settings", deps.AdminSettingsGet)
|
||||
admin.POST("/settings/brand", deps.AdminSettingsBrandPost)
|
||||
admin.POST("/settings/brand/upload", deps.AdminSettingsBrandUploadPost)
|
||||
admin.POST("/settings/brand/clear", deps.AdminSettingsBrandClearPost)
|
||||
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
|
||||
admin.POST("/settings/content-limits", deps.AdminSettingsContentLimitsPost)
|
||||
admin.POST("/settings/permalink", deps.AdminSettingsPermalinkPost)
|
||||
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
|
||||
admin.POST("/settings/mail", deps.AdminSettingsMailPost)
|
||||
admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost)
|
||||
admin.POST("/settings/aside-widgets", deps.AdminSettingsAsideWidgetsPost)
|
||||
admin.POST("/settings/backup", deps.AdminSettingsBackupPost)
|
||||
admin.GET("/settings/backup/download/:name", deps.AdminSettingsBackupDownload)
|
||||
admin.GET("/friend-links", deps.AdminFriendLinksGet)
|
||||
admin.POST("/friend-links/settings", deps.AdminFriendLinksSettingsPost)
|
||||
admin.POST("/friend-links/brand", deps.AdminFriendLinksBrandAddPost)
|
||||
admin.POST("/friend-links/brand/delete", deps.AdminFriendLinksBrandDeletePost)
|
||||
admin.POST("/friend-links/applies/:id/approve", deps.AdminFriendLinkApprovePost)
|
||||
admin.POST("/friend-links/applies/:id/reject", deps.AdminFriendLinkRejectPost)
|
||||
admin.POST("/friend-links/applies/:id/recheck", deps.AdminFriendLinkRecheckPost)
|
||||
admin.GET("/pages", deps.AdminPagesGet)
|
||||
admin.POST("/pages", deps.AdminPageCreate)
|
||||
admin.GET("/pages/:id/edit", deps.AdminPageEditGet)
|
||||
admin.POST("/pages/:id", deps.AdminPageUpdate)
|
||||
admin.POST("/pages/:id/delete", deps.AdminPageDelete)
|
||||
admin.POST("/pages/:id/publish", deps.AdminPagePublishPost)
|
||||
admin.GET("/reports", deps.AdminReportsGet)
|
||||
admin.POST("/reports/:id/handle", deps.AdminReportHandlePost)
|
||||
admin.GET("/users", deps.AdminUsersGet)
|
||||
admin.POST("/users/:id/ban", deps.AdminUserBanPost)
|
||||
admin.POST("/users/:id/verify", deps.AdminUserVerifyPost)
|
||||
admin.POST("/users/:id/points", deps.AdminUserPointsPost)
|
||||
admin.POST("/users/:id/level", deps.AdminUserLevelPost)
|
||||
admin.GET("/badges", deps.AdminBadgesGet)
|
||||
admin.POST("/badges", deps.AdminBadgeCreate)
|
||||
admin.POST("/badges/award", deps.AdminBadgeAwardPost)
|
||||
admin.POST("/badges/revoke", deps.AdminBadgeRevokePost)
|
||||
admin.GET("/badges/:id/edit", deps.AdminBadgeEditGet)
|
||||
admin.POST("/badges/:id", deps.AdminBadgeUpdate)
|
||||
admin.POST("/badges/:id/delete", deps.AdminBadgeDelete)
|
||||
admin.GET("/media", deps.AdminMediaGet)
|
||||
admin.POST("/media/delete", deps.AdminMediaBatchDeletePost)
|
||||
admin.POST("/media/sync", deps.AdminMediaSyncPost)
|
||||
admin.POST("/media/:id/delete", deps.AdminMediaDeletePost)
|
||||
admin.GET("/trash", deps.AdminTrashGet)
|
||||
admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost)
|
||||
admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost)
|
||||
}
|
||||
|
||||
g.GET("/user/:id", deps.UserPublic)
|
||||
g.GET("/profile", authMW.RequireAuth(), deps.ProfileGet)
|
||||
g.POST("/profile/nickname", authMW.RequireAuth(), deps.ProfileNicknamePost)
|
||||
g.POST("/profile/signature", authMW.RequireAuth(), deps.ProfileSignaturePost)
|
||||
g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost)
|
||||
g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost)
|
||||
g.POST("/profile/checkin", authMW.RequireAuth(), deps.ProfileCheckInPost)
|
||||
g.POST("/profile/lottery", authMW.RequireAuth(), deps.ProfileLotteryPost)
|
||||
g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet)
|
||||
g.GET("/messages", authMW.RequireAuth(), deps.MessagesList)
|
||||
g.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread)
|
||||
g.POST("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesSend)
|
||||
g.POST("/messages/read-all", authMW.RequireAuth(), deps.MessagesReadAll)
|
||||
g.GET("/links", deps.LinksGet)
|
||||
g.POST("/links/apply", authMW.RequireAuth(), deps.LinksApplyPost)
|
||||
g.POST("/links/apply/:id/cancel", authMW.RequireAuth(), deps.LinksApplyCancelPost)
|
||||
g.POST("/links/logo", authMW.RequireAuth(), deps.LinksLogoUpload)
|
||||
g.GET("/projects", deps.PendingPage)
|
||||
g.GET("/boards", deps.BoardsGet)
|
||||
g.GET("/page/:slug", deps.SitePageGet)
|
||||
}
|
||||
|
||||
// HomePageData Feed
|
||||
type HomePageData struct {
|
||||
PageChrome
|
||||
BoardName string
|
||||
Sort string
|
||||
Keyword string
|
||||
Tag string
|
||||
Author string
|
||||
TitleOnly bool
|
||||
HasSearch bool
|
||||
SearchError string
|
||||
FeedListStyle string // title|excerpt|thumbnail
|
||||
ShowExcerpt bool
|
||||
Posts []PostListItem
|
||||
Page int
|
||||
PrevPage int
|
||||
NextPage int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
// PostListItem 列表项(对齐 main SPA post-row--v2)
|
||||
type PostListItem struct {
|
||||
ID uint
|
||||
Title string
|
||||
Href string
|
||||
AuthorID uint
|
||||
AuthorName string
|
||||
AuthorHref string
|
||||
AvatarURL string
|
||||
AuthorMark string
|
||||
BoardID uint
|
||||
BoardName string
|
||||
BoardHref string
|
||||
BoardColorSlot int
|
||||
ShowBoardBadge bool
|
||||
Excerpt string
|
||||
ThumbURL string
|
||||
Pinned bool
|
||||
BoardPinned bool
|
||||
Featured bool
|
||||
CommentCount int
|
||||
LikeCount int
|
||||
ViewCount int
|
||||
CreatedLabel string
|
||||
LastReplyLabel string
|
||||
LastReplyName string
|
||||
LastReplyHref string
|
||||
ShowLastReply bool
|
||||
}
|
||||
|
||||
// FormAction 搜索表单提交路径(当前 Feed)
|
||||
func (d HomePageData) FormAction() string {
|
||||
if d.ActiveBoard > 0 {
|
||||
return fmt.Sprintf("/board/%d%s", d.ActiveBoard, d.PermalinkSuffix)
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// SortHref 排序链接,保留搜索参数
|
||||
func (d HomePageData) SortHref(sort string) string {
|
||||
return buildFeedURL(d.ActiveBoard, d.PermalinkSuffix, sort, 0, d.Keyword, d.Tag, d.Author, d.TitleOnly)
|
||||
}
|
||||
|
||||
// PageHref 分页链接,保留搜索与排序
|
||||
func (d HomePageData) PageHref(page int) string {
|
||||
return buildFeedURL(d.ActiveBoard, d.PermalinkSuffix, d.Sort, page, d.Keyword, d.Tag, d.Author, d.TitleOnly)
|
||||
}
|
||||
|
||||
func buildFeedURL(boardID uint, permalinkSuffix, sort string, page int, keyword, tag, author string, titleOnly bool) string {
|
||||
q := url.Values{}
|
||||
if sort != "" && sort != "latest" {
|
||||
q.Set("sort", sort)
|
||||
}
|
||||
if page > 1 {
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
q.Set("keyword", kw)
|
||||
}
|
||||
if t := strings.TrimSpace(tag); t != "" {
|
||||
q.Set("tag", t)
|
||||
}
|
||||
if a := strings.TrimSpace(author); a != "" {
|
||||
q.Set("author", a)
|
||||
}
|
||||
if titleOnly {
|
||||
q.Set("title_only", "1")
|
||||
}
|
||||
path := "/"
|
||||
if boardID > 0 {
|
||||
path = fmt.Sprintf("/board/%d%s", boardID, permalinkSuffix)
|
||||
}
|
||||
if enc := q.Encode(); enc != "" {
|
||||
return path + "?" + enc
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// Home 首页 / 板块
|
||||
func (d Deps) Home(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
sort := normalizeSort(c.DefaultQuery("sort", "latest"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := d.Settings.PageSizeDefault()
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
tag := strings.TrimSpace(c.Query("tag"))
|
||||
author := strings.TrimSpace(c.Query("author"))
|
||||
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
|
||||
hasSearch := keyword != "" || tag != "" || author != ""
|
||||
|
||||
var boardID uint
|
||||
var boardName string
|
||||
if idStr := c.Param("id"); idStr != "" {
|
||||
idStr = stripIDParam(idStr, d.Settings.Permalink().Ext)
|
||||
if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
|
||||
boardID = uint(n)
|
||||
}
|
||||
if boardID > 0 {
|
||||
match := d.permalink().MatchBoardPath(c.Request.URL.Path)
|
||||
if d.redirectIfNotCanonical(c, match) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chrome := d.chrome(ctx, "", "", "home/feed")
|
||||
chrome.ActiveBoard = boardID
|
||||
for _, b := range chrome.Boards {
|
||||
if b.ID == boardID {
|
||||
boardName = b.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if boardName != "" {
|
||||
chrome.Title = boardName + " · " + chrome.SiteName
|
||||
}
|
||||
|
||||
feedStyle := d.Settings.FeedListStyle()
|
||||
showExcerpt := feedStyle == "excerpt" || feedStyle == "thumbnail"
|
||||
|
||||
data := HomePageData{
|
||||
PageChrome: chrome,
|
||||
BoardName: boardName,
|
||||
Sort: sort,
|
||||
Keyword: keyword,
|
||||
Tag: tag,
|
||||
Author: author,
|
||||
TitleOnly: titleOnly,
|
||||
HasSearch: hasSearch,
|
||||
FeedListStyle: feedStyle,
|
||||
ShowExcerpt: showExcerpt,
|
||||
Page: page,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
HasPrev: page > 1,
|
||||
}
|
||||
|
||||
listKW := keyword
|
||||
if keyword != "" {
|
||||
kw, err := d.Settings.NormalizeSearchKeyword(keyword)
|
||||
if err != nil {
|
||||
data.SearchError = err.Error()
|
||||
data.Posts = []PostListItem{}
|
||||
ctx.HTML(http.StatusOK, "home", data)
|
||||
return
|
||||
}
|
||||
listKW = kw
|
||||
data.Keyword = kw
|
||||
}
|
||||
|
||||
items, total, err := d.Post.ListItems(services.PostListQuery{
|
||||
BoardID: boardID,
|
||||
Page: page,
|
||||
Size: size,
|
||||
Sort: sort,
|
||||
Keyword: listKW,
|
||||
Tag: tag,
|
||||
Author: author,
|
||||
TitleOnly: titleOnly,
|
||||
ViewerID: ctx.UserID(),
|
||||
ViewerIsAdmin: ctx.IsAdmin(),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSearchKeywordTooShort) || errors.Is(err, services.ErrSearchKeywordTooLong) {
|
||||
data.SearchError = err.Error()
|
||||
data.Posts = []PostListItem{}
|
||||
ctx.HTML(http.StatusOK, "home", data)
|
||||
return
|
||||
}
|
||||
c.String(http.StatusInternalServerError, "加载帖子失败")
|
||||
return
|
||||
}
|
||||
|
||||
posts := make([]PostListItem, 0, len(items))
|
||||
pl := d.permalink()
|
||||
for _, it := range items {
|
||||
authorName := strings.TrimSpace(it.User.Nickname)
|
||||
if authorName == "" {
|
||||
authorName = it.User.Username
|
||||
}
|
||||
bname := ""
|
||||
boardHref := ""
|
||||
boardSlot := 0
|
||||
if it.Board.ID > 0 {
|
||||
bname = it.Board.Name
|
||||
boardHref = pl.BoardPath(it.Board.ID)
|
||||
boardSlot = it.Board.ColorIndex
|
||||
if boardSlot < 0 {
|
||||
boardSlot = int(it.Board.ID % 8)
|
||||
}
|
||||
boardSlot = boardSlot % 8
|
||||
}
|
||||
excerptSrc := ""
|
||||
thumbURL := ""
|
||||
if showExcerpt {
|
||||
excerptSrc = strings.TrimSpace(it.ContentPlain)
|
||||
if excerptSrc == "" {
|
||||
excerptSrc = services.ExcerptFromHTML(it.Content, 60)
|
||||
} else {
|
||||
excerptSrc = services.TruncateRunes(excerptSrc, 60)
|
||||
}
|
||||
}
|
||||
if feedStyle == "thumbnail" {
|
||||
thumbURL = services.FirstImageURL(it.Content)
|
||||
}
|
||||
avatar := strings.TrimSpace(it.User.Avatar)
|
||||
authorID := it.UserID
|
||||
authorHref := ""
|
||||
if authorID > 0 {
|
||||
authorHref = pl.UserPath(authorID)
|
||||
}
|
||||
createdLabel := formatRelativeTime(it.CreatedAt)
|
||||
if sort == "reply" && it.LastReplyAt == nil {
|
||||
createdLabel = "暂无回复"
|
||||
}
|
||||
lastReplyName := ""
|
||||
lastReplyHref := ""
|
||||
lastReplyLabel := ""
|
||||
showLast := false
|
||||
if it.LastReplyAt != nil {
|
||||
showLast = true
|
||||
lastReplyLabel = formatRelativeTime(*it.LastReplyAt)
|
||||
if it.LastReplyUser != nil {
|
||||
lastReplyName = strings.TrimSpace(it.LastReplyUser.Nickname)
|
||||
if lastReplyName == "" {
|
||||
lastReplyName = it.LastReplyUser.Username
|
||||
}
|
||||
if it.LastReplyUser.ID > 0 {
|
||||
lastReplyHref = pl.UserPath(it.LastReplyUser.ID)
|
||||
}
|
||||
} else {
|
||||
lastReplyName = strings.TrimSpace(it.LastReplyGuestNick)
|
||||
}
|
||||
if lastReplyName == "" {
|
||||
showLast = false
|
||||
}
|
||||
}
|
||||
posts = append(posts, PostListItem{
|
||||
ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID),
|
||||
AuthorID: authorID, AuthorName: authorName, AuthorHref: authorHref,
|
||||
AvatarURL: avatar, AuthorMark: firstRuneOr(authorName, "姜"),
|
||||
BoardID: it.BoardID, BoardName: bname, BoardHref: boardHref,
|
||||
BoardColorSlot: boardSlot, ShowBoardBadge: boardID == 0 && it.Board.ID > 0,
|
||||
Excerpt: excerptSrc, ThumbURL: thumbURL,
|
||||
Pinned: it.Pinned, BoardPinned: it.BoardPinned, Featured: it.Featured,
|
||||
CommentCount: it.CommentCount, LikeCount: it.LikeCount, ViewCount: it.ViewCount,
|
||||
CreatedLabel: createdLabel,
|
||||
LastReplyLabel: lastReplyLabel, LastReplyName: lastReplyName,
|
||||
LastReplyHref: lastReplyHref, ShowLastReply: showLast,
|
||||
})
|
||||
}
|
||||
|
||||
data.Posts = posts
|
||||
data.HasMore = int64(page*size) < total
|
||||
ctx.HTML(http.StatusOK, "home", data)
|
||||
}
|
||||
|
||||
func normalizeSort(s string) string {
|
||||
switch s {
|
||||
case "reply", "hot":
|
||||
return s
|
||||
default:
|
||||
return "latest"
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// formatRelativeTime 列表用相对时间
|
||||
func formatRelativeTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
now := time.Now()
|
||||
d := now.Sub(t)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "刚刚"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d 分钟前", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%d 小时前", int(d.Hours()))
|
||||
case d < 48*time.Hour:
|
||||
return "昨天"
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%d 天前", int(d.Hours()/24))
|
||||
default:
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
255
routers/web/links.go
Normal file
255
routers/web/links.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type linksLinkView struct {
|
||||
Name string
|
||||
URL string
|
||||
Logo string
|
||||
}
|
||||
|
||||
type linksApplyView struct {
|
||||
ID uint
|
||||
Name string
|
||||
URL string
|
||||
Logo string
|
||||
Status string
|
||||
StatusLabel string
|
||||
ReviewNote string
|
||||
CreatedAt string
|
||||
CanCancel bool
|
||||
}
|
||||
|
||||
type linksPageData struct {
|
||||
PageChrome
|
||||
Links []linksLinkView
|
||||
MyApplies []linksApplyView
|
||||
FormName string
|
||||
FormURL string
|
||||
FormLogo string
|
||||
FormRecip string
|
||||
FormOnHome bool
|
||||
AvatarMaxMB int
|
||||
}
|
||||
|
||||
func friendLinkApplyStatusLabel(status string) string {
|
||||
switch status {
|
||||
case models.FriendLinkApplyStatusPending:
|
||||
return "待审核"
|
||||
case models.FriendLinkApplyStatusApproved:
|
||||
return "已通过"
|
||||
case models.FriendLinkApplyStatusRejected:
|
||||
return "已拒绝"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) publicBaseURL(c *gin.Context) string {
|
||||
proto := c.GetHeader("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
origin := ""
|
||||
if host != "" {
|
||||
origin = proto + "://" + host
|
||||
}
|
||||
return d.Settings.SitePublicBaseURL(origin)
|
||||
}
|
||||
|
||||
// LinksGet 友链公开页
|
||||
func (d Deps) LinksGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderLinks(ctx, c, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderLinks(ctx *webctx.Context, c *gin.Context, errMsg string) {
|
||||
brand := d.Settings.SiteBranding()
|
||||
chrome := d.chrome(ctx, "友情链接 · "+brand.Name, "", "")
|
||||
chrome.Error = errMsg
|
||||
data := linksPageData{
|
||||
PageChrome: chrome,
|
||||
FormOnHome: true,
|
||||
AvatarMaxMB: d.Settings.AvatarMaxMB(),
|
||||
}
|
||||
for _, l := range brand.FriendLinks {
|
||||
data.Links = append(data.Links, linksLinkView{Name: l.Name, URL: l.URL, Logo: l.Logo})
|
||||
}
|
||||
if ctx.IsSigned() && d.FriendLink != nil {
|
||||
rows, _ := d.FriendLink.ListMine(ctx.UserID())
|
||||
data.MyApplies = make([]linksApplyView, 0, len(rows))
|
||||
for _, a := range rows {
|
||||
data.MyApplies = append(data.MyApplies, linksApplyView{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
URL: a.URL,
|
||||
Logo: a.Logo,
|
||||
Status: a.Status,
|
||||
StatusLabel: friendLinkApplyStatusLabel(a.Status),
|
||||
ReviewNote: a.ReviewNote,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"),
|
||||
CanCancel: a.Status == models.FriendLinkApplyStatusPending,
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "links/list", data)
|
||||
}
|
||||
|
||||
func (d Deps) renderLinksForm(ctx *webctx.Context, c *gin.Context, errMsg string, form linksPageData) {
|
||||
brand := d.Settings.SiteBranding()
|
||||
chrome := d.chrome(ctx, "友情链接 · "+brand.Name, "", "")
|
||||
chrome.Error = errMsg
|
||||
form.PageChrome = chrome
|
||||
form.AvatarMaxMB = d.Settings.AvatarMaxMB()
|
||||
for _, l := range brand.FriendLinks {
|
||||
form.Links = append(form.Links, linksLinkView{Name: l.Name, URL: l.URL, Logo: l.Logo})
|
||||
}
|
||||
if ctx.IsSigned() && d.FriendLink != nil {
|
||||
rows, _ := d.FriendLink.ListMine(ctx.UserID())
|
||||
form.MyApplies = make([]linksApplyView, 0, len(rows))
|
||||
for _, a := range rows {
|
||||
form.MyApplies = append(form.MyApplies, linksApplyView{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
URL: a.URL,
|
||||
Logo: a.Logo,
|
||||
Status: a.Status,
|
||||
StatusLabel: friendLinkApplyStatusLabel(a.Status),
|
||||
ReviewNote: a.ReviewNote,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"),
|
||||
CanCancel: a.Status == models.FriendLinkApplyStatusPending,
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "links/list", form)
|
||||
}
|
||||
|
||||
// LinksApplyPost 提交友链申请
|
||||
func (d Deps) LinksApplyPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
form := linksPageData{
|
||||
FormName: strings.TrimSpace(c.PostForm("name")),
|
||||
FormURL: strings.TrimSpace(c.PostForm("url")),
|
||||
FormLogo: strings.TrimSpace(c.PostForm("logo")),
|
||||
FormRecip: strings.TrimSpace(c.PostForm("reciprocal_page_url")),
|
||||
FormOnHome: c.PostForm("link_on_homepage") == "1" || c.PostForm("link_on_homepage") == "on",
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderLinksForm(ctx, c, "无效请求,请重试", form)
|
||||
return
|
||||
}
|
||||
if d.FriendLink == nil {
|
||||
d.renderLinksForm(ctx, c, "友链服务未就绪", form)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("friend_link", fmt.Sprintf("%d", ctx.UserID())) {
|
||||
d.renderLinksForm(ctx, c, "申请过于频繁,请稍后再试", form)
|
||||
return
|
||||
}
|
||||
logo := form.FormLogo
|
||||
if file, err := c.FormFile("logo_file"); err == nil && file != nil {
|
||||
if d.Store == nil {
|
||||
d.renderLinksForm(ctx, c, "上传存储未就绪", form)
|
||||
return
|
||||
}
|
||||
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, fmt.Sprintf("fl_%d", ctx.UserID()))
|
||||
if err != nil {
|
||||
d.renderLinksForm(ctx, c, err.Error(), form)
|
||||
return
|
||||
}
|
||||
logo = url
|
||||
form.FormLogo = url
|
||||
}
|
||||
_, err := d.FriendLink.Create(services.FriendLinkApplyInput{
|
||||
UserID: ctx.UserID(),
|
||||
Name: form.FormName,
|
||||
URL: form.FormURL,
|
||||
Logo: logo,
|
||||
LinkOnHomepage: form.FormOnHome,
|
||||
ReciprocalPageURL: form.FormRecip,
|
||||
OurSiteURL: d.publicBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
d.renderLinksForm(ctx, c, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("友链申请已提交,请等待审核")
|
||||
ctx.Redirect("/links")
|
||||
}
|
||||
|
||||
// LinksApplyCancelPost 取消待审申请
|
||||
func (d Deps) LinksApplyCancelPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderLinks(ctx, c, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.FriendLink == nil {
|
||||
d.renderLinks(ctx, c, "友链服务未就绪")
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.FriendLink.Cancel(ctx.UserID(), uint(id64)); err != nil {
|
||||
if errors.Is(err, services.ErrFriendLinkApplyNotFound) || errors.Is(err, services.ErrFriendLinkApplyHandled) {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/links")
|
||||
return
|
||||
}
|
||||
d.renderLinks(ctx, c, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已取消申请")
|
||||
ctx.Redirect("/links")
|
||||
}
|
||||
|
||||
// LinksLogoUpload Logo 上传(JSON,供表单渐进增强)
|
||||
func (d Deps) LinksLogoUpload(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.IsSigned() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
|
||||
return
|
||||
}
|
||||
if d.Store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"})
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("logo")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 LOGO 图片"})
|
||||
return
|
||||
}
|
||||
maxBytes := int64(d.Settings.AvatarMaxMB()) * 1024 * 1024
|
||||
if file.Size > maxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大"})
|
||||
return
|
||||
}
|
||||
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, fmt.Sprintf("fl_%d", ctx.UserID()))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LOGO 已上传", "url": url})
|
||||
}
|
||||
313
routers/web/messages.go
Normal file
313
routers/web/messages.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type msgConvRow struct {
|
||||
PeerID uint
|
||||
PeerName string
|
||||
IsSystem bool
|
||||
Preview string
|
||||
Unread int64
|
||||
UpdatedLabel string
|
||||
}
|
||||
|
||||
type messagesListData struct {
|
||||
PageChrome
|
||||
Conversations []msgConvRow
|
||||
UnreadTotal int64
|
||||
UnreadDM int64
|
||||
UnreadNotify int64
|
||||
Page int
|
||||
PrevPage int
|
||||
NextPage int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
type msgBubble struct {
|
||||
ID uint
|
||||
FromSelf bool
|
||||
IsSystem bool
|
||||
Subject string
|
||||
Content string
|
||||
CreatedLabel string
|
||||
Kind string
|
||||
}
|
||||
|
||||
type messagesThreadData struct {
|
||||
PageChrome
|
||||
PeerID uint
|
||||
PeerName string
|
||||
IsSystem bool
|
||||
CanReply bool
|
||||
Messages []msgBubble
|
||||
OlderBefore uint
|
||||
HasOlder bool
|
||||
Draft string
|
||||
KindFilter string
|
||||
KindLinks []msgKindLink
|
||||
}
|
||||
|
||||
type msgKindLink struct {
|
||||
Kind string
|
||||
Label string
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// MessagesList GET /messages
|
||||
func (d Deps) MessagesList(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := 30
|
||||
list, total, err := d.Message.ListConversations(services.ConversationListQuery{
|
||||
UserID: ctx.UserID(), Page: page, Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
chrome := d.chrome(ctx, "私信 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Error = err.Error()
|
||||
ctx.HTML(http.StatusOK, "messages/list", messagesListData{PageChrome: chrome})
|
||||
return
|
||||
}
|
||||
rows := make([]msgConvRow, 0, len(list))
|
||||
for _, cv := range list {
|
||||
name := "系统通知"
|
||||
if !cv.IsSystem {
|
||||
name = "用户"
|
||||
if cv.PeerUser != nil {
|
||||
name = displayName(cv.PeerUser)
|
||||
}
|
||||
}
|
||||
preview := ""
|
||||
if cv.LastMessage != nil {
|
||||
preview = strings.TrimSpace(cv.LastMessage.Subject)
|
||||
if preview == "" {
|
||||
preview = truncateRunes(cv.LastMessage.Content, 48)
|
||||
}
|
||||
}
|
||||
rows = append(rows, msgConvRow{
|
||||
PeerID: cv.PeerUserID, PeerName: name, IsSystem: cv.IsSystem,
|
||||
Preview: preview, Unread: cv.UnreadCount,
|
||||
UpdatedLabel: formatTime(cv.UpdatedAt),
|
||||
})
|
||||
}
|
||||
totalUnread, dm, notify, _ := d.Message.UnreadCounts(ctx.UserID())
|
||||
chrome := d.chrome(ctx, "私信 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
hasMore := int64(page*size) < total
|
||||
ctx.HTML(http.StatusOK, "messages/list", messagesListData{
|
||||
PageChrome: chrome, Conversations: rows,
|
||||
UnreadTotal: totalUnread, UnreadDM: dm, UnreadNotify: notify,
|
||||
Page: page, PrevPage: page - 1, NextPage: page + 1,
|
||||
HasPrev: page > 1, HasMore: hasMore,
|
||||
})
|
||||
}
|
||||
|
||||
// MessagesThread GET /messages/with/:peerId
|
||||
func (d Deps) MessagesThread(c *gin.Context) {
|
||||
peerID, err := parsePeerID(c.Param("peerId"))
|
||||
if err != nil {
|
||||
ctx := d.ctx(c)
|
||||
ctx.SetFlash("无效会话")
|
||||
ctx.Redirect("/messages")
|
||||
return
|
||||
}
|
||||
d.renderMessagesThread(c, peerID, "", c.Query("before"), "")
|
||||
}
|
||||
|
||||
func (d Deps) renderMessagesThread(c *gin.Context, peerID uint, errMsg, beforeRaw, draft string) {
|
||||
ctx := d.ctx(c)
|
||||
uid := ctx.UserID()
|
||||
before, _ := strconv.ParseUint(beforeRaw, 10, 64)
|
||||
size := 40
|
||||
kindFilter := ""
|
||||
if peerID == 0 {
|
||||
kindFilter = normalizeNotifyKind(c.Query("kind"))
|
||||
}
|
||||
|
||||
var list []models.PrivateMessage
|
||||
var total int64
|
||||
var err error
|
||||
if peerID == 0 && kindFilter != "" && kindFilter != "all" {
|
||||
list, total, err = d.Message.ListNotifications(uid, 1, size, kindFilter)
|
||||
_ = before // kind 筛选暂不分页 before
|
||||
} else {
|
||||
list, total, err = d.Message.ListConversationMessages(services.ConversationMessagesQuery{
|
||||
UserID: uid, PeerID: peerID, Page: 1, Size: size, Before: uint(before),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/messages")
|
||||
return
|
||||
}
|
||||
if before == 0 {
|
||||
_ = d.Message.MarkConversationRead(uid, peerID)
|
||||
}
|
||||
|
||||
peerName := "系统通知"
|
||||
canReply := peerID > 0
|
||||
if peerID > 0 {
|
||||
if u, e := d.User.GetByID(peerID); e == nil {
|
||||
peerName = displayName(u)
|
||||
} else {
|
||||
peerName = fmt.Sprintf("用户 #%d", peerID)
|
||||
}
|
||||
}
|
||||
|
||||
bubbles := make([]msgBubble, 0, len(list))
|
||||
var oldest uint
|
||||
for _, m := range list {
|
||||
if oldest == 0 || m.ID < oldest {
|
||||
oldest = m.ID
|
||||
}
|
||||
bubbles = append(bubbles, msgBubble{
|
||||
ID: m.ID, FromSelf: m.FromUserID == uid && m.FromUserID > 0,
|
||||
IsSystem: m.FromUserID == 0, Subject: m.Subject, Content: m.Content,
|
||||
CreatedLabel: formatTime(m.CreatedAt), Kind: m.Kind,
|
||||
})
|
||||
}
|
||||
hasOlder := false
|
||||
if peerID != 0 || kindFilter == "" || kindFilter == "all" {
|
||||
if oldest > 0 {
|
||||
if before == 0 {
|
||||
hasOlder = total > int64(len(list))
|
||||
} else {
|
||||
hasOlder = len(list) >= size
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasOlder = false
|
||||
}
|
||||
|
||||
chrome := d.chrome(ctx, peerName+" · 私信 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Error = errMsg
|
||||
data := messagesThreadData{
|
||||
PageChrome: chrome, PeerID: peerID, PeerName: peerName,
|
||||
IsSystem: peerID == 0, CanReply: canReply, Messages: bubbles,
|
||||
OlderBefore: oldest, HasOlder: hasOlder, Draft: draft,
|
||||
}
|
||||
if peerID == 0 {
|
||||
data.KindFilter = kindFilter
|
||||
data.KindLinks = notifyKindLinks(kindFilter)
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "messages/thread", data)
|
||||
}
|
||||
|
||||
func normalizeNotifyKind(raw string) string {
|
||||
k := strings.TrimSpace(strings.ToLower(raw))
|
||||
switch k {
|
||||
case "", "all":
|
||||
return "all"
|
||||
case models.MessageKindReply, models.MessageKindMention, models.MessageKindSystem,
|
||||
models.MessageKindReject, models.MessageKindReportResult, models.MessageKindModeration:
|
||||
return k
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
func notifyKindLinks(active string) []msgKindLink {
|
||||
if active == "" {
|
||||
active = "all"
|
||||
}
|
||||
items := []struct{ kind, label string }{
|
||||
{"all", "全部"},
|
||||
{models.MessageKindReply, "回复"},
|
||||
{models.MessageKindMention, "提及"},
|
||||
{models.MessageKindSystem, "系统"},
|
||||
{models.MessageKindReject, "拒绝"},
|
||||
{models.MessageKindReportResult, "举报结果"},
|
||||
{models.MessageKindModeration, "审核"},
|
||||
}
|
||||
out := make([]msgKindLink, 0, len(items))
|
||||
for _, it := range items {
|
||||
url := "/messages/with/0"
|
||||
if it.kind != "all" {
|
||||
url += "?kind=" + it.kind
|
||||
}
|
||||
out = append(out, msgKindLink{
|
||||
Kind: it.kind, Label: it.label, URL: url, Active: active == it.kind,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MessagesSend POST /messages/with/:peerId
|
||||
func (d Deps) MessagesSend(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
peerID, err := parsePeerID(c.Param("peerId"))
|
||||
if err != nil || peerID == 0 {
|
||||
ctx.SetFlash("无法向系统通知会话发送私信")
|
||||
ctx.Redirect("/messages")
|
||||
return
|
||||
}
|
||||
content := strings.TrimSpace(c.PostForm("content"))
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderMessagesThread(c, peerID, "无效请求,请重试", "", content)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("message", fmt.Sprintf("%d", ctx.UserID())) {
|
||||
d.renderMessagesThread(c, peerID, "发送过于频繁,请稍后再试", "", content)
|
||||
return
|
||||
}
|
||||
if ctx.Doer != nil && ctx.Doer.Banned {
|
||||
d.renderMessagesThread(c, peerID, "账号已被禁言,无法发私信", "", content)
|
||||
return
|
||||
}
|
||||
_, err = d.Message.Send(services.MessageSendInput{
|
||||
FromUserID: ctx.UserID(),
|
||||
ToUserID: peerID,
|
||||
Content: content,
|
||||
})
|
||||
if err != nil {
|
||||
d.renderMessagesThread(c, peerID, err.Error(), "", content)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已发送")
|
||||
ctx.Redirect(fmt.Sprintf("/messages/with/%d", peerID))
|
||||
}
|
||||
|
||||
// MessagesReadAll POST /messages/read-all
|
||||
func (d Deps) MessagesReadAll(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/messages")
|
||||
return
|
||||
}
|
||||
_ = d.Message.MarkAllRead(ctx.UserID())
|
||||
ctx.SetFlash("已全部标为已读")
|
||||
ctx.Redirect("/messages")
|
||||
}
|
||||
|
||||
func parsePeerID(raw string) (uint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "system" {
|
||||
return 0, nil
|
||||
}
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) <= max {
|
||||
return string(r)
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
44
routers/web/page.go
Normal file
44
routers/web/page.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type sitePageViewData struct {
|
||||
PageChrome
|
||||
PageTitle string
|
||||
Slug string
|
||||
BodyHTML template.HTML
|
||||
}
|
||||
|
||||
// SitePageGet 公开站点单页
|
||||
func (d Deps) SitePageGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if d.SitePage == nil {
|
||||
d.render404(ctx)
|
||||
return
|
||||
}
|
||||
slug := c.Param("slug")
|
||||
page, err := d.SitePage.GetBySlug(slug, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSitePageNotFound) {
|
||||
d.render404(ctx)
|
||||
return
|
||||
}
|
||||
c.String(http.StatusInternalServerError, "加载失败")
|
||||
return
|
||||
}
|
||||
html := services.SanitizePostHTML(page.Content)
|
||||
chrome := d.chrome(ctx, page.Title+" · "+d.Settings.SiteBranding().Name, "", "")
|
||||
ctx.HTML(http.StatusOK, "page/view", sitePageViewData{
|
||||
PageChrome: chrome,
|
||||
PageTitle: page.Title,
|
||||
Slug: page.Slug,
|
||||
BodyHTML: template.HTML(html),
|
||||
})
|
||||
}
|
||||
35
routers/web/pending.go
Normal file
35
routers/web/pending.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type pendingData struct {
|
||||
PageChrome
|
||||
Heading string
|
||||
Message string
|
||||
}
|
||||
|
||||
// PendingPage 未迁移页
|
||||
func (d Deps) PendingPage(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
chrome := d.chrome(ctx, "页面准备中 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
ctx.HTML(http.StatusOK, "status/pending", pendingData{
|
||||
PageChrome: chrome,
|
||||
Heading: "页面准备中",
|
||||
Message: "该功能尚未用模板实现,请稍后再来。",
|
||||
})
|
||||
}
|
||||
|
||||
func (d Deps) render404(ctx *webctx.Context) {
|
||||
chrome := d.chrome(ctx, "页面不存在 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
ctx.HTML(http.StatusNotFound, "status/404", chrome)
|
||||
}
|
||||
|
||||
// NotFound NoRoute
|
||||
func (d Deps) NotFound(c *gin.Context) {
|
||||
d.render404(d.ctx(c))
|
||||
}
|
||||
1039
routers/web/post.go
Normal file
1039
routers/web/post.go
Normal file
File diff suppressed because it is too large
Load Diff
337
routers/web/profile.go
Normal file
337
routers/web/profile.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const profileLedgerLimit = 15
|
||||
|
||||
type profileLedgerItem struct {
|
||||
Delta int
|
||||
Balance int
|
||||
ReasonLabel string
|
||||
Note string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type profileData struct {
|
||||
PageChrome
|
||||
UserID uint
|
||||
Username string
|
||||
Nickname string
|
||||
Signature string
|
||||
Avatar string
|
||||
Email string
|
||||
Level int
|
||||
Exp int
|
||||
Points int
|
||||
Badges []userPublicBadge
|
||||
PostCount int64
|
||||
CommentCount int64
|
||||
FavoriteCount int64
|
||||
LikeReceived int64
|
||||
PublicURL string
|
||||
AvatarMaxMB int
|
||||
SignatureMax int
|
||||
CheckIn services.CheckInStatus
|
||||
Lottery services.LotteryStatus
|
||||
Ledger []profileLedgerItem
|
||||
}
|
||||
|
||||
func pointReasonLabel(reason string) string {
|
||||
switch reason {
|
||||
case models.PointReasonCheckIn:
|
||||
return "签到"
|
||||
case models.PointReasonLottery:
|
||||
return "每日抽奖"
|
||||
case models.PointReasonUnlockSpend:
|
||||
return "解锁内容"
|
||||
case models.PointReasonCreatorIncome:
|
||||
return "创作分成"
|
||||
case models.PointReasonAdminAdjust:
|
||||
return "站长调整"
|
||||
case models.PointReasonBountyEscrow:
|
||||
return "悬赏托管"
|
||||
case models.PointReasonBountyAward:
|
||||
return "悬赏奖励"
|
||||
case models.PointReasonBountyRefund:
|
||||
return "悬赏退款"
|
||||
default:
|
||||
if reason == "" {
|
||||
return "其他"
|
||||
}
|
||||
return reason
|
||||
}
|
||||
}
|
||||
|
||||
// ProfileGet 个人中心
|
||||
func (d Deps) ProfileGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderProfile(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) {
|
||||
uid := ctx.UserID()
|
||||
user, err := d.User.GetByID(uid)
|
||||
if err != nil {
|
||||
ctx.SetFlash("用户不存在")
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
st, _ := d.User.ActivityStats(uid)
|
||||
chrome := d.chrome(ctx, "个人中心 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Error = errMsg
|
||||
chrome.ViewerPoints = user.Points
|
||||
nick := strings.TrimSpace(user.Nickname)
|
||||
if nick == "" {
|
||||
nick = user.Username
|
||||
}
|
||||
data := profileData{
|
||||
PageChrome: chrome,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Signature: user.Signature,
|
||||
Avatar: user.Avatar,
|
||||
Email: user.Email,
|
||||
Level: models.LevelFromExp(user.Exp),
|
||||
Exp: user.Exp,
|
||||
Points: user.Points,
|
||||
PostCount: st.PostCount,
|
||||
CommentCount: st.CommentCount,
|
||||
FavoriteCount: st.FavoriteCount,
|
||||
LikeReceived: st.LikeReceived,
|
||||
PublicURL: fmt.Sprintf("/user/%d", user.ID),
|
||||
AvatarMaxMB: d.Settings.AvatarMaxMB(),
|
||||
SignatureMax: d.Settings.SignatureMax(),
|
||||
}
|
||||
if d.Badge != nil {
|
||||
_ = d.Badge.EvaluateAuto(uid)
|
||||
if rows, err := d.Badge.ListUserBadges(uid); err == nil {
|
||||
for _, v := range services.BadgeViews(rows, 0) {
|
||||
data.Badges = append(data.Badges, userPublicBadge{Name: v.Name, Description: v.Description})
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.Points != nil {
|
||||
data.CheckIn, _ = d.Points.GetCheckInStatus(uid)
|
||||
data.Lottery, _ = d.Points.GetLotteryStatus(uid)
|
||||
rows, _, _ := d.Points.ListLedger(uid, 1, profileLedgerLimit)
|
||||
data.Ledger = make([]profileLedgerItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
data.Ledger = append(data.Ledger, profileLedgerItem{
|
||||
Delta: row.Delta,
|
||||
Balance: row.Balance,
|
||||
ReasonLabel: pointReasonLabel(row.Reason),
|
||||
Note: row.Note,
|
||||
CreatedAt: row.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
}
|
||||
// 导航显示最新昵称
|
||||
data.ViewerName = nick
|
||||
ctx.HTML(http.StatusOK, "profile/view", data)
|
||||
}
|
||||
|
||||
// ProfileNicknamePost 改昵称
|
||||
func (d Deps) ProfileNicknamePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderProfile(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if err := d.User.UpdateNickname(ctx.UserID(), strings.TrimSpace(c.PostForm("nickname"))); err != nil {
|
||||
d.renderProfile(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("昵称已更新")
|
||||
ctx.Redirect("/profile")
|
||||
}
|
||||
|
||||
// ProfileSignaturePost 改签名
|
||||
func (d Deps) ProfileSignaturePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderProfile(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if err := d.User.UpdateSignature(ctx.UserID(), c.PostForm("signature")); err != nil {
|
||||
d.renderProfile(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("签名已更新")
|
||||
ctx.Redirect("/profile")
|
||||
}
|
||||
|
||||
// ProfilePasswordPost 改密码:吊销全部 session 后为本端重建
|
||||
func (d Deps) ProfilePasswordPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderProfile(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
oldPass := c.PostForm("old_password")
|
||||
newPass := c.PostForm("new_password")
|
||||
confirm := c.PostForm("new_password2")
|
||||
if newPass != confirm {
|
||||
d.renderProfile(ctx, "两次输入的新密码不一致")
|
||||
return
|
||||
}
|
||||
uid := ctx.UserID()
|
||||
if err := d.User.UpdatePassword(uid, oldPass, newPass); err != nil {
|
||||
d.renderProfile(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
services.RevokeUserSessions(uid)
|
||||
user, err := d.User.GetByID(uid)
|
||||
if err != nil {
|
||||
ctx.SetFlash("密码已更新,请重新登录")
|
||||
ctx.Redirect("/login?redirect=/profile")
|
||||
return
|
||||
}
|
||||
sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
ctx.SetFlash("密码已更新,请重新登录")
|
||||
ctx.Redirect("/login?redirect=/profile")
|
||||
return
|
||||
}
|
||||
ctx.SetLoginCookie(sid)
|
||||
ctx.SetFlash("密码已更新,其它设备已登出")
|
||||
ctx.Redirect("/profile")
|
||||
}
|
||||
|
||||
// ProfileAvatarPost 上传头像
|
||||
func (d Deps) ProfileAvatarPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderProfile(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
d.renderProfile(ctx, "请选择头像文件")
|
||||
return
|
||||
}
|
||||
if d.Store == nil {
|
||||
d.renderProfile(ctx, "上传存储未就绪")
|
||||
return
|
||||
}
|
||||
if _, err := d.User.UploadAvatar(ctx.UserID(), file, d.Store); err != nil {
|
||||
d.renderProfile(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("头像已更新")
|
||||
ctx.Redirect("/profile")
|
||||
}
|
||||
|
||||
// ProfileCheckInPost 每日签到(PRG)
|
||||
func (d Deps) ProfileCheckInPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
redir := safePointsRedirect(c.PostForm("redirect"))
|
||||
if !ctx.CheckCSRF() {
|
||||
if redir != "/profile" {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
d.renderProfile(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.Points == nil {
|
||||
if redir != "/profile" {
|
||||
ctx.SetFlash("积分服务未就绪")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
d.renderProfile(ctx, "积分服务未就绪")
|
||||
return
|
||||
}
|
||||
st, err := d.Points.CheckIn(ctx.UserID())
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrAlreadyCheckedIn) {
|
||||
ctx.SetFlash("今日已签到")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if redir != "/profile" {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
d.renderProfile(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash(fmt.Sprintf("签到成功:连续 %d 天,获得 %d 积分", st.Streak, st.TodayPoints))
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
// ProfileLotteryPost 每日抽奖(PRG)
|
||||
func (d Deps) ProfileLotteryPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
redir := safePointsRedirect(c.PostForm("redirect"))
|
||||
if !ctx.CheckCSRF() {
|
||||
if redir != "/profile" {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
d.renderProfile(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
if d.Points == nil {
|
||||
if redir != "/profile" {
|
||||
ctx.SetFlash("积分服务未就绪")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
d.renderProfile(ctx, "积分服务未就绪")
|
||||
return
|
||||
}
|
||||
st, err := d.Points.DrawLottery(ctx.UserID())
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrAlreadyLottery) {
|
||||
ctx.SetFlash("今日已抽奖")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if redir != "/profile" {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
d.renderProfile(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if st.Points > 0 {
|
||||
ctx.SetFlash(fmt.Sprintf("抽奖结果:获得 %d 积分", st.Points))
|
||||
} else {
|
||||
ctx.SetFlash("抽奖结果:未中奖,明天再来")
|
||||
}
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
// safePointsRedirect 签到/抽奖 PRG 回跳:仅允许 /、/board/…、/post/…;空或非法则 /profile
|
||||
func safePointsRedirect(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.Contains(raw, "://") || strings.HasPrefix(raw, "//") || strings.Contains(raw, "..") {
|
||||
return "/profile"
|
||||
}
|
||||
if raw == "/" {
|
||||
return raw
|
||||
}
|
||||
if strings.HasPrefix(raw, "/board/") || strings.HasPrefix(raw, "/post/") {
|
||||
if strings.ContainsAny(raw, "?#") {
|
||||
return "/profile"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
return "/profile"
|
||||
}
|
||||
72
routers/web/report.go
Normal file
72
routers/web/report.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PostReportPost 举报帖子
|
||||
func (d Deps) PostReportPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
redir := fmt.Sprintf("/post/%d", postID)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if d.Report == nil {
|
||||
ctx.SetFlash("举报服务未就绪")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("report", fmt.Sprintf("%d", ctx.UserID())) {
|
||||
ctx.SetFlash("举报过于频繁,请稍后再试")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||
detail := strings.TrimSpace(c.PostForm("detail"))
|
||||
if _, err := d.Report.Create(ctx.UserID(), uint(postID), reason, detail); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("举报已提交,感谢反馈")
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
// CommentReportPost 举报评论
|
||||
func (d Deps) CommentReportPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
cid, _ := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||||
redir := fmt.Sprintf("/post/%d", postID)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if d.Report == nil {
|
||||
ctx.SetFlash("举报服务未就绪")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("report", fmt.Sprintf("%d", ctx.UserID())) {
|
||||
ctx.SetFlash("举报过于频繁,请稍后再试")
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||
detail := strings.TrimSpace(c.PostForm("detail"))
|
||||
if _, err := d.Report.CreateCommentReport(ctx.UserID(), uint(cid), reason, detail); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(redir)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("举报已提交,感谢反馈")
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
128
routers/web/user.go
Normal file
128
routers/web/user.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type userPublicBadge struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
type userPublicData struct {
|
||||
PageChrome
|
||||
UserID uint
|
||||
Username string
|
||||
Nickname string
|
||||
Signature string
|
||||
Avatar string
|
||||
Level int
|
||||
Exp int
|
||||
Points int
|
||||
Badges []userPublicBadge
|
||||
PostCount int64
|
||||
CommentCount int64
|
||||
FavoriteCount int64
|
||||
LikeReceived int64
|
||||
IsSelf bool
|
||||
Posts []PostListItem
|
||||
Page int
|
||||
PrevPage int
|
||||
NextPage int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
// UserPublic 公开用户主页(不含邮箱)
|
||||
func (d Deps) UserPublic(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
d.render404(ctx)
|
||||
return
|
||||
}
|
||||
user, err := d.User.GetByID(uint(id))
|
||||
if err != nil {
|
||||
d.render404(ctx)
|
||||
return
|
||||
}
|
||||
match := d.permalink().MatchUserPath(c.Request.URL.Path)
|
||||
if d.redirectIfNotCanonical(c, match) {
|
||||
return
|
||||
}
|
||||
st, _ := d.User.ActivityStats(user.ID)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := d.Settings.PageSizeDefault()
|
||||
posts, total, _ := d.Post.List(services.PostListQuery{
|
||||
UserID: user.ID,
|
||||
Page: page,
|
||||
Size: size,
|
||||
Sort: "latest",
|
||||
ViewerID: ctx.UserID(),
|
||||
ViewerIsAdmin: ctx.IsAdmin(),
|
||||
})
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
pl := d.permalink()
|
||||
for _, p := range posts {
|
||||
board := ""
|
||||
if p.Board.ID > 0 {
|
||||
board = p.Board.Name
|
||||
}
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, Title: p.Title, Href: pl.PostPath(p.ID), AuthorName: displayName(user),
|
||||
BoardName: board, Pinned: p.Pinned || p.BoardPinned, Featured: p.Featured,
|
||||
CreatedLabel: formatTime(p.CreatedAt),
|
||||
})
|
||||
}
|
||||
nick := displayName(user)
|
||||
chrome := d.chrome(ctx, nick+" · "+d.Settings.SiteBranding().Name, strings.TrimSpace(user.Signature), "")
|
||||
hasMore := int64(page*size) < total
|
||||
badges := []userPublicBadge{}
|
||||
if d.Badge != nil {
|
||||
_ = d.Badge.EvaluateAuto(user.ID)
|
||||
if rows, err := d.Badge.ListUserBadges(user.ID); err == nil {
|
||||
for _, v := range services.BadgeViews(rows, 0) {
|
||||
badges = append(badges, userPublicBadge{Name: v.Name, Description: v.Description})
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "user/view", userPublicData{
|
||||
PageChrome: chrome,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Signature: user.Signature,
|
||||
Avatar: user.Avatar,
|
||||
Level: models.LevelFromExp(user.Exp),
|
||||
Exp: user.Exp,
|
||||
Points: user.Points,
|
||||
Badges: badges,
|
||||
PostCount: st.PostCount, CommentCount: st.CommentCount,
|
||||
FavoriteCount: st.FavoriteCount, LikeReceived: st.LikeReceived,
|
||||
IsSelf: ctx.UserID() == user.ID,
|
||||
Posts: items,
|
||||
Page: page, PrevPage: page - 1, NextPage: page + 1,
|
||||
HasPrev: page > 1, HasMore: hasMore,
|
||||
})
|
||||
}
|
||||
|
||||
func displayName(u *models.User) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
n := strings.TrimSpace(u.Nickname)
|
||||
if n == "" {
|
||||
return u.Username
|
||||
}
|
||||
return n
|
||||
}
|
||||
37
services/aside_widgets_test.go
Normal file
37
services/aside_widgets_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
|
||||
in := []AsideWidget{
|
||||
{ID: AsideWidgetFriendLinks, Enabled: true},
|
||||
{ID: AsideWidgetTagCloud, Enabled: true},
|
||||
{ID: AsideWidgetRecentComments, Enabled: false},
|
||||
}
|
||||
out := NormalizeAsideWidgets(in)
|
||||
if len(out) != 4 {
|
||||
t.Fatalf("want 4 widgets, got %d", len(out))
|
||||
}
|
||||
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers}
|
||||
for i, id := range want {
|
||||
if out[i].ID != id {
|
||||
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
|
||||
}
|
||||
}
|
||||
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled {
|
||||
t.Fatalf("enabled flags mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsideBoolsFromWidgets(t *testing.T) {
|
||||
widgets := []AsideWidget{
|
||||
{ID: AsideWidgetRecentComments, Enabled: true},
|
||||
{ID: AsideWidgetFriendLinks, Enabled: false},
|
||||
{ID: AsideWidgetTagCloud, Enabled: true},
|
||||
{ID: AsideWidgetRecentUsers, Enabled: true},
|
||||
}
|
||||
bools := asideBoolsFromWidgets(widgets)
|
||||
if !bools.tagCloud || !bools.recentComments || bools.friendLinks {
|
||||
t.Fatalf("unexpected bools: %+v", bools)
|
||||
}
|
||||
}
|
||||
174
services/auth.go
Normal file
174
services/auth.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
)
|
||||
|
||||
// 最近访问写入节流,避免每次请求都打库
|
||||
const lastAccessTouchInterval = 5 * time.Minute
|
||||
|
||||
var lastAccessTouchCache sync.Map // userID(uint) -> time.Time
|
||||
|
||||
// SessionTTL 与浏览器会话对齐(兼容旧常量名)
|
||||
const TokenExpire = SessionTTL
|
||||
|
||||
type AuthService struct {
|
||||
hmacSecret string // CSRF 等 HMAC;不再用于浏览器登录 JWT
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewAuthService(hmacSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
|
||||
return &AuthService{hmacSecret: hmacSecret, filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
// HMACSecret 供 CSRF 等使用
|
||||
func (s *AuthService) HMACSecret() string { return s.hmacSecret }
|
||||
|
||||
// UserCount 当前用户数
|
||||
func (s *AuthService) UserCount() int64 {
|
||||
var n int64
|
||||
models.DB.Model(&models.User{}).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(username, password, nickname, email string) (*models.User, error) {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email = NormalizeEmail(email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var exist models.User
|
||||
if err := models.DB.Where("username = ?", username).First(&exist).Error; err == nil {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
if err := models.DB.Where("email = ?", email).First(&exist).Error; err == nil {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nickname == "" {
|
||||
nickname = username
|
||||
}
|
||||
nickname = s.filter.Filter(nickname)
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
Nickname: nickname,
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
if err := models.DB.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreateAdmin 安装向导创建管理员(仅应在未安装时调用)
|
||||
func (s *AuthService) CreateAdmin(username, password, nickname, email string) (*models.User, error) {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email = NormalizeEmail(email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nickname == "" {
|
||||
nickname = username
|
||||
}
|
||||
nickname = s.filter.Filter(nickname)
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
Nickname: nickname,
|
||||
Role: models.RoleAdmin,
|
||||
Verified: true,
|
||||
}
|
||||
if err := models.DB.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 校验密码并创建会话,返回 session id
|
||||
func (s *AuthService) Login(username, password, clientIP, userAgent string) (sessionID string, user *models.User, err error) {
|
||||
var u models.User
|
||||
if err := models.DB.Where("username = ?", username).First(&u).Error; err != nil {
|
||||
return "", nil, ErrInvalidCred
|
||||
}
|
||||
if u.Banned {
|
||||
return "", nil, ErrUserBanned
|
||||
}
|
||||
if !CheckPassword(u.Password, password) {
|
||||
return "", nil, ErrInvalidCred
|
||||
}
|
||||
s.recordLogin(&u, clientIP)
|
||||
sid, err := CreateSession(u.ID, clientIP, userAgent)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return sid, &u, nil
|
||||
}
|
||||
|
||||
// CreateSessionForUser 已认证用户直接建会话(注册后自动登录)
|
||||
func (s *AuthService) CreateSessionForUser(user *models.User, clientIP, userAgent string) (string, error) {
|
||||
if user == nil {
|
||||
return "", ErrInvalidCred
|
||||
}
|
||||
s.recordLogin(user, clientIP)
|
||||
return CreateSession(user.ID, clientIP, userAgent)
|
||||
}
|
||||
|
||||
func (s *AuthService) recordLogin(user *models.User, clientIP string) {
|
||||
now := time.Now()
|
||||
ip := clientIP
|
||||
if len(ip) > 45 {
|
||||
ip = ip[:45]
|
||||
}
|
||||
_ = models.DB.Model(user).Updates(map[string]interface{}{
|
||||
"last_login_at": now,
|
||||
"last_login_ip": ip,
|
||||
"last_access_at": now,
|
||||
}).Error
|
||||
user.LastLoginAt = &now
|
||||
user.LastLoginIP = ip
|
||||
user.LastAccessAt = &now
|
||||
lastAccessTouchCache.Store(user.ID, now)
|
||||
}
|
||||
|
||||
// TouchLastAccess 记录最近访问时间(节流写入,失败忽略)
|
||||
func (s *AuthService) TouchLastAccess(userID uint) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if v, ok := lastAccessTouchCache.Load(userID); ok {
|
||||
if t, ok := v.(time.Time); ok && now.Sub(t) < lastAccessTouchInterval {
|
||||
return
|
||||
}
|
||||
}
|
||||
lastAccessTouchCache.Store(userID, now)
|
||||
_ = models.DB.Model(&models.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
|
||||
}
|
||||
45
services/backup.go
Normal file
45
services/backup.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
)
|
||||
|
||||
type BackupService struct {
|
||||
dbPath string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewBackupService(dbPath, dataDir string) *BackupService {
|
||||
return &BackupService{dbPath: dbPath, dataDir: dataDir}
|
||||
}
|
||||
|
||||
// ExportSQLite 导出 SQLite 备份文件到 data 目录(仅 sqlite)
|
||||
func (s *BackupService) ExportSQLite() (string, error) {
|
||||
if models.DialectorName() != "sqlite" {
|
||||
return "", fmt.Errorf("一键文件备份仅支持 SQLite;当前为 %s,请使用数据库自带备份工具", models.DialectorName())
|
||||
}
|
||||
src, err := os.Open(s.dbPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
filename := fmt.Sprintf("jiang13_backup_%s.db", time.Now().Format("20060102_150405"))
|
||||
destPath := filepath.Join(s.dataDir, filename)
|
||||
dst, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return destPath, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user