docs+feat: 重构规格与 Gitea 式 SSR 骨架(首页)

在 rebuild/gitea-ssr 落地产品规格、Cursor 规则,以及 Go 模板 SSR 首页/板块列表;未迁移路径仍回落 SPA,便于对照 main。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 03:11:25 +08:00
parent 4dc3bbb13f
commit 1414c71dec
28 changed files with 2891 additions and 24 deletions

View File

@@ -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` 为准。

View 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` |
规格与代码冲突时:以**当前分支代码**行为为准,并应回写修正规格。

View File

@@ -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 传入。

View File

@@ -12,39 +12,42 @@ REGISTRY_IMAGE := hangzhang714128/jiang13-forum
GO := go GO := go
GOFLAGS := -trimpath GOFLAGS := -trimpath
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build 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 all: build
web-src-build:
cd web_src && npm run build
frontend-build: frontend-build:
cd frontend && npm install && npm run build cd frontend && npm install && npm run build
## 编译当前平台二进制(纯 Go SQLite无需 CGO ## 编译当前平台二进制(纯 Go SQLite无需 CGO
build: frontend-build build: web-src-build frontend-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG) CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)" @echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
## Windows amd64先打包前端再 embed ## Windows amd64先打包前端再 embed
build-windows: frontend-build build-windows: web-src-build frontend-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe" @echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
## Linux amd64先打包前端再 embed ## Linux amd64先打包前端再 embed
build-linux: frontend-build build-linux: web-src-build frontend-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64" @echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
## macOS arm64 (Apple Silicon)(先打包前端再 embed ## macOS arm64 (Apple Silicon)(先打包前端再 embed
build-darwin: frontend-build build-darwin: web-src-build frontend-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG) CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64" @echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
## 跨平台全量编译frontend-build 只跑一次) ## 跨平台全量编译(web-src + frontend 只跑一次)
build-all: frontend-build build-all: web-src-build frontend-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@@ -89,13 +92,14 @@ compose-down:
help: help:
@echo "姜十三论坛编译命令:" @echo "姜十三论坛编译命令:"
@echo " make web-src-build - 构建 SSR 渐进资源 (web_src)"
@echo " make build - 编译当前平台" @echo " make build - 编译当前平台"
@echo " make build-windows - 编译 Windows" @echo " make build-windows - 编译 Windows"
@echo " make build-linux - 编译 Linux" @echo " make build-linux - 编译 Linux"
@echo " make build-darwin - 编译 macOS" @echo " make build-darwin - 编译 macOS"
@echo " make build-all - 编译全部平台" @echo " make build-all - 编译全部平台"
@echo " make run - 启动后端(:3000" @echo " make run - 启动后端 SSR:3000"
@echo " make dev - 前端热更新开发:5173 + :3000" @echo " make dev - 后端 + 旧 SPA Vite 对照:5173 + :3000"
@echo " make docker - 构建 Docker 镜像" @echo " make docker - 构建 Docker 镜像"
@echo " make compose-up - Docker Compose 启动" @echo " make compose-up - Docker Compose 启动"
@echo " make compose-down - Docker Compose 停止" @echo " make compose-down - Docker Compose 停止"

View File

@@ -3,7 +3,7 @@
# .\build.ps1 -Target build-windows # .\build.ps1 -Target build-windows
param( 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' [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 { function Build-Frontend {
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan Write-Host '[frontend] npm run build...' -ForegroundColor Cyan
Push-Location frontend Push-Location frontend
@@ -70,12 +81,13 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
switch ($Target) { switch ($Target) {
'help' { 'help' {
Write-Host '.\build.ps1 build current platform' Write-Host '.\build.ps1 build current platform'
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-windows'
Write-Host '.\build.ps1 -Target build-linux' Write-Host '.\build.ps1 -Target build-linux'
Write-Host '.\build.ps1 -Target build-all' Write-Host '.\build.ps1 -Target build-all'
Write-Host '.\build.ps1 -Target run backend only (port 3000)' Write-Host '.\build.ps1 -Target run backend (SSR on :3000)'
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)' Write-Host '.\build.ps1 -Target dev backend + Vite SPA对照'
Write-Host '.\build.ps1 -Target tidy' Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean' Write-Host '.\build.ps1 -Target clean'
Write-Host '.\build.ps1 -Target docker build Docker image' Write-Host '.\build.ps1 -Target docker build Docker image'
@@ -85,6 +97,7 @@ switch ($Target) {
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.' Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
} }
'frontend' { Build-Frontend } 'frontend' { Build-Frontend }
'web-src' { Build-WebSrc }
'tidy' { go mod tidy } 'tidy' { go mod tidy }
'clean' { 'clean' {
if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir } if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
@@ -117,23 +130,28 @@ switch ($Target) {
} }
} }
'build' { 'build' {
Build-WebSrc
Build-Frontend Build-Frontend
Build-Go -OutFile $AppName Build-Go -OutFile $AppName
} }
'build-windows' { 'build-windows' {
Build-WebSrc
Build-Frontend Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
} }
'build-linux' { 'build-linux' {
Write-Host '[build-linux] will 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-Frontend
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64' Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
} }
'build-darwin' { 'build-darwin' {
Build-WebSrc
Build-Frontend Build-Frontend
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64' Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
} }
'build-all' { 'build-all' {
Build-WebSrc
Build-Frontend Build-Frontend
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64' Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'

View 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 运维(拆开看待)
| 维度 | 当前实现 | 重构时 |
|------|----------|--------|
| **产品** | 论坛功能全集(见模块地图) | **必须对齐**功能与规则 |
| **运维** | 单二进制 + 内嵌 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 帖子
- 富文本正文HTMLTipTap 产出)+ 可选 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**:不可消费经验 → 等级 Lv110
- **CreatorIncomeTotal**:创作分成累计(徽章指标)
- 徽章:自动(门槛)+ 限定(管理员发放)
### 4.7 私信与通知
统一走 `private_messages` 表,用 `kind` 区分用户私信与系统事件。
### 4.8 友链与站点页
- 管理员维护品牌友链 JSON用户申请审核可选回链检测
- 自定义单页关于、版规等slug、发布、导航/页脚展示
### 4.9 Gitea 开源码桶
后台配置后定时同步公开仓库到 `gitea_repos`,前台 `/projects` 展示。
### 4.10 OIDC Provider
本站作为 IdPDiscovery / 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)。

View File

@@ -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` 投票210 选项;单选/多选;可选截止时间;投票;作者可结束
- [ ] `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 → 等级 Lv110
- [ ] 自动徽章(注册天数 / 获赞 / 创作分成)
- [ ] 限定徽章:管理员定义与授予/撤销
- [ ] 管理员调整积分、设定等级
---
## 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 大幅优化
- 帖子搜索增强(组合筛选更强)
若新站一并实现,可作为加分项,不阻塞「功能对等」验收。

View File

@@ -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
<members-only>...</members-only>
<reply-only>...</reply-only>
<points-only data-cost="10">...</points-only>
```
积分解锁 `block_key` = `sha256(innerHTML)[:16]`hex见 [`service/unlock.go`](../../service/unlock.go)。

376
docs/rebuild-spec/04-api.md Normal file
View File

@@ -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": "<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, beforepeerId=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)。

View File

@@ -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
| 规则 | 细节 |
|------|------|
| 选项数 | 210单选项 ≤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
| 规则 | 细节 |
|------|------|
| 中奖人数 | 120 |
| 参与者 | 已发布评论且 **非楼主**;按用户去重(保留最早评论) |
| 开奖 | 作者/管理员;人数不足报错;随机抽取;写 `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=autotenure_days / likes_received / creator_income 达阈值则写入 `user_badges`。限定徽章仅管理员发放。
---
## 13. 置顶排序语义
| 标记 | 首页全部 Feed | 板块 Feed |
|------|---------------|-----------|
| `pinned` | 抬升 | 抬升 |
| `board_pinned` | **不**抬升 | 抬升 |
| `featured` | 标记展示,不一定改变排序 | 同左 |
具体 SQL/排序实现见 [`service/post.go`](../../service/post.go) ListItems。

View File

@@ -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板块 Feedid 可带伪静态后缀) |
| `/post/:id` | PostDetailPage |
| `/compose` | ComposePage 发帖 |
| `/post/:id/edit` | ComposePage 编辑 |
| `/profile` | ProfilePage需登录 |
| `/user/:id` | UserProfilePage |
| `/favorites` | FavoritesPage |
| `/projects` | ProjectsPageGitea 码桶) |
| `/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 |
|------|---------|
| 讨论 | 无 |
| 问答 | 无额外字段(解决状态在详情) |
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
| 悬赏 | 积分输入(显示余额) |
| 抽奖 | 中奖人数 120 |
### 3.2 编辑器能力(应对齐)
源:[`ArticleEditor.tsx`](../../frontend/src/components/ArticleEditor.tsx) 与 `editor/` 扩展
- 标题 h2h6无 h1避免与帖标题冲突
- 粗体/斜体/删除线等基础标记
- 链接对话框
- 代码块(语言、选项对话框)
- 表格插入/编辑
- 图片上传 + 图片组布局 + 浮动/清除浮动
- 表情 / 贴纸选择器多套bilibili/douyin/tieba/weibo 等静态资源)
- **登录可见** / **回复可见** / **积分可见**(价格 19999节点
- 富文本 ↔ 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 细节
- Toastsonner反馈成功/失败
- 路由级 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、用户一套空壳」双轨除非过渡期兼容。

View File

@@ -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 字段应对齐**
| 字段 | 来源 |
|------|------|
| `<title>` | 站点 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)。

View File

@@ -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`

137
docs/rebuild-spec/README.md Normal file
View File

@@ -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` | 可保留语义,实现可换成更好的会话方案 |
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染SSRHTMLSEO 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. 搭出等价的页面信息架构与关键交互
若规格与代码冲突:**以代码为准**,并应回写修正本目录文档。

View File

@@ -68,9 +68,12 @@ func ServeSPANoIndex(c *gin.Context) {
}) })
} }
// IsSPARoute 判断是否应由 SPA 处理 // IsSPARoute 判断是否应由 SPA 处理(已迁移的 SSR 路径返回 false
func IsSPARoute(path string) bool { 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 return false
} }
if strings.HasPrefix(path, "/api") || if strings.HasPrefix(path, "/api") ||
@@ -78,6 +81,7 @@ func IsSPARoute(path string) bool {
strings.HasPrefix(path, "/uploads") || strings.HasPrefix(path, "/uploads") ||
strings.HasPrefix(path, "/media") || strings.HasPrefix(path, "/media") ||
strings.HasPrefix(path, "/assets") || strings.HasPrefix(path, "/assets") ||
strings.HasPrefix(path, "/ssr-assets") ||
strings.HasPrefix(path, "/stickers") || strings.HasPrefix(path, "/stickers") ||
strings.HasPrefix(path, "/oauth") || strings.HasPrefix(path, "/oauth") ||
strings.HasPrefix(path, "/.well-known") { strings.HasPrefix(path, "/.well-known") {

View File

@@ -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)
}

163
public/assets/site.css Normal file
View File

@@ -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;
}

2
public/assets/site.js Normal file
View File

@@ -0,0 +1,2 @@
// 姜十三论坛 SSR 渐进增强入口(骨架阶段仅占位)
document.documentElement.dataset.j13Ssr = "1";

8
public/embed.go Normal file
View File

@@ -0,0 +1,8 @@
package public
import "embed"
// Assets 为 web_src 构建产物site.css / site.js
//
//go:embed assets/*
var Assets embed.FS

View File

@@ -3,6 +3,7 @@ package router
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -11,6 +12,8 @@ import (
"git.iioio.com/freefire/jiang13-forum/embed_static" "git.iioio.com/freefire/jiang13-forum/embed_static"
"git.iioio.com/freefire/jiang13-forum/handler" "git.iioio.com/freefire/jiang13-forum/handler"
"git.iioio.com/freefire/jiang13-forum/middleware" "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" "git.iioio.com/freefire/jiang13-forum/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -21,14 +24,22 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery()) r.Use(gin.Recovery())
r.Use(gin.Logger()) r.Use(gin.Logger())
// dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供 // SSR 静态资源(独立前缀,避免与 SPA /assets/* 冲突)
// 用户应访问 5173 端口Vite 通过 proxy 将 /api 等请求转发到本服务(:3000) 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未迁移路径仍走 SPAdevVite 可对照旧前台
if !cfg.DevMode { if !cfg.DevMode {
if err := embed_static.SetupEmbed(r); err != nil { if err := embed_static.SetupEmbed(r); err != nil {
return nil, err return nil, err
} }
} else { } 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() filter := service.NewSensitiveFilter()
@@ -97,6 +108,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
} }
authMW := middleware.NewAuthMiddleware(authSvc) authMW := middleware.NewAuthMiddleware(authSvc)
// Gitea 式 SSR 公开页(优先于 SPA
webpages.Register(r, webpages.Deps{
Settings: settingsSvc,
Board: boardSvc,
Post: postSvc,
}, authMW)
// 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突 // 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突
r.GET("/media/thumb/*filepath", h.ServeImageThumb) r.GET("/media/thumb/*filepath", h.ServeImageThumb)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads")) r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
@@ -289,17 +307,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
} }
} }
// React SPA 入口 // 未迁移路径:生产仍回落 React SPA/ 与 /board/:id 已由 routers/web 接管
// dev 模式:前端由 Vite(:5173) 提供,非 API 请求返回开发提示
// 生产模式:注入 SEO meta / JSON-LD / 预渲染摘要
if cfg.DevMode { if cfg.DevMode {
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"error": "dev 模式下前端由 Vite 提供,请访问 http://localhost:5173", "error": "页面未找到。SSR 首页请打开 / ;旧 SPA 请用 Vite http://localhost:5173",
}) })
}) })
} else { } else {
r.GET("/", h.ServePublicSPA)
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
if embed_static.IsSPARoute(c.Request.URL.Path) { if embed_static.IsSPARoute(c.Request.URL.Path) {
h.ServePublicSPA(c) h.ServePublicSPA(c)

219
routers/web/home.go Normal file
View File

@@ -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
}

51
templates/base.tmpl Normal file
View File

@@ -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}}</title>
{{if .Description}}<meta name="description" content="{{.Description}}"/>{{end}}
<link rel="stylesheet" href="/ssr-assets/site.css"/>
</head>
<body class="j13-body">
<header class="j13-header">
<div class="j13-header__inner">
<a class="j13-brand" href="/">
<span class="j13-brand__mark">{{.LogoMark}}</span>
<span class="j13-brand__text">
<strong>{{.SiteName}}</strong>
{{if .Slogan}}<span class="j13-brand__slogan">{{.Slogan}}</span>{{end}}
</span>
</a>
<nav class="j13-nav">
{{if .LoggedIn}}
<a href="/compose">发帖</a>
<a href="/messages">消息</a>
<a href="/profile">{{.ViewerName}}</a>
{{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}}
{{else}}
<a href="/login">登录</a>
<a href="/register">注册</a>
{{end}}
</nav>
</div>
</header>
<div class="j13-layout">
<aside class="j13-aside j13-aside--left">
<a class="j13-aside__link{{if eq .ActiveBoard 0}} is-active{{end}}" href="/">全部帖子</a>
{{range .Boards}}
<a class="j13-aside__link{{if eq $.ActiveBoard .ID}} is-active{{end}}" href="/board/{{.ID}}">{{.Name}}</a>
{{end}}
</aside>
<main class="j13-main">
{{template "content" .}}
</main>
</div>
<footer class="j13-footer">
<p>{{.SiteName}} · Gitea 式 SSR 骨架</p>
</footer>
<script src="/ssr-assets/site.js" defer></script>
</body>
</html>
{{end}}

6
templates/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package templates
import "embed"
//go:embed *.tmpl
var FS embed.FS

42
templates/home.tmpl Normal file
View File

@@ -0,0 +1,42 @@
{{define "home"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="j13-feed">
<header class="j13-feed__header">
<h1 class="j13-feed__title">{{if .BoardName}}{{.BoardName}}{{else}}全部帖子{{end}}</h1>
<div class="j13-sort" role="navigation" aria-label="排序">
<a class="{{if eq .Sort "latest"}}is-active{{end}}" href="{{sortURL .ActiveBoard "latest"}}">最新发帖</a>
<a class="{{if eq .Sort "reply"}}is-active{{end}}" href="{{sortURL .ActiveBoard "reply"}}">最新回复</a>
<a class="{{if eq .Sort "hot"}}is-active{{end}}" href="{{sortURL .ActiveBoard "hot"}}">热门讨论</a>
</div>
</header>
{{if not .Posts}}
<p class="j13-empty">暂无帖子。登录后可以发第一帖。</p>
{{else}}
<ul class="j13-post-list">
{{range .Posts}}
<li class="j13-post-item">
<a class="j13-post-item__title" href="/post/{{.ID}}">{{.Title}}</a>
<div class="j13-post-item__meta">
{{if .Pinned}}<span class="j13-tag j13-tag--pin">置顶</span>{{end}}
{{if .Featured}}<span class="j13-tag j13-tag--feat">精华</span>{{end}}
{{if .BoardName}}<span class="j13-tag">{{.BoardName}}</span>{{end}}
<span>{{.AuthorName}}</span>
<span>{{.CreatedLabel}}</span>
<span>{{.CommentCount}} 回复</span>
</div>
</li>
{{end}}
</ul>
{{end}}
{{if or .HasPrev .HasMore}}
<nav class="j13-pager">
{{if .HasPrev}}<a href="{{pageURL .ActiveBoard .Sort .PrevPage}}">上一页</a>{{end}}
<span>第 {{.Page}} 页</span>
{{if .HasMore}}<a href="{{pageURL .ActiveBoard .Sort .NextPage}}">下一页</a>{{end}}
</nav>
{{end}}
</section>
{{end}}

14
web_src/build.mjs Normal file
View File

@@ -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/");

163
web_src/css/site.css Normal file
View File

@@ -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;
}

2
web_src/js/site.js Normal file
View File

@@ -0,0 +1,2 @@
// 姜十三论坛 SSR 渐进增强入口(骨架阶段仅占位)
document.documentElement.dataset.j13Ssr = "1";

8
web_src/package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "jiang13-web-src",
"private": true,
"description": "Gitea-style progressive assets for Jiang13 SSR",
"scripts": {
"build": "node build.mjs"
}
}