From 1414c71dec5e1e4eb231ec75fd18e71faefb1083 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 29 Aug 2026 03:11:25 +0800 Subject: [PATCH] =?UTF-8?q?docs+feat:=20=E9=87=8D=E6=9E=84=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=E4=B8=8E=20Gitea=20=E5=BC=8F=20SSR=20=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=EF=BC=88=E9=A6=96=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 rebuild/gitea-ssr 落地产品规格、Cursor 规则,以及 Go 模板 SSR 首页/板块列表;未迁移路径仍回落 SPA,便于对照 main。 Co-authored-by: Cursor --- .cursor/rules/rebuild-gitea-ssr.mdc | 32 ++ .cursor/rules/rebuild-spec-source.mdc | 20 + .cursor/rules/rebuild-templates.mdc | 13 + Makefile | 22 +- build.ps1 | 28 +- docs/rebuild-spec/01-product.md | 185 +++++++ docs/rebuild-spec/02-features.md | 211 ++++++++ docs/rebuild-spec/03-data-model.md | 452 ++++++++++++++++++ docs/rebuild-spec/04-api.md | 376 +++++++++++++++ docs/rebuild-spec/05-business-rules.md | 228 +++++++++ docs/rebuild-spec/06-pages-ux.md | 215 +++++++++ docs/rebuild-spec/07-config-ops.md | 150 ++++++ .../rebuild-spec/08-gitea-ssr-architecture.md | 58 +++ docs/rebuild-spec/README.md | 137 ++++++ embed_static/embed.go | 8 +- modules/webrender/render.go | 71 +++ public/assets/site.css | 163 +++++++ public/assets/site.js | 2 + public/embed.go | 8 + router/router.go | 31 +- routers/web/home.go | 219 +++++++++ templates/base.tmpl | 51 ++ templates/embed.go | 6 + templates/home.tmpl | 42 ++ web_src/build.mjs | 14 + web_src/css/site.css | 163 +++++++ web_src/js/site.js | 2 + web_src/package.json | 8 + 28 files changed, 2891 insertions(+), 24 deletions(-) create mode 100644 .cursor/rules/rebuild-gitea-ssr.mdc create mode 100644 .cursor/rules/rebuild-spec-source.mdc create mode 100644 .cursor/rules/rebuild-templates.mdc create mode 100644 docs/rebuild-spec/01-product.md create mode 100644 docs/rebuild-spec/02-features.md create mode 100644 docs/rebuild-spec/03-data-model.md create mode 100644 docs/rebuild-spec/04-api.md create mode 100644 docs/rebuild-spec/05-business-rules.md create mode 100644 docs/rebuild-spec/06-pages-ux.md create mode 100644 docs/rebuild-spec/07-config-ops.md create mode 100644 docs/rebuild-spec/08-gitea-ssr-architecture.md create mode 100644 docs/rebuild-spec/README.md create mode 100644 modules/webrender/render.go create mode 100644 public/assets/site.css create mode 100644 public/assets/site.js create mode 100644 public/embed.go create mode 100644 routers/web/home.go create mode 100644 templates/base.tmpl create mode 100644 templates/embed.go create mode 100644 templates/home.tmpl create mode 100644 web_src/build.mjs create mode 100644 web_src/css/site.css create mode 100644 web_src/js/site.js create mode 100644 web_src/package.json diff --git a/.cursor/rules/rebuild-gitea-ssr.mdc b/.cursor/rules/rebuild-gitea-ssr.mdc new file mode 100644 index 0000000..38bd0de --- /dev/null +++ b/.cursor/rules/rebuild-gitea-ssr.mdc @@ -0,0 +1,32 @@ +--- +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 页面路由 +- `templates/` — 模板 +- `web_src/` → `public/assets/` — 渐进增强 CSS/JS +- 现有 `service/`、`model/` 可复用业务逻辑 + +细节见 [`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` 为准。 diff --git a/.cursor/rules/rebuild-spec-source.mdc b/.cursor/rules/rebuild-spec-source.mdc new file mode 100644 index 0000000..c2e2f82 --- /dev/null +++ b/.cursor/rules/rebuild-spec-source.mdc @@ -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` | + +规格与代码冲突时:以**当前分支代码**行为为准,并应回写修正规格。 diff --git a/.cursor/rules/rebuild-templates.mdc b/.cursor/rules/rebuild-templates.mdc new file mode 100644 index 0000000..0fd0d75 --- /dev/null +++ b/.cursor/rules/rebuild-templates.mdc @@ -0,0 +1,13 @@ +--- +description: Go HTML 模板约定(SSR) +globs: templates/**/* +alwaysApply: false +--- + +# 模板约定 + +- 布局:`base.tmpl` + 各页定义 `content` 等 block(或 `{{template}}` 组合);保持片段小而可复用。 +- **默认转义**:`{{.Title}}` 等普通插值;用户生成 HTML 必须先经现有消毒(如 `SanitizePostHTML`),再用显式安全函数输出,禁止随意放开。 +- 静态资源走 `/ssr-assets/...`(`public/assets` 嵌入),不要写死外链 CDN(除非产品规格要求)。 +- 页面数据通过 handler 组装的 view model 传入;模板内不做复杂业务判断。 +- 中文 UI 文案可写在模板;与品牌相关的站点名等从 settings/view 传入。 diff --git a/Makefile b/Makefile index 041f219..8acfd7b 100644 --- a/Makefile +++ b/Makefile @@ -12,39 +12,42 @@ REGISTRY_IMAGE := hangzhang714128/jiang13-forum GO := go GOFLAGS := -trimpath -.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build docker compose-up compose-down +.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build web-src-build docker compose-up compose-down all: build +web-src-build: + cd web_src && npm run build + frontend-build: cd frontend && npm install && npm run build ## 编译当前平台二进制(纯 Go SQLite,无需 CGO) -build: frontend-build +build: web-src-build frontend-build @mkdir -p $(BUILD_DIR) CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG) @echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)" ## Windows amd64(先打包前端再 embed) -build-windows: frontend-build +build-windows: web-src-build frontend-build @mkdir -p $(BUILD_DIR) CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) @echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe" ## Linux amd64(先打包前端再 embed) -build-linux: frontend-build +build-linux: web-src-build frontend-build @mkdir -p $(BUILD_DIR) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) @echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64" ## macOS arm64 (Apple Silicon)(先打包前端再 embed) -build-darwin: frontend-build +build-darwin: web-src-build frontend-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" -## 跨平台全量编译(frontend-build 只跑一次) -build-all: frontend-build +## 跨平台全量编译(web-src + frontend 只跑一次) +build-all: web-src-build frontend-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) @@ -89,13 +92,14 @@ compose-down: help: @echo "姜十三论坛编译命令:" + @echo " make web-src-build - 构建 SSR 渐进资源 (web_src)" @echo " make build - 编译当前平台" @echo " make build-windows - 编译 Windows" @echo " make build-linux - 编译 Linux" @echo " make build-darwin - 编译 macOS" @echo " make build-all - 编译全部平台" - @echo " make run - 仅启动后端(:3000)" - @echo " make dev - 前端热更新开发(:5173 + :3000)" + @echo " make run - 启动后端 SSR(:3000)" + @echo " make dev - 后端 + 旧 SPA Vite 对照(:5173 + :3000)" @echo " make docker - 构建 Docker 镜像" @echo " make compose-up - Docker Compose 启动" @echo " make compose-down - Docker Compose 停止" diff --git a/build.ps1 b/build.ps1 index ad983b8..661d4c0 100644 --- a/build.ps1 +++ b/build.ps1 @@ -3,7 +3,7 @@ # .\build.ps1 -Target build-windows param( - [ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')] + [ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'web-src', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')] [string]$Target = 'build' ) @@ -22,6 +22,17 @@ function Ensure-Dir($path) { } } +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-Frontend { Write-Host '[frontend] npm run build...' -ForegroundColor Cyan Push-Location frontend @@ -70,12 +81,13 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') { switch ($Target) { 'help' { Write-Host '.\build.ps1 build current platform' - Write-Host '.\build.ps1 -Target frontend frontend only' + Write-Host '.\build.ps1 -Target frontend SPA frontend only (legacy)' + Write-Host '.\build.ps1 -Target web-src SSR progressive assets' Write-Host '.\build.ps1 -Target build-windows' Write-Host '.\build.ps1 -Target build-linux' Write-Host '.\build.ps1 -Target build-all' - Write-Host '.\build.ps1 -Target run backend only (port 3000)' - Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)' + Write-Host '.\build.ps1 -Target run backend (SSR on :3000)' + Write-Host '.\build.ps1 -Target dev backend + Vite SPA对照' Write-Host '.\build.ps1 -Target tidy' Write-Host '.\build.ps1 -Target clean' Write-Host '.\build.ps1 -Target docker build Docker image' @@ -85,6 +97,7 @@ switch ($Target) { Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.' } 'frontend' { Build-Frontend } + 'web-src' { Build-WebSrc } 'tidy' { go mod tidy } 'clean' { if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir } @@ -117,23 +130,28 @@ switch ($Target) { } } 'build' { + Build-WebSrc Build-Frontend Build-Go -OutFile $AppName } 'build-windows' { + Build-WebSrc Build-Frontend Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' } 'build-linux' { - Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow + Write-Host '[build-linux] will build web_src + SPA then go:embed' -ForegroundColor Yellow + Build-WebSrc Build-Frontend Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64' } 'build-darwin' { + Build-WebSrc Build-Frontend Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64' } 'build-all' { + Build-WebSrc Build-Frontend Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64' diff --git a/docs/rebuild-spec/01-product.md b/docs/rebuild-spec/01-product.md new file mode 100644 index 0000000..fa59f9d --- /dev/null +++ b/docs/rebuild-spec/01-product.md @@ -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 运维(拆开看待) + +| 维度 | 当前实现 | 重构时 | +|------|----------|--------| +| **产品** | 论坛功能全集(见模块地图) | **必须对齐**功能与规则 | +| **运维** | 单二进制 + 内嵌 SPA + SQLite + `app.ini` | **可选保留**;可换容器 / PG / 分离部署 | + +规格文档把「用户能做什么」写死;把「怎么打包发布」放在 [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 中): + +| 标签 | 含义 | +|------|------| +| `` | 登录可见 | +| `` | 本帖已回复可见 | +| `` | 积分解锁;按块计费 | + +### 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)。 diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md new file mode 100644 index 0000000..3cc0313 --- /dev/null +++ b/docs/rebuild-spec/02-features.md @@ -0,0 +1,211 @@ +# 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`](../../frontend/src/App.tsx)、[`router/router.go`](../../router/router.go)、[`README.md`](../../README.md) + +用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。 + +--- + +## A. 浏览与布局 + +- [ ] 三栏布局:左导航 / 中 Feed / 右栏小组件 +- [ ] 浅色 / 暗色主题;跟随系统偏好并本地记忆 +- [ ] 响应式:平板/手机收起侧栏 +- [ ] 长列表虚拟滚动或等价流畅方案 +- [ ] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门) +- [ ] 板块筛选:全部 + 单板块 +- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail` +- [ ] 搜索:关键词、标签、作者、仅标题(`title_only`) +- [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序) +- [ ] 登录用户右栏/侧边:签到与抽奖入口 +- [ ] 下拉刷新(移动端) +- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配) +- [ ] 404 页 + +--- + +## B. 认证与个人中心 + +- [ ] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码) +- [ ] 图形验证码接口(注册流程) +- [ ] 登录 / 登出(会话 Cookie) +- [ ] 忘记密码:邮箱验证码 + 重置 +- [ ] 注册配置接口:是否首用户、邮件是否就绪、是否开放注册 +- [ ] 首个用户自动成为管理员 +- [ ] 个人中心:改昵称、签名、密码、上传头像(可裁剪) +- [ ] 个人活动统计:帖数、评数、收藏数、获赞 +- [ ] 公开用户主页 `/user/:id`(无邮箱) +- [ ] 禁言用户无法使用需登录写接口 + +--- + +## C. 板块 + +- [ ] 列出板块(含帖数等展示字段) +- [ ] 管理员:创建 / 改 / 删板块 +- [ ] 板块名称、描述、图标、色板索引、排序 +- [ ] 默认板块保障(空站可引导创建) + +--- + +## D. 帖子(通用) + +- [ ] 发帖:选板块、标题、标签、正文 +- [ ] 正文图片上传 +- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节) +- [ ] Markdown 编辑模式(与富文本互转/双模) +- [ ] 编辑帖子(时限、锁帖约束) +- [ ] 删除帖子 → 软删进回收站 +- [ ] 修订历史列表与单条详情(可做 diff) +- [ ] 点赞切换;收藏切换;收藏列表页 +- [ ] 浏览量(可 `skip_view=1` 跳过计数) +- [ ] 举报帖子 +- [ ] 内容审核状态展示(作者可见待审/被拒) + +### D.1 帖子类型 + +- [ ] `normal` 普通讨论 +- [ ] `question` 问答:可标记已解决 / 未解决 +- [ ] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 +- [ ] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05) +- [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 + +### D.2 运营标记(管理员) + +- [ ] 全局置顶 / 取消 +- [ ] 版内置顶 / 取消(仅板块列表抬升) +- [ ] 精华 / 取消 +- [ ] 禁止编辑(edit lock) +- [ ] 禁止评论 / 结贴(comments lock) +- [ ] 审核通过 / 拒绝(拒绝可通知作者) +- [ ] 回收站:恢复 / 彻底删除 + +--- + +## E. 内容门控 + +- [ ] 编辑器可插入「登录可见」块 +- [ ] 编辑器可插入「回复可见」块 +- [ ] 编辑器可插入「积分可见」块(可设价格) +- [ ] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 +- [ ] 已登录未回复:遮盖 reply-only(作者与管理员始终可见) +- [ ] 积分块:未解锁遮盖;`POST .../unlock` 扣积分并返回 inner HTML +- [ ] 搜索 / SEO 出口对门控内容做红action,不泄露正文 + +--- + +## F. 评论 + +- [ ] 按帖拉取评论列表(楼层、嵌套父级、引用目标) +- [ ] 发表评论(登录);支持 `reply_to`、私密评论 +- [ ] 游客评论(公开接口可写,字段 guest_*) +- [ ] 编辑评论(时限);删除评论 +- [ ] 评论点赞 +- [ ] 评论举报 +- [ ] @ 提及 → 通知 +- [ ] 回复提醒(站内信 + 可选邮件) +- [ ] 审核中 / 被拒评论可见性规则 +- [ ] 管理员:通过 / 拒绝 / 回收站 / 查看评论修订 + +--- + +## G. 私信与通知 + +- [ ] 会话列表(含系统会话 peer=0) +- [ ] 会话消息(分页 / before 游标) +- [ ] 发送私信 +- [ ] 未读数(可分私信 / 通知) +- [ ] 标记会话已读 / 通知已读 / 全部已读 +- [ ] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等 + +--- + +## H. 积分、签到、抽奖、徽章、等级 + +- [ ] 积分流水查询 +- [ ] 每日签到:基础 5,连签每日 +1,封顶 15 +- [ ] 每日抽奖:奖池加权(0/2/5/10/20),成本 0 +- [ ] 积分解锁分成:读者付全额,作者约 70% +- [ ] 短龄同 IP 互刷拒绝分成 +- [ ] Exp → 等级 Lv1–10 +- [ ] 自动徽章(注册天数 / 获赞 / 创作分成) +- [ ] 限定徽章:管理员定义与授予/撤销 +- [ ] 管理员调整积分、设定等级 + +--- + +## I. 友链 + +- [ ] 前台友链页;导航/页脚入口可配 +- [ ] 用户申请(名称、URL、Logo、是否上首页、回链页) +- [ ] Logo 上传 +- [ ] 我的申请列表;修改待审申请;取消 +- [ ] 管理员审核:通过 / 拒绝 / 重新检测回链 +- [ ] 管理员维护品牌友链列表(设置里 JSON) + +--- + +## J. 站点单页 + +- [ ] 公开:`/page/:slug` 列表入口(nav/footer) +- [ ] 管理员 CRUD;发布开关;排序;nav/footer 展示开关 + +--- + +## K. Gitea 码桶 + +- [ ] 后台开关、Base URL、Token、同步间隔 +- [ ] 手动同步 + 后台定时同步 +- [ ] 前台 `/projects` 列表与搜索 + +--- + +## L. OIDC Provider + +- [ ] Discovery、JWKS、Authorize、Token、UserInfo、Logout +- [ ] 多 OAuth 客户端 CRUD;密钥哈希存储;PKCE 字段支持 +- [ ] groups claim 映射 admin/user 组 + +--- + +## M. 媒体与存储 + +- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置) +- [ ] 头像 / 帖图 / 站点品牌资源分类 +- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`) +- [ ] 管理后台媒体列表与删除 +- [ ] 媒体索引表同步 + +--- + +## N. 管理后台其它 + +- [ ] 仪表盘:计数 + 待审帖/评/举报/友链 + 最近帖 +- [ ] 敏感词文件读写 +- [ ] 论坛限流与字数等 Limits +- [ ] SMTP 配置与测试信 +- [ ] 站点品牌:名称、标语、简介、keywords、Logo、Favicon、OG 图、ICP +- [ ] SQLite 一键备份与下载 + +--- + +## O. 基础设施 + +- [ ] `GET /health` +- [ ] `robots.txt` / `sitemap.xml` +- [ ] 静态上传文件可达 +- [ ] 限流:发帖、评论、注册、登录、举报、私信、友链等 + +--- + +## 明确不在当前规格内(计划中可后做) + +摘自 [`ROADMAP.md`](../../ROADMAP.md),**不是**现网必交验收项: + +- 通知动态 UX 大幅优化 +- 帖子搜索增强(组合筛选更强) + +若新站一并实现,可作为加分项,不阻塞「功能对等」验收。 diff --git a/docs/rebuild-spec/03-data-model.md b/docs/rebuild-spec/03-data-model.md new file mode 100644 index 0000000..ed2f882 --- /dev/null +++ b/docs/rebuild-spec/03-data-model.md @@ -0,0 +1,452 @@ +# 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`](../../model/models.go)、[`model/oauth.go`](../../model/oauth.go)、[`model/gitea.go`](../../model/gitea.go)、[`model/level.go`](../../model/level.go)、[`model/db.go`](../../model/db.go)、[`model/user_view.go`](../../model/user_view.go)、[`service/settings.go`](../../service/settings.go) + +当前无独立 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`(键值)、`OAuthClient` / `OAuthAuthCode`、`GiteaRepo`、`SitePage`。 + +--- + +## 2. 表与字段 + +说明:`json:"-"` 表示默认 API 序列化隐藏;软删列 `deleted_at` 表示 GORM soft delete。 + +### 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`](../../model/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`](../../model/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`](../../model/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`](../../service/settings.go)、[`service/permalink.go`](../../service/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 | + +### 6.5 Gitea 同步 + +| Key | 默认 | +|-----|------| +| gitea_sync_enabled | 0 | +| gitea_base_url | | +| gitea_token | | +| gitea_sync_interval_min | 60 | + +### 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`](../../model/db.go) 在 AutoMigrate 后: + +- 空 `status` 的帖/评 → `published` +- 空 `post_type` → `normal` +- Exp=0 用户按存量内容粗算经验:`posts*10 + comments*2 + like_sum` + +新站若从空库开始可忽略;若迁移旧库需保留等价 backfill。 + +--- + +## 8. 内容门控在库中的形态 + +**无独立表**存放门控块;存在 `posts.content` HTML 中,例如: + +```html +... +... +... +``` + +积分解锁 `block_key` = `sha256(innerHTML)[:16]`(hex),见 [`service/unlock.go`](../../service/unlock.go)。 diff --git a/docs/rebuild-spec/04-api.md b/docs/rebuild-spec/04-api.md new file mode 100644 index 0000000..8a8340c --- /dev/null +++ b/docs/rebuild-spec/04-api.md @@ -0,0 +1,376 @@ +# 04 · HTTP API 合约 + +> **读者**:实现后端 / BFF / 前端数据层的 AI +> **前置**:[03-data-model.md](03-data-model.md) +> **源码**:[`router/router.go`](../../router/router.go)、[`frontend/src/api/client.ts`](../../frontend/src/api/client.ts)、[`frontend/src/api/types.ts`](../../frontend/src/api/types.ts)、[`middleware/auth.go`](../../middleware/auth.go) + +不要求 OpenAPI YAML;以下表格 + JSON 形状即为合约。新站可加 `/v1` 前缀,但**字段名建议保持**以便对照迁移。 + +--- + +## 1. 通用约定 + +| 项 | 约定 | +|----|------| +| Base | 同源;前端 `credentials: 'same-origin'` | +| 成功 | HTTP 2xx + JSON body | +| 失败 | 非 2xx + `{ "error": "人类可读中文或英文消息" }` | +| 鉴权 | Cookie `jiang13_token`(HttpOnly);部分也接受 Authorization Bearer(以实现为准) | +| 内容类型 | JSON 默认;部分写接口用 `multipart/form-data`(FormData) | +| OptionalAuth | 有 cookie 则解析用户,无则游客继续 | +| RequireAuth | 必须登录且未禁言 | +| RequireAdmin | 必须 `role=admin` | + +### 分页形态差异 + +| 场景 | 典型字段 | +|------|----------| +| 前台帖列表 | `posts`, `total`, `page`, `size`, `has_more` | +| 后台多数列表 | `total`, `page`, `total_pages` + 实体数组 | +| 私信会话消息 | `before` 游标式 | + +--- + +## 2. 基础设施 / SEO / 静态 + +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | `/health` | 无 | `{ "status": "ok" }`(DB ping 失败则非 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`](../../service/oidc.go) / [`handler/oidc.go`](../../handler/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: username, password, nickname, email, 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": "

...

" + } +} +``` + +### 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` | GiteaSyncConfig | +| POST | `/settings/gitea/sync` | 手动同步 | +| 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`](../../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`](../../middleware/auth.go)。 diff --git a/docs/rebuild-spec/05-business-rules.md b/docs/rebuild-spec/05-business-rules.md new file mode 100644 index 0000000..283e5f2 --- /dev/null +++ b/docs/rebuild-spec/05-business-rules.md @@ -0,0 +1,228 @@ +# 05 · 业务规则与状态机 + +> **读者**:实现领域逻辑的 AI(最易「看起来像但算错」) +> **前置**:[03-data-model.md](03-data-model.md)、[04-api.md](04-api.md) +> **源码**:[`service/`](../../service/)、[`model/models.go`](../../model/models.go) + +--- + +## 1. 注册与引导 + +源:[`handler/handlers.go`](../../handler/handlers.go) `APIRegisterConfig`、[`service/auth.go`](../../service/auth.go) + +| 规则 | 细节 | +|------|------| +| 首用户 = 管理员 | `UserCount() == 0` 时注册的用户 `role=admin` | +| 开放注册 | `register_open = (userCount == 0) \|\| mailReady` | +| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时首用户仍可无码注册 | +| 后续用户 | 邮件未配置则注册关闭,直到管理员配好 SMTP | + +密码: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`](../../service/post.go) `CanViewPost`、[`service/comment.go`](../../service/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`](../../service/post.go)、[`service/comment.go`](../../service/comment.go)、[`service/badge.go`](../../service/badge.go) `AddExp`。 + +等级门槛见 [03-data-model.md](03-data-model.md) §4。管理员设 level 时应同步 Exp 到门槛值。 + +--- + +## 5. 内容门控(红action) + +源:[`service/content.go`](../../service/content.go)、[`handler/api.go`](../../handler/api.go) `APIPostDetail`、[`service/unlock.go`](../../service/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`](../../service/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. 敏感词与限流 + +- 敏感词文件:`data/filter_words.txt`;发帖/评/私信等路径过滤 +- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings + +--- + +## 12. 徽章自动授予 + +定期或触发时检查 `BadgeDef`(kind=auto):tenure_days / likes_received / creator_income 达阈值则写入 `user_badges`。限定徽章仅管理员发放。 + +--- + +## 13. 置顶排序语义 + +| 标记 | 首页全部 Feed | 板块 Feed | +|------|---------------|-----------| +| `pinned` | 抬升 | 抬升 | +| `board_pinned` | **不**抬升 | 抬升 | +| `featured` | 标记展示,不一定改变排序 | 同左 | + +具体 SQL/排序实现见 [`service/post.go`](../../service/post.go) ListItems。 diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md new file mode 100644 index 0000000..3f86133 --- /dev/null +++ b/docs/rebuild-spec/06-pages-ux.md @@ -0,0 +1,215 @@ +# 06 · 页面、交互与信息架构 + +> **读者**:实现前台 / 后台 UI 的 AI +> **前置**:[02-features.md](02-features.md) +> **源码**:[`frontend/src/App.tsx`](../../frontend/src/App.tsx)、[`frontend/src/pages/`](../../frontend/src/pages/)、[`frontend/src/components/`](../../frontend/src/components/)、[`frontend/src/layouts/`](../../frontend/src/layouts/) + +视觉可重设;**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。 + +--- + +## 1. 路由表 + +### 1.1 认证(无 MainLayout 壳或独立简洁壳) + +| 路径 | 页面 | 说明 | +|------|------|------| +| `/login` | LoginPage | | +| `/register` | RegisterPage | 读 register/config;可能关闭 | +| `/forgot-password` | ForgotPasswordPage | 依赖邮件 | + +### 1.2 前台(MainLayout) + +| 路径 | 页面 | +|------|------| +| `/` | HomePage(全部 Feed) | +| `/board/:id` | HomePage(板块 Feed;id 可带伪静态后缀) | +| `/post/:id` | PostDetailPage | +| `/compose` | ComposePage 发帖 | +| `/post/:id/edit` | ComposePage 编辑 | +| `/profile` | ProfilePage(需登录) | +| `/user/:id` | UserProfilePage | +| `/favorites` | FavoritesPage | +| `/projects` | ProjectsPage(Gitea 码桶) | +| `/links` | LinksPage | +| `/messages` | MessagesPage | +| `/page/:slug` | SitePageView | +| `*` | NotFoundPage | + +重定向:`/boards` → `/admin/boards`。 + +### 1.3 后台(AdminLayout,需管理员) + +| 路径 | 页面 | +|------|------| +| `/admin` → `/admin/dashboard` | 仪表盘 | +| `/admin/boards` | 板块管理 | +| `/admin/pages` | 单页列表 | +| `/admin/pages/new`、`/admin/pages/:id/edit` | 单页编辑 | +| `/admin/links` | 友链与申请 | +| `/admin/posts` | 帖子审核/运营 | +| `/admin/comments` | 评论 | +| `/admin/reports` | 举报 | +| `/admin/users` | 用户 | +| `/admin/badges` | 徽章定义 | +| `/admin/media` | 媒体 | +| `/admin/settings` | 系统设置(多 Tab) | + +--- + +## 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**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题)+ 虚拟列表项(标题、作者、板块徽章、标签、回复数、最后回复、置顶/精华标记)。列表样式随 `feed_list_style`。 + +**帖子详情**:标题区操作(赞、藏、举报、编辑、管理操作)→ 特殊组件(投票卡 / 悬赏条 / 抽奖卡)→ 正文 `PostContent`(门控块 UI)→ 文章目录 → 作者卡片 → 修订入口 → 评论线程 + 评论框。 + +### 2.3 右栏 RightPanel + +- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口) +- 热门帖 +- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 settings) + +### 2.4 移动端 + +- 侧栏抽屉化;顶栏搜索/发帖/登录触手可及 +- `PullToRefresh` 下拉刷新 +- 触控友好列表行高 + +### 2.5 主题 + +- 浅色 / 暗色;`localStorage` 记忆;可跟随系统 + +--- + +## 3. 发帖页 Compose + +组件:`ComposeHeader`、`ComposeContextBar`(帖类型)、`ComposeSpecialFields`、`ComposeDocument` / `ArticleEditor`。 + +### 3.1 帖类型切换 + +| 类型 | 附加 UI | +|------|---------| +| 讨论 | 无 | +| 问答 | 无额外字段(解决状态在详情) | +| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 | +| 悬赏 | 积分输入(显示余额) | +| 抽奖 | 中奖人数 1–20 | + +### 3.2 编辑器能力(应对齐) + +源:[`ArticleEditor.tsx`](../../frontend/src/components/ArticleEditor.tsx) 与 `editor/` 扩展 + +- 标题 h2–h6(无 h1,避免与帖标题冲突) +- 粗体/斜体/删除线等基础标记 +- 链接对话框 +- 代码块(语言、选项对话框) +- 表格插入/编辑 +- 图片上传 + 图片组布局 + 浮动/清除浮动 +- 表情 / 贴纸选择器(多套:bilibili/douyin/tieba/weibo 等静态资源) +- **登录可见** / **回复可见** / **积分可见**(价格 1–9999)节点 +- 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`](../../frontend/src/utils/markdownContent.ts)) +- Tab 缩进 + +未保存离开:`UnsavedChangesDialog`。 + +--- + +## 4. 帖子详情关键交互 + +| 模块 | 行为 | +|------|------| +| 门控块 | 锁定态显示长度/价格;解锁按钮调 API 后替换 inner HTML | +| 投票卡 | 选选项提交;显示百分比;作者可结束 | +| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | +| 抽奖卡 | 显示参与人数;开奖;中奖名单 | +| 评论 | 楼层列表、回复、引用、私密开关、@ 用户搜索、点赞、编辑、举报 | +| 修订 | 面板列出历史,可选对比 | +| 图片 | Lightbox 查看 | + +--- + +## 5. 消息页 + +- 左:会话列表(系统会话单独) +- 右:消息时间线;发送框 +- 顶:未读角标(全局导航也可显示) +- 通知筛选(kind) + +--- + +## 6. 个人中心 / 公开主页 + +- 资料编辑、头像裁剪、密码 +- 积分钱包面板(流水、签到状态) +- 徽章与等级徽记展示 +- 公开页:签名、徽章、统计、最近帖(按现实现) + +--- + +## 7. 友链页 + +- 展示已通过/品牌友链 +- 「申请友链」对话框:名称、URL、Logo 上传、是否上首页、回链页 URL +- 我的申请状态列表 + +--- + +## 8. 管理后台操作流(按页) + +| 页 | 关键操作 | +|----|----------| +| Dashboard | 看计数与待办;点进对应列表 | +| Boards | 拖拽或数字排序;图标/色板选择;增删改 | +| Pages | 列表发布开关;进编辑器写正文;nav/footer 勾选 | +| Links | 品牌友链 CRUD;申请队列通过/拒绝/复检;回链检测开关;nav/footer/aside 开关 | +| Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 | +| Comments | 审核;修订查看;回收站 | +| Reports | 处理动作四选一 | +| Users | 搜索;禁言;认证;设等级;调积分;授徽章 | +| Badges | 定义自动/限定徽章 | +| Media | 分类浏览;批量删 | +| Settings | Tab:论坛限制、侧栏组件、伪静态、邮件、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、用户一套空壳」双轨,除非过渡期兼容。 diff --git a/docs/rebuild-spec/07-config-ops.md b/docs/rebuild-spec/07-config-ops.md new file mode 100644 index 0000000..7878f25 --- /dev/null +++ b/docs/rebuild-spec/07-config-ops.md @@ -0,0 +1,150 @@ +# 07 · 配置、运维与 SEO + +> **读者**:部署与运维、以及实现配置层的 AI +> **前置**:[README.md](README.md) +> **源码**:[`app.ini.example`](../../app.ini.example)、[`config/`](../../config/)、[`README.md`](../../README.md)、[`handler/seo.go`](../../handler/seo.go)、[`handler/seo_bot.go`](../../handler/seo_bot.go)、[`embed_static/`](../../embed_static/) + +运维形态可改;下列描述**现网**行为,便于迁移数据与对齐环境变量语义。 + +--- + +## 1. 进程配置优先级 + +**命令行显式参数 > 环境变量 > `app.ini` > 内置默认** + +| CLI | 环境变量 | INI | 默认 | 说明 | +|-----|----------|-----|------|------| +| `--port` | `JIANG13_HTTP_PORT` | `[server] HTTP_PORT` | 3000 | 监听端口 | +| `--data` | `JIANG13_DATA` | `[paths] DATA` | `data` | 数据目录 | +| `--jwt-secret` | `JIANG13_JWT_SECRET` | `[security] JWT_SECRET` | 自动生成 | JWT 密钥 | +| `--config` | `JIANG13_CONFIG` | | `{work}/app.ini` | 配置文件路径 | +| `--work-path` | `JIANG13_WORK_PATH` | | 可执行文件目录 | 工作目录 | +| `--service` | | | | install/uninstall/start/stop/restart/status | + +`app.ini` 示例见 [`app.ini.example`](../../app.ini.example)。业务配置(邮件、OIDC、Gitea、存储、品牌等)在 **DB `forum_settings`**,管理后台热更新,不必写进 ini。 + +--- + +## 2. 数据目录结构 + +```text +data/ +├── jiang13.db # SQLite 主库 +├── jiang13.log # 运行日志 +├── filter_words.txt # 敏感词 +├── .jwt_secret # 自动生成的 JWT 密钥(勿提交仓库) +├── uploads/ +│ ├── avatars/ +│ ├── posts/ +│ └── site/ # 品牌资源等 +└── jiang13_backup_*.db # 后台导出备份 +``` + +开发时后端常与 `dist/data` 共用,避免 dev 与产物数据分裂(见根 README)。 + +--- + +## 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`。 + +容器常用环境变量与上表 `JIANG13_*` 一致。旧镜像权限问题:数据目录属主 uid 1000。 + +--- + +## 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 字段应对齐**: + +| 字段 | 来源 | +|------|------| +| `` | 站点 DocumentTitle 或帖标题 | +| meta description | 站点简介优先,否则标语;帖文则摘要 | +| meta keywords | 站点 keywords | +| canonical | 绝对 URL | +| og:type / site_name / locale / title / description / url / image | | +| twitter:card / title / description / image | | +| JSON-LD | 结构化数据(站点或 Article) | +| robots | 个别页可 noindex(以实现为准) | + +### 现网额外机制(可废弃) + +| 机制 | 说明 | +|------|------| +| SPA 壳注入 | [`embed_static`](../../embed_static/) 注入 title / branding JSON,**无帖文 DOM** | +| 爬虫 HTML | User-Agent 命中时 [`seo_bot.go`](../../handler/seo_bot.go) 返回简易 HTML | +| robots.txt / sitemap.xml | 动态生成 | + +重构验收:用普通浏览器「查看网页源代码」应能看到帖文正文,而不仅是空 div + script。 + +--- + +## 6. 伪静态 Permalink + +设置:`permalink_enabled`、`permalink_ext`(默认 `html`)。 + +规范路径示例: + +- `/post/123.html` +- `/user/1.html` +- `/board/2.html` +- `/page/about.html` + +路由应同时接受无后缀与有后缀形式。解析逻辑见 [`service/permalink.go`](../../service/permalink.go)。 + +--- + +## 7. 安全相关运维注意 + +| 项 | 说明 | +|----|------| +| JWT 密钥 | 生产必须固定且保密;勿提交 `.jwt_secret` | +| Cookie | `jiang13_token` HttpOnly;生产应 Secure + 合适 SameSite | +| 上传 | 类型/大小限制(头像 MB、帖图策略) | +| 敏感词 | 后台可改;影响发帖评论私信等 | +| OAuth 密钥 | 仅存 bcrypt 哈希;创建时明文只回显一次 | +| 备份 | 含用户哈希与私信,下载需管理员权限、传输加密 | + +--- + +## 8. 健康检查 + +`GET /health` → JSON `status`。Docker / 负载均衡探活依赖此接口;实现应在 DB 不可用时返回非 200。 + +--- + +## 9. 从旧站迁数据建议 + +1. 导出 / 复制 `jiang13.db`(或 dump 到新库并映射表) +2. 复制 `uploads/` 与 `filter_words.txt` +3. 迁移 `forum_settings` 键值(或后台重新配置) +4. 会话:旧 JWT 密钥兼容一阶段,或强制全员重登 +5. OIDC 客户端:`oauth_clients` 表 + 重新下发密钥(若无法迁移哈希) + +表语义以 [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)。 diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md new file mode 100644 index 0000000..2fb8743 --- /dev/null +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -0,0 +1,58 @@ +# 08 · Gitea 式 SSR 架构(开发约定) + +> **读者**:在本仓库 `rebuild/gitea-ssr` 分支上开发的 AI / 开发者 +> **前置**:[README.md](README.md) +> **对照上游**:[go-gitea/gitea](https://github.com/go-gitea/gitea) + +--- + +## 目标栈(已确认) + +| 层 | 选择 | +|----|------| +| 公开页渲染 | Go `html/template` **真 SSR**(完整 HTML,含帖文/列表 DOM) | +| 渐进增强 | `web_src/` 少量 CSS/JS,构建后嵌入 | +| 发布 | 单二进制 + `go:embed` | +| 业务语义 | 仍以本目录 `01`–`07` 为准 | +| 不做 | React/Next 公开页 SPA;用户与爬虫双轨 HTML | + +--- + +## 开发分支与对照 + +| 分支 | 用途 | +|------|------| +| `main` | 现网 **React SPA** 对照,勿在此做破坏性 SSR 替换 | +| `rebuild/gitea-ssr` | **唯一** Gitea 式重构开发分支 | + +对照运行: + +```bash +git checkout main # 旧 SPA +# 或 +git worktree add ../jiang13-spa main +``` + +--- + +## 目录职责(演进中) + +```text +routers/web/ # 返回 HTML 的页面路由 +templates/ # Go 模板源文件(嵌入) +web_src/ # CSS/JS 源码 +public/assets/ # web_src 构建产物(嵌入,URL 前缀 `/ssr-assets/`) +modules/ # 横切(模板渲染等) +docs/rebuild-spec/ # 产品规格 +.cursor/rules/ # AI 开发规则 +``` + +现有 `model/`、`service/`、`handler/`(JSON API)可先复用,公开页出口改为模板。 + +--- + +## 渲染原则 + +1. 用户访问已迁移路径时,「查看源代码」须可见内容 DOM,而非空壳。 +2. JSON `/api` 留给交互增强与后台;**不得**作为公开页首屏唯一数据来源。 +3. 模板默认 HTML 转义;可信 HTML(已消毒正文)用明确的安全管道,禁止随意 `| safe`。 diff --git a/docs/rebuild-spec/README.md b/docs/rebuild-spec/README.md new file mode 100644 index 0000000..2d3eccd --- /dev/null +++ b/docs/rebuild-spec/README.md @@ -0,0 +1,137 @@ +# 姜十三论坛 · 重构规格文档包 + +> **读者**:准备用新栈(建议真 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) + +> **实现栈(重构分支)**: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 分支与目录约定 | + +单次 context 不够时:先投喂 `README` + `01` + `02`;实现某模块时再追加对应 `03`–`06` 章节。 + +--- + +## 当前产品是什么 + +**姜十三论坛(Jiang13 Forum)** 面向小圈子 / 团队 / 同好社群的轻量论坛。 + +当前实现技术栈(**可抛弃,仅作对照**): + +| 层 | 技术 | +|----|------| +| 后端 | Go · Gin · GORM · SQLite | +| 前端 | React 18 SPA · TipTap · Tailwind · TanStack Virtual | +| 发布 | Vite 构建 → `go:embed` 打进单二进制 | +| 认证 | bcrypt + JWT Cookie(`jiang13_token`) | + +演示站: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 | 生产入口 [`embed_static`](../../embed_static/) 只注入 title / branding / Open Graph,**不渲染帖文 DOM** | 刷新先出壳再灌数据,体验不如 SSR | +| 爬虫双轨 | [`handler/seo_bot.go`](../../handler/seo_bot.go) 对爬虫返回独立 HTML | 用户与爬虫看到的不是同一套渲染路径 | +| 无正式 migration | Schema 靠 GORM `AutoMigrate`([`model/db.go`](../../model/db.go)) | 升级靠「加字段」,难做破坏性迁移与审计 | +| Cookie JWT | 无 session 表,密钥在 `data/.jwt_secret` | 可保留语义,实现可换成更好的会话方案 | + +**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(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` 免审)/ 管理员;首个注册用户为管理员 + +### 建议兼容(降低迁移成本) + +- [04-api.md](04-api.md) 的 JSON 字段命名与路径形状(可做版本前缀,但旧字段名便于对照) +- Cookie 名 `jiang13_token` 或提供清晰的会话迁移方案 +- 数据目录语义:`jiang13.db`、`uploads/`、`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 | 当前登录凭证,存 Cookie | +| Feed | 信息流 | 首页 / 板块帖列表 | +| 门控 | Content gate | 登录可见 / 回复可见 / 积分可见区块 | +| 伪静态 | Permalink | 如 `/post/123.html` 的可选后缀 | + +--- + +## 源码速查(核对规格时) + +| 主题 | 路径 | +|------|------| +| 路由总表 | [`router/router.go`](../../router/router.go) | +| GORM 模型 | [`model/models.go`](../../model/models.go) | +| AutoMigrate | [`model/db.go`](../../model/db.go) | +| 论坛设置键 | [`service/settings.go`](../../service/settings.go) | +| 前端 API 客户端 | [`frontend/src/api/client.ts`](../../frontend/src/api/client.ts) | +| 前端类型 | [`frontend/src/api/types.ts`](../../frontend/src/api/types.ts) | +| 页面路由 | [`frontend/src/App.tsx`](../../frontend/src/App.tsx) | +| 产品介绍 | [`docs/introduction.md`](../introduction.md)、[`README.md`](../../README.md) | + +--- + +## 文档包完成标准 + +另一 AI 仅阅读本目录、**不打开业务源码**,应能: + +1. 列出全部用户可见功能与后台能力 +2. 画出核心表 ER 并理解枚举 +3. 实现或 mock 与现网兼容的 API 形状 +4. 复现审核 / 积分 / 门控 / 特殊帖规则 +5. 搭出等价的页面信息架构与关键交互 + +若规格与代码冲突:**以代码为准**,并应回写修正本目录文档。 diff --git a/embed_static/embed.go b/embed_static/embed.go index c41c1d7..a2c9ff7 100644 --- a/embed_static/embed.go +++ b/embed_static/embed.go @@ -68,9 +68,12 @@ func ServeSPANoIndex(c *gin.Context) { }) } -// IsSPARoute 判断是否应由 SPA 处理 +// IsSPARoute 判断是否应由 SPA 处理(已迁移的 SSR 路径返回 false) func IsSPARoute(path string) bool { - if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" { + if path == "/" || path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" { + return false + } + if strings.HasPrefix(path, "/board/") { return false } if strings.HasPrefix(path, "/api") || @@ -78,6 +81,7 @@ func IsSPARoute(path string) bool { strings.HasPrefix(path, "/uploads") || strings.HasPrefix(path, "/media") || strings.HasPrefix(path, "/assets") || + strings.HasPrefix(path, "/ssr-assets") || strings.HasPrefix(path, "/stickers") || strings.HasPrefix(path, "/oauth") || strings.HasPrefix(path, "/.well-known") { diff --git a/modules/webrender/render.go b/modules/webrender/render.go new file mode 100644 index 0000000..890e44d --- /dev/null +++ b/modules/webrender/render.go @@ -0,0 +1,71 @@ +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{ + "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 + }, + } +} + +// Load 解析全部模板(进程内一次) +func Load() (*template.Template, error) { + loadOnce.Do(func() { + tpl, loadErr = template.New("root").Funcs(funcMap()).ParseFS(apptemplates.FS, "*.tmpl") + }) + 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) +} diff --git a/public/assets/site.css b/public/assets/site.css new file mode 100644 index 0000000..1cecad3 --- /dev/null +++ b/public/assets/site.css @@ -0,0 +1,163 @@ +/* 姜十三论坛 SSR 骨架样式(web_src → public/assets) */ +:root { + --j13-bg: #f6f4ef; + --j13-surface: #fffdf8; + --j13-text: #1c1917; + --j13-muted: #78716c; + --j13-accent: #0f766e; + --j13-border: #e7e5e4; + --j13-radius: 8px; + --j13-font: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { box-sizing: border-box; } +body.j13-body { + margin: 0; + font-family: var(--j13-font); + color: var(--j13-text); + background: var(--j13-bg); + line-height: 1.5; + min-height: 100vh; +} + +.j13-header { + background: var(--j13-surface); + border-bottom: 1px solid var(--j13-border); + position: sticky; + top: 0; + z-index: 10; +} +.j13-header__inner { + max-width: 1100px; + margin: 0 auto; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} +.j13-brand { + display: flex; + align-items: center; + gap: 0.6rem; + text-decoration: none; + color: inherit; +} +.j13-brand__mark { + width: 2rem; + height: 2rem; + border-radius: var(--j13-radius); + background: var(--j13-accent); + color: #fff; + display: grid; + place-items: center; + font-weight: 700; +} +.j13-brand__text { display: flex; flex-direction: column; } +.j13-brand__text strong { font-size: 1rem; } +.j13-brand__slogan { font-size: 0.75rem; color: var(--j13-muted); } +.j13-nav { display: flex; gap: 0.85rem; flex-wrap: wrap; } +.j13-nav a { color: var(--j13-accent); text-decoration: none; font-size: 0.9rem; } +.j13-nav a:hover { text-decoration: underline; } + +.j13-layout { + max-width: 1100px; + margin: 0 auto; + padding: 1rem; + display: grid; + grid-template-columns: 200px 1fr; + gap: 1.25rem; +} +@media (max-width: 800px) { + .j13-layout { grid-template-columns: 1fr; } +} + +.j13-aside { + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.j13-aside__link { + padding: 0.45rem 0.65rem; + border-radius: var(--j13-radius); + color: var(--j13-text); + text-decoration: none; + font-size: 0.9rem; +} +.j13-aside__link:hover, +.j13-aside__link.is-active { + background: var(--j13-surface); + color: var(--j13-accent); +} + +.j13-main { + background: var(--j13-surface); + border: 1px solid var(--j13-border); + border-radius: calc(var(--j13-radius) + 2px); + padding: 1rem 1.1rem 1.25rem; + min-height: 40vh; +} + +.j13-feed__header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; +} +.j13-feed__title { margin: 0; font-size: 1.25rem; } +.j13-sort { display: flex; gap: 0.75rem; font-size: 0.875rem; } +.j13-sort a { color: var(--j13-muted); text-decoration: none; } +.j13-sort a.is-active, +.j13-sort a:hover { color: var(--j13-accent); font-weight: 600; } + +.j13-post-list { list-style: none; margin: 0; padding: 0; } +.j13-post-item { + padding: 0.85rem 0; + border-top: 1px solid var(--j13-border); +} +.j13-post-item:first-child { border-top: 0; } +.j13-post-item__title { + color: var(--j13-text); + text-decoration: none; + font-weight: 600; + font-size: 1.05rem; +} +.j13-post-item__title:hover { color: var(--j13-accent); } +.j13-post-item__meta { + margin-top: 0.35rem; + font-size: 0.8rem; + color: var(--j13-muted); + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} +.j13-tag { + background: #ecfdf5; + color: var(--j13-accent); + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.75rem; +} +.j13-tag--pin { background: #fef3c7; color: #92400e; } +.j13-tag--feat { background: #ede9fe; color: #5b21b6; } + +.j13-empty { color: var(--j13-muted); } +.j13-pager { + margin-top: 1.25rem; + display: flex; + gap: 1rem; + align-items: center; + font-size: 0.9rem; +} +.j13-pager a { color: var(--j13-accent); } + +.j13-footer { + max-width: 1100px; + margin: 0 auto; + padding: 1.5rem 1rem 2rem; + color: var(--j13-muted); + font-size: 0.8rem; +} diff --git a/public/assets/site.js b/public/assets/site.js new file mode 100644 index 0000000..56e0267 --- /dev/null +++ b/public/assets/site.js @@ -0,0 +1,2 @@ +// 姜十三论坛 SSR 渐进增强入口(骨架阶段仅占位) +document.documentElement.dataset.j13Ssr = "1"; diff --git a/public/embed.go b/public/embed.go new file mode 100644 index 0000000..50e87ca --- /dev/null +++ b/public/embed.go @@ -0,0 +1,8 @@ +package public + +import "embed" + +// Assets 为 web_src 构建产物(site.css / site.js) +// +//go:embed assets/* +var Assets embed.FS diff --git a/router/router.go b/router/router.go index 3270bc1..e939d9a 100644 --- a/router/router.go +++ b/router/router.go @@ -3,6 +3,7 @@ package router import ( "encoding/json" "fmt" + "io/fs" "net/http" "os" "path/filepath" @@ -11,6 +12,8 @@ import ( "git.iioio.com/freefire/jiang13-forum/embed_static" "git.iioio.com/freefire/jiang13-forum/handler" "git.iioio.com/freefire/jiang13-forum/middleware" + webpublic "git.iioio.com/freefire/jiang13-forum/public" + webpages "git.iioio.com/freefire/jiang13-forum/routers/web" "git.iioio.com/freefire/jiang13-forum/service" "github.com/gin-gonic/gin" ) @@ -21,14 +24,22 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.Use(gin.Recovery()) r.Use(gin.Logger()) - // dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供 - // 用户应访问 5173 端口,Vite 通过 proxy 将 /api 等请求转发到本服务(:3000) + // SSR 静态资源(独立前缀,避免与 SPA /assets/* 冲突) + 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) { + c.Header("Cache-Control", "public, max-age=86400") + ssrFiles.ServeHTTP(c.Writer, c.Request) + }) + } + + // 生产:内嵌 React SPA(未迁移路径仍走 SPA);dev:Vite 可对照旧前台 if !cfg.DevMode { if err := embed_static.SetupEmbed(r); err != nil { return nil, err } } else { - fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API,前端请访问 http://localhost:5173\n") + fmt.Fprintf(os.Stderr, "[dev] SSR 页面请访问 http://localhost:3000 ;旧 SPA 对照可用 Vite :5173\n") } filter := service.NewSensitiveFilter() @@ -97,6 +108,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { } authMW := middleware.NewAuthMiddleware(authSvc) + // Gitea 式 SSR 公开页(优先于 SPA) + webpages.Register(r, webpages.Deps{ + Settings: settingsSvc, + Board: boardSvc, + Post: postSvc, + }, authMW) + // 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突 r.GET("/media/thumb/*filepath", h.ServeImageThumb) r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads")) @@ -289,17 +307,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { } } - // React SPA 入口 - // dev 模式:前端由 Vite(:5173) 提供,非 API 请求返回开发提示 - // 生产模式:注入 SEO meta / JSON-LD / 预渲染摘要 + // 未迁移路径:生产仍回落 React SPA;/ 与 /board/:id 已由 routers/web 接管 if cfg.DevMode { r.NoRoute(func(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{ - "error": "dev 模式下前端由 Vite 提供,请访问 http://localhost:5173", + "error": "页面未找到。SSR 首页请打开 / ;旧 SPA 请用 Vite http://localhost:5173", }) }) } else { - r.GET("/", h.ServePublicSPA) r.NoRoute(func(c *gin.Context) { if embed_static.IsSPARoute(c.Request.URL.Path) { h.ServePublicSPA(c) diff --git a/routers/web/home.go b/routers/web/home.go new file mode 100644 index 0000000..8abd064 --- /dev/null +++ b/routers/web/home.go @@ -0,0 +1,219 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + "time" + + "git.iioio.com/freefire/jiang13-forum/middleware" + "git.iioio.com/freefire/jiang13-forum/model" + "git.iioio.com/freefire/jiang13-forum/modules/webrender" + "git.iioio.com/freefire/jiang13-forum/service" + "github.com/gin-gonic/gin" +) + +// Deps 页面路由依赖(复用现有 service,避免 Phase 1 大搬家) +type Deps struct { + Settings *service.ForumSettingsService + Board *service.BoardService + Post *service.PostService +} + +// BoardView 侧栏板块 +type BoardView struct { + ID uint + Name string +} + +// PostView 列表项 +type PostView struct { + ID uint + Title string + AuthorName string + BoardName string + Pinned bool + Featured bool + CommentCount int + CreatedLabel string +} + +// HomePageData 首页 / 板块 Feed +type HomePageData struct { + Title string + Description string + SiteName string + Slogan string + LogoMark string + LoggedIn bool + IsAdmin bool + ViewerName string + Boards []BoardView + ActiveBoard uint + BoardName string + Sort string + Posts []PostView + Page int + PrevPage int + NextPage int + HasPrev bool + HasMore bool +} + +// Register 注册已迁移的 SSR 页面(优先于 SPA) +func Register(r *gin.Engine, deps Deps, authMW *middleware.AuthMiddleware) { + g := r.Group("/", authMW.OptionalAuth()) + g.GET("/", deps.Home) + g.GET("/board/:id", deps.Home) +} + +// Home SSR 首页与板块列表 +func (d Deps) Home(c *gin.Context) { + brand := d.Settings.SiteBranding() + sort := c.DefaultQuery("sort", "latest") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + size := d.Settings.PageSizeDefault() + + var boardID uint + var boardName string + if idStr := c.Param("id"); idStr != "" { + // 兼容伪静态后缀 123.html + idStr = strings.TrimSuffix(idStr, "."+d.Settings.Permalink().Ext) + idStr = strings.TrimSuffix(idStr, ".html") + idStr = strings.TrimSuffix(idStr, ".htm") + if n, err := strconv.ParseUint(idStr, 10, 64); err == nil { + boardID = uint(n) + } + } + + boards, _ := d.Board.List() + boardViews := make([]BoardView, 0, len(boards)) + for _, b := range boards { + boardViews = append(boardViews, BoardView{ID: b.ID, Name: b.Name}) + if b.ID == boardID { + boardName = b.Name + } + } + + var uid uint + if v, ok := c.Get(middleware.CtxUserID); ok { + uid, _ = v.(uint) + } + isAdmin := false + if v, ok := c.Get(middleware.CtxRole); ok { + switch r := v.(type) { + case model.Role: + isAdmin = r == model.RoleAdmin + case string: + isAdmin = r == string(model.RoleAdmin) + } + } + username, _ := c.Get(middleware.CtxUsername) + + q := service.PostListQuery{ + BoardID: boardID, + Page: page, + Size: size, + Sort: sort, + ViewerID: uid, + ViewerIsAdmin: isAdmin, + } + items, total, err := d.Post.ListItems(q) + if err != nil { + c.String(http.StatusInternalServerError, "加载帖子失败") + return + } + + posts := make([]PostView, 0, len(items)) + for _, it := range items { + author := strings.TrimSpace(it.User.Nickname) + if author == "" { + author = it.User.Username + } + bname := "" + if it.Board.ID > 0 { + bname = it.Board.Name + } + posts = append(posts, PostView{ + ID: it.ID, + Title: it.Title, + AuthorName: author, + BoardName: bname, + Pinned: it.Pinned, + Featured: it.Featured, + CommentCount: it.CommentCount, + CreatedLabel: formatTime(it.CreatedAt), + }) + } + + title := brand.DocumentTitle() + if boardName != "" { + title = boardName + " · " + brand.Name + } + + data := HomePageData{ + Title: title, + Description: brand.MetaDescription(), + SiteName: brand.Name, + Slogan: brand.Slogan, + LogoMark: firstRuneOr(brand.LogoMark, "姜"), + LoggedIn: uid > 0, + IsAdmin: isAdmin, + ViewerName: fmtViewer(username), + Boards: boardViews, + ActiveBoard: boardID, + BoardName: boardName, + Sort: normalizeSort(sort), + Posts: posts, + Page: page, + PrevPage: page - 1, + NextPage: page + 1, + HasPrev: page > 1, + HasMore: int64(page*size) < total, + } + + c.Header("Content-Type", "text/html; charset=utf-8") + c.Status(http.StatusOK) + if err := webrender.Execute(c.Writer, "home", data); err != nil { + c.String(http.StatusInternalServerError, "模板渲染失败: %v", err) + } +} + +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") +} + +func firstRuneOr(s, fallback string) string { + s = strings.TrimSpace(s) + if s == "" { + return fallback + } + for _, r := range s { + return string(r) + } + return fallback +} + +func fmtViewer(v any) string { + if v == nil { + return "我的" + } + s, _ := v.(string) + s = strings.TrimSpace(s) + if s == "" { + return "我的" + } + return s +} diff --git a/templates/base.tmpl b/templates/base.tmpl new file mode 100644 index 0000000..c0a64c3 --- /dev/null +++ b/templates/base.tmpl @@ -0,0 +1,51 @@ +{{define "base"}} +<!DOCTYPE html> +<html lang="zh-CN"> +<head> + <meta charset="UTF-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <title>{{.Title}} + {{if .Description}}{{end}} + + + +
+
+ + {{.LogoMark}} + + {{.SiteName}} + {{if .Slogan}}{{.Slogan}}{{end}} + + + +
+
+
+ +
+ {{template "content" .}} +
+
+
+

{{.SiteName}} · Gitea 式 SSR 骨架

+
+ + + +{{end}} diff --git a/templates/embed.go b/templates/embed.go new file mode 100644 index 0000000..ce11628 --- /dev/null +++ b/templates/embed.go @@ -0,0 +1,6 @@ +package templates + +import "embed" + +//go:embed *.tmpl +var FS embed.FS diff --git a/templates/home.tmpl b/templates/home.tmpl new file mode 100644 index 0000000..99d7abd --- /dev/null +++ b/templates/home.tmpl @@ -0,0 +1,42 @@ +{{define "home"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+
+

{{if .BoardName}}{{.BoardName}}{{else}}全部帖子{{end}}

+ +
+ + {{if not .Posts}} +

暂无帖子。登录后可以发第一帖。

+ {{else}} +
    + {{range .Posts}} +
  • + {{.Title}} +
    + {{if .Pinned}}置顶{{end}} + {{if .Featured}}精华{{end}} + {{if .BoardName}}{{.BoardName}}{{end}} + {{.AuthorName}} + {{.CreatedLabel}} + {{.CommentCount}} 回复 +
    +
  • + {{end}} +
+ {{end}} + + {{if or .HasPrev .HasMore}} + + {{end}} +
+{{end}} diff --git a/web_src/build.mjs b/web_src/build.mjs new file mode 100644 index 0000000..a6b0edb --- /dev/null +++ b/web_src/build.mjs @@ -0,0 +1,14 @@ +import { cpSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(fileURLToPath(import.meta.url)); +const outDir = join(root, "..", "public", "assets"); + +if (existsSync(outDir)) { + rmSync(outDir, { recursive: true, force: true }); +} +mkdirSync(outDir, { recursive: true }); +cpSync(join(root, "css", "site.css"), join(outDir, "site.css")); +cpSync(join(root, "js", "site.js"), join(outDir, "site.js")); +console.log("[web_src] built -> public/assets/"); diff --git a/web_src/css/site.css b/web_src/css/site.css new file mode 100644 index 0000000..1cecad3 --- /dev/null +++ b/web_src/css/site.css @@ -0,0 +1,163 @@ +/* 姜十三论坛 SSR 骨架样式(web_src → public/assets) */ +:root { + --j13-bg: #f6f4ef; + --j13-surface: #fffdf8; + --j13-text: #1c1917; + --j13-muted: #78716c; + --j13-accent: #0f766e; + --j13-border: #e7e5e4; + --j13-radius: 8px; + --j13-font: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { box-sizing: border-box; } +body.j13-body { + margin: 0; + font-family: var(--j13-font); + color: var(--j13-text); + background: var(--j13-bg); + line-height: 1.5; + min-height: 100vh; +} + +.j13-header { + background: var(--j13-surface); + border-bottom: 1px solid var(--j13-border); + position: sticky; + top: 0; + z-index: 10; +} +.j13-header__inner { + max-width: 1100px; + margin: 0 auto; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} +.j13-brand { + display: flex; + align-items: center; + gap: 0.6rem; + text-decoration: none; + color: inherit; +} +.j13-brand__mark { + width: 2rem; + height: 2rem; + border-radius: var(--j13-radius); + background: var(--j13-accent); + color: #fff; + display: grid; + place-items: center; + font-weight: 700; +} +.j13-brand__text { display: flex; flex-direction: column; } +.j13-brand__text strong { font-size: 1rem; } +.j13-brand__slogan { font-size: 0.75rem; color: var(--j13-muted); } +.j13-nav { display: flex; gap: 0.85rem; flex-wrap: wrap; } +.j13-nav a { color: var(--j13-accent); text-decoration: none; font-size: 0.9rem; } +.j13-nav a:hover { text-decoration: underline; } + +.j13-layout { + max-width: 1100px; + margin: 0 auto; + padding: 1rem; + display: grid; + grid-template-columns: 200px 1fr; + gap: 1.25rem; +} +@media (max-width: 800px) { + .j13-layout { grid-template-columns: 1fr; } +} + +.j13-aside { + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.j13-aside__link { + padding: 0.45rem 0.65rem; + border-radius: var(--j13-radius); + color: var(--j13-text); + text-decoration: none; + font-size: 0.9rem; +} +.j13-aside__link:hover, +.j13-aside__link.is-active { + background: var(--j13-surface); + color: var(--j13-accent); +} + +.j13-main { + background: var(--j13-surface); + border: 1px solid var(--j13-border); + border-radius: calc(var(--j13-radius) + 2px); + padding: 1rem 1.1rem 1.25rem; + min-height: 40vh; +} + +.j13-feed__header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; +} +.j13-feed__title { margin: 0; font-size: 1.25rem; } +.j13-sort { display: flex; gap: 0.75rem; font-size: 0.875rem; } +.j13-sort a { color: var(--j13-muted); text-decoration: none; } +.j13-sort a.is-active, +.j13-sort a:hover { color: var(--j13-accent); font-weight: 600; } + +.j13-post-list { list-style: none; margin: 0; padding: 0; } +.j13-post-item { + padding: 0.85rem 0; + border-top: 1px solid var(--j13-border); +} +.j13-post-item:first-child { border-top: 0; } +.j13-post-item__title { + color: var(--j13-text); + text-decoration: none; + font-weight: 600; + font-size: 1.05rem; +} +.j13-post-item__title:hover { color: var(--j13-accent); } +.j13-post-item__meta { + margin-top: 0.35rem; + font-size: 0.8rem; + color: var(--j13-muted); + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} +.j13-tag { + background: #ecfdf5; + color: var(--j13-accent); + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.75rem; +} +.j13-tag--pin { background: #fef3c7; color: #92400e; } +.j13-tag--feat { background: #ede9fe; color: #5b21b6; } + +.j13-empty { color: var(--j13-muted); } +.j13-pager { + margin-top: 1.25rem; + display: flex; + gap: 1rem; + align-items: center; + font-size: 0.9rem; +} +.j13-pager a { color: var(--j13-accent); } + +.j13-footer { + max-width: 1100px; + margin: 0 auto; + padding: 1.5rem 1rem 2rem; + color: var(--j13-muted); + font-size: 0.8rem; +} diff --git a/web_src/js/site.js b/web_src/js/site.js new file mode 100644 index 0000000..56e0267 --- /dev/null +++ b/web_src/js/site.js @@ -0,0 +1,2 @@ +// 姜十三论坛 SSR 渐进增强入口(骨架阶段仅占位) +document.documentElement.dataset.j13Ssr = "1"; diff --git a/web_src/package.json b/web_src/package.json new file mode 100644 index 0000000..41f1484 --- /dev/null +++ b/web_src/package.json @@ -0,0 +1,8 @@ +{ + "name": "jiang13-web-src", + "private": true, + "description": "Gitea-style progressive assets for Jiang13 SSR", + "scripts": { + "build": "node build.mjs" + } +}