Compare commits
36 Commits
rebuild/gi
...
v1.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
| c958dcfd80 | |||
| 84bfbd9a3e | |||
| 500e452c2f | |||
| 1c0f7ede55 | |||
| 2e9c42a34c | |||
| dff60dde6a | |||
| ea9e0058d4 | |||
| d5aa36416c | |||
| 2159280af5 | |||
| bc9a031890 | |||
| 0d81787125 | |||
| 68713c053b | |||
| 94823c0821 | |||
| b608ec85ba | |||
| 6048d71759 | |||
| 37d14a22d5 | |||
| a02d103cfe | |||
| 21c6233fc0 | |||
| 4ca5c17e2e | |||
| cf7a37eccc | |||
| 44eed97fe0 | |||
| 9e134916a4 | |||
| ba60a9b1c4 | |||
| 132ca5c82c | |||
| 185649db7c | |||
| 9d926750b2 | |||
| a91d917fa9 | |||
| 34f3d088ad | |||
| c6ce44f8b4 | |||
| 429956c594 | |||
| 16b6a18096 | |||
| 95c2154087 | |||
| 0abfb32194 | |||
| bcfd306643 | |||
| 335b708b04 | |||
| a83f8bd670 |
@@ -5,8 +5,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
# 编译与构建脚本
|
# 编译与构建脚本
|
||||||
|
|
||||||
本分支(`rebuild/gitea-ssr`):Go 单二进制 + `go:embed` 的 **templates** 与 **public/assets**(由 `web_src` 构建);产物在 `dist/`。
|
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
|
||||||
对照 SPA 仅在 `main` 分支。
|
|
||||||
|
|
||||||
## 运行编译(优先用封装命令,不要猜命令)
|
## 运行编译(优先用封装命令,不要猜命令)
|
||||||
|
|
||||||
@@ -19,16 +18,15 @@ alwaysApply: true
|
|||||||
|
|
||||||
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
||||||
|
|
||||||
常用 target:`build`(默认)、`dev`、`run`、`web-src`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
常用 target:`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
||||||
|
|
||||||
## 修改构建脚本时的约定
|
## 修改构建脚本时的约定
|
||||||
|
|
||||||
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
|
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
|
||||||
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
|
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
|
||||||
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
|
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
|
||||||
4. **构建顺序**:先 `web_src` 内 `npm run build`(产出 `public/assets`),再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
||||||
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
|
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
|
||||||
6. **本分支无 `frontend/`**:勿再添加 SPA 构建步骤;需要对照 UI 时 `git checkout main`。
|
|
||||||
|
|
||||||
## 新增 target 检查清单
|
## 新增 target 检查清单
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Gitea 式 SSR 重构总则(rebuild/gitea-ssr 分支)
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Gitea 式 SSR 重构
|
|
||||||
|
|
||||||
## 分支
|
|
||||||
|
|
||||||
- 功能开发在 **`rebuild/gitea-ssr`**;**不要**把破坏性 SSR 替换推到 `main`。
|
|
||||||
- `main` 保留 React SPA,用作对照(checkout / worktree)。
|
|
||||||
|
|
||||||
## 渲染
|
|
||||||
|
|
||||||
- 公开页(首页、板块、帖详情、用户页等)必须用 **Go `html/template` 服务端渲染完整 HTML**。
|
|
||||||
- 禁止为已迁移公开页恢复 React SPA 空壳;禁止「用户 SPA + 爬虫专用 HTML」双轨作为长期方案。
|
|
||||||
- JSON `/api` 仅用于交互增强与管理后台,**不得**作为公开页首屏唯一数据来源。
|
|
||||||
|
|
||||||
## 架构参照
|
|
||||||
|
|
||||||
对齐 [Gitea](https://github.com/go-gitea/gitea) 职责划分:
|
|
||||||
|
|
||||||
- `routers/web` — HTML 页面路由
|
|
||||||
- `routers/api` — JSON API(原 `handler/`)
|
|
||||||
- `routers/setup.go` — 路由总装
|
|
||||||
- `templates/` — 模板
|
|
||||||
- `web_src/` → `public/assets/` — 渐进增强 CSS/JS
|
|
||||||
- `models/`、`services/` — 数据与业务
|
|
||||||
- `modules/auth`、`modules/webrender`、`modules/seo` — 横切
|
|
||||||
|
|
||||||
本分支**已删除** `frontend/` 与 `embed_static/`;勿再恢复 SPA 生产回落。
|
|
||||||
|
|
||||||
细节见 [`docs/rebuild-spec/08-gitea-ssr-architecture.md`](docs/rebuild-spec/08-gitea-ssr-architecture.md)。
|
|
||||||
|
|
||||||
## 产品规格
|
|
||||||
|
|
||||||
实现功能前按 [`docs/rebuild-spec/README.md`](docs/rebuild-spec/README.md) 阅读顺序核对;业务规则以 `05-business-rules.md` 为准。
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
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` |
|
|
||||||
|
|
||||||
规格与代码冲突时:以**当前分支代码**行为为准,并应回写修正规格。
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
---
|
|
||||||
description: Go HTML 模板约定(SSR)
|
|
||||||
globs: templates/**/*
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 模板约定
|
|
||||||
|
|
||||||
- 目录对齐 Gitea:`templates/base/`、`home/`、`post/`、`shared/`、`status/`、`auth/`,根级 `install.tmpl` / `post-install.tmpl`。
|
|
||||||
- 页面入口用固定 `{{define "home"}}` / `{{define "post"}}` 等显式 `{{template "base/head"}}`…;**禁止** `{{template .Name}}`(标准库不支持动态名)。
|
|
||||||
- **默认转义**;用户 HTML 须先消毒 + 门控后再 `{{safeHTML ...}}`。
|
|
||||||
- 静态资源 `/ssr-assets/...`。
|
|
||||||
- 浏览器写操作走 `routers/web` 表单 POST + CSRF(`webctx`),不依赖 JSON `/api`。
|
|
||||||
- 中文文案可写在模板;站点名等从 PageChrome / settings 传入。
|
|
||||||
@@ -4,17 +4,17 @@
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.cursor
|
.cursor
|
||||||
.trae
|
|
||||||
|
|
||||||
# 运行时数据与本地配置
|
# 运行时数据与本地配置
|
||||||
data/
|
data/
|
||||||
app.ini
|
app.ini
|
||||||
tmp-cookie.txt
|
tmp-cookie.txt
|
||||||
|
|
||||||
# 编译产物与依赖缓存
|
# 编译产物与前端缓存
|
||||||
dist/
|
dist/
|
||||||
web_src/node_modules/
|
frontend/node_modules/
|
||||||
public/assets/
|
frontend/dist/
|
||||||
|
embed_static/static/spa/
|
||||||
node_modules/
|
node_modules/
|
||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
|
|||||||
20
.gitignore
vendored
@@ -4,27 +4,25 @@
|
|||||||
# 本地配置(保留 app.ini.example)
|
# 本地配置(保留 app.ini.example)
|
||||||
/app.ini
|
/app.ini
|
||||||
|
|
||||||
# Node / 构建缓存
|
# 前端依赖与构建缓存
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/web_src/node_modules/
|
/frontend/node_modules/
|
||||||
*.tsbuildinfo
|
/frontend/dist/
|
||||||
|
/embed_static/static/spa/
|
||||||
|
|
||||||
# Go 编译产物(统一进 dist/;勿把二进制扔在仓库根目录)
|
# Go 编译产物
|
||||||
/dist/
|
/dist/
|
||||||
*.exe
|
*.exe
|
||||||
/jiang13
|
|
||||||
/jiang13-*
|
|
||||||
!/cmd/jiang13/
|
|
||||||
|
|
||||||
# 临时文件
|
# 临时文件
|
||||||
tmp-cookie.txt
|
tmp-cookie.txt
|
||||||
*-err.txt
|
|
||||||
*-out.txt
|
|
||||||
|
|
||||||
# 编辑器 / AI 草稿 / OS
|
# 编辑器 / OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.trae/
|
|
||||||
*.swp
|
*.swp
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
|
||||||
|
frontend/tmp-lucide-paths.json
|
||||||
|
|||||||
151
.trae/documents/emoji-effect-adjustment.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# 表情效果调整计划
|
||||||
|
|
||||||
|
## 问题分析
|
||||||
|
|
||||||
|
当前实现存在 5 个问题,根因如下:
|
||||||
|
|
||||||
|
### 1. 插入表情后有选择状态(蓝色高亮)
|
||||||
|
- **根因**: Tiptap `setImage` 插入图片节点后,节点处于 "node-selected" 状态,显示蓝色选中框
|
||||||
|
- **位置**: [CommentEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentEditor.tsx) L155-163
|
||||||
|
|
||||||
|
### 2. 输入框表情太大
|
||||||
|
- **根因**: CSS 选择器 `img[src^="data:image/svg"]` 只匹配旧 SVG data URI,不匹配新的 AVIF URL(`/stickers/tieba/tb_01.avif`),导致表情图片无尺寸约束,以原始大尺寸渲染
|
||||||
|
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5584-5603
|
||||||
|
|
||||||
|
### 3. 插入表情后光标换行
|
||||||
|
- **根因**: `ArticleImage.configure({ inline: false })` 使图片为块级节点,插入后自动换行
|
||||||
|
- **位置**: [CommentEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentEditor.tsx) L92
|
||||||
|
|
||||||
|
### 4. 表情栏目太宽松
|
||||||
|
- **根因**: Grid 仅 6 列,gap 4px,padding 10px
|
||||||
|
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5502-5510
|
||||||
|
|
||||||
|
### 5. 颜文字太小
|
||||||
|
- **根因**: `.sticker-picker-text` 的 `font-size: 8px`,极小
|
||||||
|
- **位置**: [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css) L5606-5616
|
||||||
|
|
||||||
|
## 改动方案
|
||||||
|
|
||||||
|
### 文件 1: CommentEditor.tsx
|
||||||
|
**改动 A — 图片改为内联** (L92):
|
||||||
|
```typescript
|
||||||
|
// 旧
|
||||||
|
ArticleImage.configure({ inline: false, allowBase64: true }),
|
||||||
|
// 新
|
||||||
|
ArticleImage.configure({ inline: true, allowBase64: true }),
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 B — insertSticker 插入后取消选中和换行** (L155-163):
|
||||||
|
```typescript
|
||||||
|
const insertSticker = useCallback((sticker: Sticker) => {
|
||||||
|
if (!editor) return;
|
||||||
|
if (sticker.type === 'text' && sticker.text) {
|
||||||
|
editor.chain().focus().insertContent(sticker.text).run();
|
||||||
|
} else if (sticker.url) {
|
||||||
|
// 插入内联图片 + 尾随零宽空格,确保光标在图片后面而非选中图片
|
||||||
|
editor.chain().focus().insertContent([
|
||||||
|
{ type: 'image', attrs: { src: sticker.url, alt: sticker.name } },
|
||||||
|
{ type: 'text', text: '\u200b' },
|
||||||
|
]).run();
|
||||||
|
}
|
||||||
|
setShowSticker(false);
|
||||||
|
}, [editor]);
|
||||||
|
```
|
||||||
|
> 用 `insertContent` + 零宽空格替代 `setImage`,避免 node-selected 状态,光标自然落在图片后。
|
||||||
|
|
||||||
|
### 文件 2: StickerPicker.tsx
|
||||||
|
**改动 C — 键盘导航列数匹配新网格** (L44):
|
||||||
|
```typescript
|
||||||
|
// 旧
|
||||||
|
const cols = 6;
|
||||||
|
// 新
|
||||||
|
const cols = 8;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件 3: global.css
|
||||||
|
**改动 D — 表情选择器密度提升** (L5502-5510):
|
||||||
|
```css
|
||||||
|
.sticker-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr); /* 6 → 8 */
|
||||||
|
gap: 2px; /* 4px → 2px */
|
||||||
|
padding: 6px; /* 10px → 6px */
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 E — 选择器项缩小** (L5512-5522):
|
||||||
|
```css
|
||||||
|
.sticker-picker-item {
|
||||||
|
padding: 2px; /* 4px → 2px */
|
||||||
|
/* 其余不变 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 F — 颜文字字体放大** (L5606-5616):
|
||||||
|
```css
|
||||||
|
.sticker-picker-text {
|
||||||
|
font-size: 14px; /* 8px → 14px */
|
||||||
|
line-height: 1.4; /* 1.2 → 1.4 */
|
||||||
|
/* 其余不变 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 G — 替换旧 SVG 选择器为通用贴纸选择器** (L5584-5603):
|
||||||
|
|
||||||
|
删除旧的 `img[src^="data:image/svg"]` 选择器,替换为基于 `/stickers/` 路径的选择器:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 编辑器内贴纸 img 尺寸约束 */
|
||||||
|
.comment-editor .article-prosemirror img[src*="/stickers/"],
|
||||||
|
.comment-editor .article-editor-content img[src*="/stickers/"] {
|
||||||
|
display: inline-block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评论正文中的贴纸 img */
|
||||||
|
.floor-body img[src*="/stickers/"],
|
||||||
|
.comment-body img[src*="/stickers/"] {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 0 1px;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动 H — 移动端网格也改为 8 列** (L5619-5624):
|
||||||
|
```css
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sticker-picker { max-height: 240px; }
|
||||||
|
.sticker-picker-grid { grid-template-columns: repeat(6, 1fr); } /* 移动端 6 列 */
|
||||||
|
.sticker-picker-tab { padding: 6px 10px; font-size: 12px; }
|
||||||
|
.comment-editor .article-tool-btn { width: 30px; height: 30px; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> 移动端保持 6 列(屏幕窄),桌面端 8 列。
|
||||||
|
|
||||||
|
## 不改动
|
||||||
|
- `ArticleImageExtension.tsx` — 无需修改,`inline: true` 通过 `configure()` 传入即可
|
||||||
|
- `kaomoji.ts` / `emojiData.ts` / `hot.ts` — 数据层不变
|
||||||
|
- `CommentContent.tsx` — 渲染层不变(CSS 覆盖即可)
|
||||||
|
- PostEditor(帖子编辑器)— 不受影响,仍使用 `inline: false`
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
1. `npm run build` 无报错
|
||||||
|
2. 浏览器验证:
|
||||||
|
- 选择表情后插入无蓝色选中框
|
||||||
|
- 表情在输入框中显示 28px,内联在文字中
|
||||||
|
- 插入表情后光标紧跟表情后方,不换行
|
||||||
|
- 表情选择器 8 列密度更高
|
||||||
|
- 颜文字标签内字体清晰可读
|
||||||
597
.trae/documents/emoji_rich_comment_plan.md
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
# 评论表情包改造 + 富文本评论编辑器计划
|
||||||
|
|
||||||
|
## 一、摘要
|
||||||
|
|
||||||
|
将评论系统从「纯文本 textarea + Unicode Emoji」升级为「Tiptap 富文本编辑器 + 姜十三专属 SVG 贴纸」,实现:
|
||||||
|
1. 去除所有 Unicode Emoji,替换为自定义 SVG 贴纸(懒加载)
|
||||||
|
2. 姜十三专属表情包:萌系吉祥物表情 + 中文网络流行语文字气泡(混合风格)
|
||||||
|
3. 评论复用帖子编辑器核心能力(精简变体),支持代码块、链接、图片、格式化等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、当前状态分析
|
||||||
|
|
||||||
|
### 2.1 评论输入:纯文本 textarea
|
||||||
|
|
||||||
|
[CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx) 使用 `<textarea>` 输入评论,功能包括:
|
||||||
|
- `@` 用户提及(textarea 选区扫描 + API 搜索用户)
|
||||||
|
- Unicode Emoji 面板([EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx) + [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts))
|
||||||
|
- 隐私评论开关
|
||||||
|
- 无富文本格式化能力
|
||||||
|
|
||||||
|
### 2.2 评论渲染:纯文本转义
|
||||||
|
|
||||||
|
[CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx) 通过 [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts) 的 `highlightMentions()` 渲染评论:
|
||||||
|
- `escapeWithBreaks()` 转义 HTML 并将 `\n` 转为 `<br>`
|
||||||
|
- 正则匹配 `@username` 包裹为可点击 `<span class="mention">`
|
||||||
|
- 不支持 HTML/Markdown 渲染
|
||||||
|
|
||||||
|
### 2.3 评论编辑:纯 textarea
|
||||||
|
|
||||||
|
[CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297) 编辑模式使用 `<textarea>` 直接修改文本。
|
||||||
|
|
||||||
|
### 2.4 帖子编辑器:Tiptap 富文本
|
||||||
|
|
||||||
|
[ArticleEditor.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/ArticleEditor.tsx) 是功能完整的 Tiptap 编辑器(900 行),包含:
|
||||||
|
- 富文本模式 + Markdown 模式切换
|
||||||
|
- 工具栏:标题/加粗/斜体/下划线/删除线/分割线/引用/列表/代码块/表格/链接/图片/图组
|
||||||
|
- 门控区块:登录可见/回复可见/积分可见
|
||||||
|
- 全屏模式
|
||||||
|
- `forwardRef` 暴露 `getHTML()` / `isEmpty()` / `focus()`
|
||||||
|
- 使用 `DOMPurify` + `POST_CONTENT_PURIFY_CONFIG` 净化 HTML
|
||||||
|
|
||||||
|
### 2.5 后端评论存储
|
||||||
|
|
||||||
|
- [models.go](file:///c:/Users/freefire/Documents/jiang13-forum/model/models.go#L143-L164): `Comment.Content` 字段类型为 `text`,存储纯文本
|
||||||
|
- [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176): `Create()` 仅做 `TrimSpace` + 敏感词过滤,**无 HTML 净化**
|
||||||
|
- [handlers.go](file:///c:/Users/freefire/Documents/jiang13-forum/handler/handlers.go#L508-L530): `APICreateComment` 从 FormData 取 `content` 字段
|
||||||
|
- [sanitize_html.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/sanitize_html.go): `SanitizePostHTML` 已有 HTML 白名单策略(bluemonday),但**仅用于帖子,未用于评论**
|
||||||
|
|
||||||
|
### 2.6 表情数据
|
||||||
|
|
||||||
|
[emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts) 定义约 160 个 Unicode Emoji 字符,无分类、无搜索。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、修改方案
|
||||||
|
|
||||||
|
### Part A:姜十三专属 SVG 贴纸系统
|
||||||
|
|
||||||
|
#### A1. 贴纸数据定义
|
||||||
|
|
||||||
|
**新建** `frontend/src/data/stickers.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Sticker {
|
||||||
|
id: string; // 唯一ID,如 "j13-happy"
|
||||||
|
name: string; // 名称,如 "开心"
|
||||||
|
category: StickerCategory;
|
||||||
|
aliases?: string[]; // 搜索别名
|
||||||
|
svg: string; // 完整 SVG 字符串(含 viewBox 0 0 64 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StickerCategory = '热门' | '姜十三' | '文字气泡';
|
||||||
|
|
||||||
|
export const STICKER_CATEGORIES: StickerCategory[] = ['热门', '姜十三', '文字气泡'];
|
||||||
|
export const STICKERS: Sticker[] = [ /* ... */ ];
|
||||||
|
```
|
||||||
|
|
||||||
|
**贴纸内容设计(约 40 个):**
|
||||||
|
|
||||||
|
| 分类 | 数量 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| 姜十三 | 16 | 萌系姜色(ginger色 #D4A574)圆脸吉祥物,额头带"13"标记,各种表情:开心/大笑/哭泣/生气/惊讶/思考/点赞/心心眼/睡觉/疑惑/酷/捂脸/送花/鼓掌/加油/拜托 |
|
||||||
|
| 文字气泡 | 16 | 圆角气泡 + 中文网络流行语:666/大佬/同问/给力/沙发/学习了/已赞/佩服/妙啊/牛批/感谢/收藏了/围观/催更/瑞思拜/芜湖 |
|
||||||
|
| 热门 | 8 | 从上述两类中精选最常用的 8 个 |
|
||||||
|
|
||||||
|
**SVG 设计规范:**
|
||||||
|
- `viewBox="0 0 64 64"` 统一尺寸
|
||||||
|
- 所有 fill 使用内联颜色(不依赖 CSS 变量)
|
||||||
|
- 姜十三吉祥物主色系:`#D4A574`(姜色)/ `#FFF3E0`(浅姜)/ `#E8B87C`(深姜)
|
||||||
|
- 文字气泡:`#FF6B6B`(红)/ `#4ECDC4`(青)/ `#FFE66D`(黄)/ `#95E1D3`(绿)四色循环
|
||||||
|
- 线条圆润,stroke-linecap: round
|
||||||
|
|
||||||
|
#### A2. SVG 贴纸渲染组件
|
||||||
|
|
||||||
|
**新建** `frontend/src/components/emoji/StickerSvg.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StickerSvgProps {
|
||||||
|
id: string;
|
||||||
|
size?: number; // 默认 28
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 从 `STICKERS` 查找对应 id,渲染 `dangerouslySetInnerHTML={{ __html: sticker.svg }}`
|
||||||
|
- SVG 已自包含 fill 颜色,无需额外样式
|
||||||
|
|
||||||
|
#### A3. 贴纸选择器(懒加载)
|
||||||
|
|
||||||
|
**新建** `frontend/src/components/emoji/StickerPicker.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StickerPickerProps {
|
||||||
|
onSelect: (stickerId: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI 结构:**
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ [热门] [姜十三] [文字气泡] │ ← 分类 Tab
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ [🙂] [😂] [❤️] [👍] [🎉] ... │ ← SVG 贴纸网格(6列桌面/4列移动端)
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**懒加载策略:**
|
||||||
|
- 贴纸数据按分类拆分为独立 chunk:`data/stickers/j13.ts`、`data/stickers/text.ts`、`data/stickers/hot.ts`
|
||||||
|
- `StickerPicker` 使用 `React.lazy()` + `Suspense` 按需加载当前分类
|
||||||
|
- 切换分类时才加载对应 chunk,首次打开只加载"热门"分类
|
||||||
|
- 每个贴纸 SVG 在组件挂载时渲染(已在数据中内联,无需额外网络请求)
|
||||||
|
|
||||||
|
**交互:**
|
||||||
|
- 分类 Tab 点击切换,带下划线动画
|
||||||
|
- 贴纸 hover: `transform: scale(1.15)`, 0.1s 过渡
|
||||||
|
- 点击贴纸触发 `onSelect(sticker.id)`
|
||||||
|
- 键盘导航:方向键浏览 + Enter 选中
|
||||||
|
|
||||||
|
#### A4. 删除旧 Emoji 系统
|
||||||
|
|
||||||
|
- **删除** [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts)(`EMOJI_LIST` 导出)
|
||||||
|
- **删除** [EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx)
|
||||||
|
- **保留** `.emoji-picker*` CSS 类(避免影响其他可能的引用),新增 `.sticker-picker*` 类
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part B:评论富文本编辑器(精简变体)
|
||||||
|
|
||||||
|
#### B1. 创建 CommentEditor 组件
|
||||||
|
|
||||||
|
**新建** `frontend/src/components/CommentEditor.tsx`
|
||||||
|
|
||||||
|
不直接复用 ArticleEditor(900 行,含全屏/Markdown/门控区块等评论不需要的功能),而是创建独立的精简 Tiptap 编辑器,**复用 ArticleEditor 的扩展组件**。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface CommentEditorHandle {
|
||||||
|
getHTML: () => string;
|
||||||
|
isEmpty: () => boolean;
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用的 Tiptap 扩展(复用现有):**
|
||||||
|
- `StarterKit`(含 heading H2-H4、bold/italic/strike、blockquote、bulletList/orderedList、horizontalRule)
|
||||||
|
- `Underline`(来自 @tiptap/extension-underline,已安装)
|
||||||
|
- `Link`(来自 @tiptap/extension-link,已安装)
|
||||||
|
- `ArticleCodeBlock`(来自 [ArticleCodeBlockExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/ArticleCodeBlockExtension.tsx),复用)
|
||||||
|
- `Placeholder`(来自 @tiptap/extension-placeholder,已安装)
|
||||||
|
- `TabIndent`(来自 [TabIndentExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/TabIndentExtension.tsx),复用)
|
||||||
|
- `ArticleImage`(来自 [ArticleImageExtension.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/editor/ArticleImageExtension.tsx),复用,用于插入图片)
|
||||||
|
|
||||||
|
**不使用的扩展(评论场景不需要):**
|
||||||
|
- ~~TableKit~~(表格)
|
||||||
|
- ~~ImageGroup~~(图组)
|
||||||
|
- ~~MembersOnly / ReplyOnly / PointsOnly~~(门控区块)
|
||||||
|
- ~~ClearFloatParagraph~~(清除浮动段落)
|
||||||
|
- ~~Markdown 模式~~
|
||||||
|
- ~~全屏模式~~
|
||||||
|
|
||||||
|
**工具栏按钮(精简版):**
|
||||||
|
```
|
||||||
|
[H] [B] [I] [U] [S] [引用] [列表] [有序列表] [代码块] [链接] [图片] [贴纸]
|
||||||
|
```
|
||||||
|
|
||||||
|
**贴纸集成:**
|
||||||
|
- 工具栏增加贴纸按钮(Lucide `Sticker` 图标)
|
||||||
|
- 点击弹出 `StickerPicker`
|
||||||
|
- 选中贴纸后,将 SVG 作为 inline `<img>` 插入编辑器:
|
||||||
|
```typescript
|
||||||
|
const sticker = STICKERS.find(s => s.id === id);
|
||||||
|
const dataUri = `data:image/svg+xml,${encodeURIComponent(sticker.svg)}`;
|
||||||
|
editor.chain().focus().setImage({ src: dataUri, alt: sticker.name }).run();
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTML 净化:**
|
||||||
|
- 使用 `DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG)` 与 ArticleEditor 一致
|
||||||
|
- `POST_CONTENT_PURIFY_CONFIG` 来自 [postContent.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/postContent.ts),复用现有配置
|
||||||
|
|
||||||
|
#### B2. 改造 CommentBox
|
||||||
|
|
||||||
|
**修改** [CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx)
|
||||||
|
|
||||||
|
**主要变更:**
|
||||||
|
1. 用 `CommentEditor` 替换 `<textarea>`
|
||||||
|
2. 用 `CommentEditorHandle` ref 替代 `textareaRef`
|
||||||
|
3. `content` 状态存储 HTML 而非纯文本
|
||||||
|
4. 移除 `EmojiPicker` 导入和使用,贴纸功能已集成到 `CommentEditor`
|
||||||
|
5. 移除 `owoRef` 和 `showEmoji` 状态
|
||||||
|
6. `@` 提及功能:暂时保留为文本输入(在编辑器中输入 `@username`,渲染时由 `processCommentHtml` 处理高亮),编辑器内自动补全作为后续增强
|
||||||
|
7. `insertEmoji` 改为 `insertSticker`,调用 `CommentEditor` ref 方法
|
||||||
|
8. 隐私评论开关保留
|
||||||
|
9. 发送时 `content` 为 HTML,直接传给 API
|
||||||
|
|
||||||
|
**改动前:**
|
||||||
|
```tsx
|
||||||
|
<textarea ref={textareaRef} value={content} onChange={handleChange} ... />
|
||||||
|
<button ref={owoRef} onClick={() => setShowEmoji(v => !v)}>OwO</button>
|
||||||
|
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
|
||||||
|
```
|
||||||
|
|
||||||
|
**改动后:**
|
||||||
|
```tsx
|
||||||
|
<CommentEditor ref={editorRef} value={content} onChange={setContent} placeholder="说点什么吧…" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B3. 改造评论编辑模式
|
||||||
|
|
||||||
|
**修改** [CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297)
|
||||||
|
|
||||||
|
编辑模式从 `<textarea>` 改为 `CommentEditor`:
|
||||||
|
```tsx
|
||||||
|
// 改动前
|
||||||
|
<textarea value={editText} onChange={e => setEditText(e.target.value)} rows={3} />
|
||||||
|
|
||||||
|
// 改动后
|
||||||
|
<CommentEditor value={editText} onChange={setEditText} placeholder="编辑评论…" />
|
||||||
|
```
|
||||||
|
|
||||||
|
需要 import `CommentEditor`,并在 `handleSave` 中提交 HTML 内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part C:评论内容渲染
|
||||||
|
|
||||||
|
#### C1. 更新 content.ts 支持富文本
|
||||||
|
|
||||||
|
**修改** [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts)
|
||||||
|
|
||||||
|
当前 `highlightMentions()` 处理纯文本(转义 HTML + 换行 + @高亮)。需要新增 HTML 处理能力。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||||
|
|
||||||
|
/** 判断内容是否为 HTML(包含常见 HTML 标签) */
|
||||||
|
function isHtmlContent(text: string): boolean {
|
||||||
|
return /<(?:p|div|span|br|h[1-6]|ul|ol|li|pre|code|blockquote|a|img|table|strong|em|u|s)\b/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在 HTML 文本节点中高亮 @ 提及(DOM 遍历,避免破坏标签) */
|
||||||
|
function processMentionsInHtml(html: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html;
|
||||||
|
const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT);
|
||||||
|
const textNodes: Text[] = [];
|
||||||
|
let node: Node | null;
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
textNodes.push(node as Text);
|
||||||
|
}
|
||||||
|
for (const textNode of textNodes) {
|
||||||
|
const text = textNode.textContent ?? '';
|
||||||
|
if (!/@[\w\u4e00-\u9fa5_-]/.test(text)) continue;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const parts = text.split(/(@[\w\u4e00-\u9fa5_-]+)/);
|
||||||
|
for (const part of parts) {
|
||||||
|
const m = part.match(/^@([\w\u4e00-\u9fa5_-]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'mention';
|
||||||
|
span.setAttribute('data-name', m[1]);
|
||||||
|
span.setAttribute('role', 'link');
|
||||||
|
span.setAttribute('tabindex', '0');
|
||||||
|
span.textContent = part;
|
||||||
|
frag.appendChild(span);
|
||||||
|
} else if (part) {
|
||||||
|
frag.appendChild(document.createTextNode(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textNode.parentNode?.replaceChild(frag, textNode);
|
||||||
|
}
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染评论内容:HTML 净化 + @提及高亮,兼容旧版纯文本 */
|
||||||
|
export function renderCommentContent(content: string): string {
|
||||||
|
if (isHtmlContent(content)) {
|
||||||
|
// 新版 HTML 评论
|
||||||
|
const sanitized = DOMPurify.sanitize(content, POST_CONTENT_PURIFY_CONFIG);
|
||||||
|
return processMentionsInHtml(sanitized);
|
||||||
|
}
|
||||||
|
// 旧版纯文本评论(向后兼容)
|
||||||
|
return escapeWithBreaks(content).replace(
|
||||||
|
/@([\w\u4e00-\u9fa5_-]+)/g,
|
||||||
|
'<span class="mention" data-name="$1" role="link" tabindex="0">@$1</span>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
保留原 `highlightMentions()` 函数不删除(可能有其他引用),新增 `renderCommentContent()`。
|
||||||
|
|
||||||
|
#### C2. 更新 CommentContent 组件
|
||||||
|
|
||||||
|
**修改** [CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { renderCommentContent } from '../utils/content';
|
||||||
|
|
||||||
|
// 改动前
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightMentions(content) }}
|
||||||
|
|
||||||
|
// 改动后
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderCommentContent(content) }}
|
||||||
|
```
|
||||||
|
|
||||||
|
评论内容中的贴纸 `<img>` 标签会被 `POST_CONTENT_PURIFY_CONFIG` 保留(已允许 `<img>` + `src`),自然渲染为 SVG 图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part D:后端评论 HTML 净化
|
||||||
|
|
||||||
|
#### D1. 评论创建时净化
|
||||||
|
|
||||||
|
**修改** [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
||||||
|
content := SanitizePostHTML(strings.TrimSpace(in.Content)) // 新增 HTML 净化
|
||||||
|
content = s.filter.Filter(content) // 敏感词过滤
|
||||||
|
// ... 其余不变
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### D2. 评论更新时净化
|
||||||
|
|
||||||
|
**修改** [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L354-L376)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
|
||||||
|
// ...
|
||||||
|
content = SanitizePostHTML(strings.TrimSpace(content)) // 新增
|
||||||
|
content = s.filter.Filter(content) // 敏感词过滤
|
||||||
|
// ... 其余不变
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SanitizePostHTML` 已在 [sanitize_html.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/sanitize_html.go) 中定义,使用 bluemonday 白名单策略,允许 Tiptap 产出的 HTML 标签和属性,禁止 `<script>`、`<style>` 等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Part E:CSS 样式
|
||||||
|
|
||||||
|
#### E1. 贴纸选择器样式
|
||||||
|
|
||||||
|
**修改** [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css#L5427-L5462) 区域
|
||||||
|
|
||||||
|
新增样式块(在现有 `.emoji-picker*` 样式之后):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 贴纸选择器 */
|
||||||
|
.sticker-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-picker-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--j13-border-light);
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-picker-tab {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-picker-tab.active {
|
||||||
|
color: var(--j13-green);
|
||||||
|
border-bottom-color: var(--j13-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-picker-item {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.1s, transform 0.1s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-picker-item:hover {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 贴纸加载占位 */
|
||||||
|
.sticker-picker-loading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sticker-picker-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E2. 评论编辑器样式
|
||||||
|
|
||||||
|
新增 `.comment-editor` 相关样式,复用 `.article-editor-bar`、`.article-tool-btn` 等现有类名,仅做覆盖调整:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 评论富文本编辑器 */
|
||||||
|
.comment-editor .article-editor-bar {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-editor .article-tool-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-editor .article-editor-content {
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-editor .article-editor-status {
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评论内贴纸图片 */
|
||||||
|
.comment-sticker {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、文件变更清单
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 文件路径 | 用途 |
|
||||||
|
|----------|------|
|
||||||
|
| `frontend/src/data/stickers/hot.ts` | 热门贴纸数据(懒加载 chunk) |
|
||||||
|
| `frontend/src/data/stickers/j13.ts` | 姜十三吉祥物贴纸数据(懒加载 chunk) |
|
||||||
|
| `frontend/src/data/stickers/text.ts` | 文字气泡贴纸数据(懒加载 chunk) |
|
||||||
|
| `frontend/src/data/stickers/index.ts` | 贴纸类型定义 + 统一导出 |
|
||||||
|
| `frontend/src/components/emoji/StickerSvg.tsx` | SVG 贴纸渲染组件 |
|
||||||
|
| `frontend/src/components/emoji/StickerPicker.tsx` | 贴纸选择器(懒加载) |
|
||||||
|
| `frontend/src/components/CommentEditor.tsx` | 评论富文本编辑器(精简 Tiptap) |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| [CommentBox.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentBox.tsx) | textarea → CommentEditor,移除 EmojiPicker,content 改为 HTML |
|
||||||
|
| [CommentThreadList.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentThreadList.tsx#L276-L297) | 编辑模式 textarea → CommentEditor |
|
||||||
|
| [CommentContent.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/CommentContent.tsx) | 使用 `renderCommentContent()` 替代 `highlightMentions()` |
|
||||||
|
| [content.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/content.ts) | 新增 `renderCommentContent()` + `processMentionsInHtml()` |
|
||||||
|
| [global.css](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/styles/global.css#L5427-L5462) | 新增 `.sticker-picker*` 和 `.comment-editor*` 样式 |
|
||||||
|
| [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L172-L176) | Create() 增加 `SanitizePostHTML` |
|
||||||
|
| [comment.go](file:///c:/Users/freefire/Documents/jiang13-forum/service/comment.go#L354-L376) | Update() 增加 `SanitizePostHTML` |
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
|
||||||
|
| 文件 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| [emojis.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/utils/emojis.ts) | Unicode Emoji 数据不再需要 |
|
||||||
|
| [EmojiPicker.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/components/EmojiPicker.tsx) | 被 StickerPicker 替代 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、假设与决策
|
||||||
|
|
||||||
|
### 5.1 编辑器决策
|
||||||
|
|
||||||
|
- **不直接复用 ArticleEditor 组件**,而是创建独立的 CommentEditor,原因:
|
||||||
|
- ArticleEditor 900 行,含全屏/Markdown/门控区块等评论不需要的功能
|
||||||
|
- 复用 ArticleEditor 的 Tiptap 扩展组件(ArticleCodeBlock、ArticleImage、TabIndent 等),避免代码重复
|
||||||
|
- CommentEditor 更轻量,每个评论实例加载更快
|
||||||
|
- **@ 提及**:暂不在编辑器中实现自动补全(需要 Tiptap Mention 扩展或自定义扩展),用户手动输入 `@username`,渲染时由 `processMentionsInHtml` 高亮。后续可增强为 Tiptap Mention 扩展。
|
||||||
|
|
||||||
|
### 5.2 贴纸存储格式
|
||||||
|
|
||||||
|
- 贴纸以 `data:image/svg+xml` URI 内联在 `<img src="...">` 中
|
||||||
|
- 每个 SVG 约 300-800 字节,单条评论即使 10 个贴纸也仅 ~8KB
|
||||||
|
- 后端 `SanitizePostHTML` 白名单已允许 `<img src="...">`,无需额外修改
|
||||||
|
- 旧评论(纯文本)不受影响,`renderCommentContent` 自动检测并兼容
|
||||||
|
|
||||||
|
### 5.3 懒加载策略
|
||||||
|
|
||||||
|
- 贴纸数据按分类拆分为 3 个 chunk(hot/j13/text),`React.lazy()` 动态导入
|
||||||
|
- 首次打开选择器只加载"热门"chunk(8 个贴纸),切换分类时才加载其他 chunk
|
||||||
|
- 每个 chunk 约 5-10KB,加载延迟 < 100ms
|
||||||
|
|
||||||
|
### 5.4 向后兼容
|
||||||
|
|
||||||
|
- 旧评论为纯文本,新 `renderCommentContent()` 通过 `isHtmlContent()` 检测自动走旧路径(escapeWithBreaks + 正则高亮)
|
||||||
|
- 新评论为 HTML,走 DOMPurify 净化 + DOM 遍历高亮路径
|
||||||
|
- 后端 `SanitizePostHTML` 对纯文本也安全(bluemonday 会保留纯文本,仅过滤危险标签)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、实施顺序
|
||||||
|
|
||||||
|
1. **Part A**:贴纸数据 + 组件(stickers/*.ts → StickerSvg → StickerPicker)
|
||||||
|
2. **Part D**:后端评论 HTML 净化(comment.go Create/Update 加 SanitizePostHTML)
|
||||||
|
3. **Part B**:CommentEditor 组件 → CommentBox 集成 → CommentThreadList 编辑模式
|
||||||
|
4. **Part C**:content.ts 渲染函数 → CommentContent 更新
|
||||||
|
5. **Part E**:CSS 样式
|
||||||
|
6. 删除旧 EmojiPicker / emojis.ts
|
||||||
|
7. 验证测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、验证步骤
|
||||||
|
|
||||||
|
1. **贴纸系统验证**:
|
||||||
|
- 点击贴纸按钮,选择器弹出,3 个分类 Tab 可切换
|
||||||
|
- 切换分类时加载对应贴纸(Network 面板确认懒加载)
|
||||||
|
- 点击贴纸后编辑器中出现对应 SVG 图
|
||||||
|
|
||||||
|
2. **评论编辑器验证**:
|
||||||
|
- 评论框支持加粗/斜体/下划线/删除线/标题/引用/列表/代码块/链接/图片/贴纸
|
||||||
|
- 代码块支持语法高亮和折叠
|
||||||
|
- 图片可上传并插入
|
||||||
|
- 无全屏/Markdown/表格/门控区块按钮
|
||||||
|
|
||||||
|
3. **评论渲染验证**:
|
||||||
|
- 新评论 HTML 正确渲染(格式化、代码块、图片、贴纸)
|
||||||
|
- `@username` 在 HTML 评论中正确高亮为可点击链接
|
||||||
|
- 旧评论(纯文本)仍正常渲染
|
||||||
|
- 贴纸图片在评论中正确显示
|
||||||
|
|
||||||
|
4. **后端验证**:
|
||||||
|
- 发送含 `<script>alert(1)</script>` 的评论,后端净化后移除 script 标签
|
||||||
|
- 发送正常 HTML 评论,后端存储完整 HTML
|
||||||
|
- 旧纯文本评论正常存储和渲染
|
||||||
|
|
||||||
|
5. **编辑模式验证**:
|
||||||
|
- 编辑已有评论时,CommentEditor 正确加载 HTML 内容
|
||||||
|
- 保存后评论更新正确
|
||||||
138
.trae/documents/fix-comment-scroll-position-plan.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 修复主页右侧栏评论点击定位问题
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
用户在主页右侧栏点击评论时,如果该评论不是帖子的第一个评论(`floor > 0`),页面跳转后没有定位到该评论上。
|
||||||
|
|
||||||
|
## 根因分析
|
||||||
|
|
||||||
|
### 问题 1:`jumpToFloor` 使用 `scrollIntoView` 而非 `pageRef.scrollTo`
|
||||||
|
|
||||||
|
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L235-L242)
|
||||||
|
|
||||||
|
当前 `jumpToFloor` 函数使用 `el.scrollIntoView()` 来滚动:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// ...
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
但是页面使用了自定义滚动容器 `pageRef`(类名为 `.post-detail-page`),且 `useGlobalWheelScroll` 钩子拦截了滚轮事件,将其转换为对 `pageRef.scrollTop` 的直接操作。这导致原生 `scrollIntoView` 可能无法正确触发滚动。
|
||||||
|
|
||||||
|
对比 `jumpToHeadingHash` 函数(第 244-262 行),它正确地使用了 `pageRef.scrollTo()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
|
||||||
|
// ...
|
||||||
|
const root = pageRef.current;
|
||||||
|
if (root) {
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||||
|
root.scrollTo({ top: Math.max(0, top), behavior });
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 2:`#floor-N` 定位缺少重试机制
|
||||||
|
|
||||||
|
**文件**: [PostDetailPage.tsx](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/pages/PostDetailPage.tsx#L264-L273)
|
||||||
|
|
||||||
|
当前 `#floor-N` 定位只有一次 80ms 延迟,没有重试机制:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !post) return;
|
||||||
|
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||||
|
if (!m) return;
|
||||||
|
const floor = Number(m[1]);
|
||||||
|
if (!floor) return;
|
||||||
|
const t = window.setTimeout(() => jumpToFloor(floor), 80);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||||
|
```
|
||||||
|
|
||||||
|
对比 `#heading-N` 定位(第 275-299 行),它有完善的重试机制(最多 30 次,每次 50ms)。
|
||||||
|
|
||||||
|
## 修改方案
|
||||||
|
|
||||||
|
### 修改 1:修改 `jumpToFloor` 函数使用 `pageRef.scrollTo`
|
||||||
|
|
||||||
|
将 `jumpToFloor` 函数改为使用与 `jumpToHeadingHash` 相同的滚动方式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
|
if (!el) return false;
|
||||||
|
|
||||||
|
const root = pageRef.current;
|
||||||
|
if (root) {
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||||
|
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighlightFloor(floor);
|
||||||
|
clearTimeout(highlightTimer.current);
|
||||||
|
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改 2:为 `#floor-N` 定位添加重试机制
|
||||||
|
|
||||||
|
将 `#floor-N` 定位的 `useEffect` 改为类似 `#heading-N` 的重试机制:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !post) return;
|
||||||
|
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||||
|
if (!m) return;
|
||||||
|
const floor = Number(m[1]);
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
|
const tryJump = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (jumpToFloor(floor)) return;
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts < 30) {
|
||||||
|
timer = window.setTimeout(tryJump, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = window.setTimeout(tryJump, 0);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `frontend/src/pages/PostDetailPage.tsx`:修改 `jumpToFloor` 函数和 `#floor-N` 定位的 `useEffect`
|
||||||
|
|
||||||
|
## 风险评估
|
||||||
|
|
||||||
|
- **低风险**:修改仅限于评论定位逻辑,不影响其他功能
|
||||||
|
- **需测试**:需要验证页面加载时评论定位是否正常工作,以及页面内部导航时是否正常
|
||||||
|
|
||||||
|
## 测试步骤
|
||||||
|
|
||||||
|
1. 进入主页,点击右侧栏的一个非第一条评论
|
||||||
|
2. 验证页面跳转到帖子详情后,是否正确定位到目标评论
|
||||||
|
3. 验证评论高亮效果是否正常显示
|
||||||
|
4. 测试第一条评论(floor=0)的定位是否仍然正常工作
|
||||||
|
5. 在帖子详情页直接加载带 hash 的 URL(如 `/post/123#floor-5`),验证定位效果
|
||||||
117
.trae/documents/fix-post-scroll-persist-plan.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 修复:再次进入帖子时滚动位置异常保留
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
浏览帖子滑到下方 → 返回主页 → 再次进入同一帖子,滚动条停在之前阅读的位置,而非顶部。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
项目未设置 `history.scrollRestoration`(默认 `'auto'`)。浏览器在导航时会对**内部滚动容器**(`overflow: auto` 的 `.page-wrap`)执行滚动位置恢复。虽然 PostDetailPage 重新挂载后 `.page-wrap` 是新 DOM 元素,浏览器仍会在 paint 前将其 `scrollTop` 恢复到上次记录的值。
|
||||||
|
|
||||||
|
项目没有 ScrollToTop 机制来覆盖此行为。
|
||||||
|
|
||||||
|
## 与刷新恢复的冲突
|
||||||
|
|
||||||
|
之前实现的 `useScrollRestoration`(MainLayout mount-only)负责刷新场景的位置恢复。本修复必须与之共存:
|
||||||
|
|
||||||
|
| 场景 | 期望行为 | 处理者 |
|
||||||
|
|---|---|---|
|
||||||
|
| 刷新帖子页 | 恢复到上次位置 | `useScrollRestoration`(sessionStorage + rAF) |
|
||||||
|
| SPA 导航进入帖子 | 从顶部开始 | 本修复(重置 scrollTop=0) |
|
||||||
|
| `/post/123` → `/post/456` | 从顶部开始 | 本修复(重置 scrollTop=0) |
|
||||||
|
|
||||||
|
**区分依据**:`pagehide` 时 `saveScrollPositions` 将当前 URL 的滚动记录存入 sessionStorage。刷新后该记录存在;SPA 导航进入时该记录不存在(从未为此 URL 保存过,或已被 `restoreScrollPositions` 消费清除)。因此 PostDetailPage 渲染 `.page-wrap` 时检查 sessionStorage 是否有当前 URL 的记录即可区分两种场景。
|
||||||
|
|
||||||
|
**时序保证**:React 的 effect 执行顺序是子组件先于父组件。PostDetailPage 的 `useLayoutEffect` 在 MainLayout 的 `useEffect`(含 `useScrollRestoration`)之前执行。此时 sessionStorage 记录尚未被消费,`hasPendingScrollRestore()` 返回准确值。
|
||||||
|
|
||||||
|
## 改动计划
|
||||||
|
|
||||||
|
### 1. `frontend/src/utils/scrollRestore.ts` — 新增 `hasPendingScrollRestore`
|
||||||
|
|
||||||
|
在现有文件中新增导出函数:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** 检查 sessionStorage 中是否有指定 URL 的待恢复滚动记录(不消费/不删除) */
|
||||||
|
export function hasPendingScrollRestore(url: string = getCurrentUrl()): boolean {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(storageKey(url));
|
||||||
|
if (!raw) return false;
|
||||||
|
const entry = JSON.parse(raw) as SavedPositions;
|
||||||
|
return !!entry?.containers && Object.keys(entry.containers).length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
纯读取,不删除记录(`restoreScrollPositions` 的 rAF 循环仍需要它来恢复)。
|
||||||
|
|
||||||
|
### 2. 新建 `frontend/src/hooks/useScrollToTopOnMount.ts`
|
||||||
|
|
||||||
|
通用 hook,在滚动容器渲染就绪后重置到顶部(刷新场景跳过):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
||||||
|
import { hasPendingScrollRestore } from '../utils/scrollRestore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动容器渲染就绪后重置到顶部。
|
||||||
|
* - 首次就绪:刷新场景(sessionStorage 有记录)跳过,让 useScrollRestoration 恢复;
|
||||||
|
* SPA 导航(无记录)重置到顶部。
|
||||||
|
* - deps 变化(如 postId 变化):始终重置到顶部。
|
||||||
|
* - 同 deps 的 ready 状态变化(如评论刷新导致 loading 短暂为 true):不处理,避免误重置。
|
||||||
|
*/
|
||||||
|
export function useScrollToTopOnMount(
|
||||||
|
scrollRef: RefObject<HTMLElement | null>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
ready: boolean,
|
||||||
|
): void {
|
||||||
|
const lastKeyRef = useRef<string | null>(null);
|
||||||
|
const key = JSON.stringify(deps);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!ready || !scrollRef.current) return;
|
||||||
|
|
||||||
|
if (lastKeyRef.current === null) {
|
||||||
|
lastKeyRef.current = key;
|
||||||
|
if (!hasPendingScrollRestore()) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastKeyRef.current !== key) {
|
||||||
|
lastKeyRef.current = key;
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [key, ready]); // ready 变化时重新检查
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `frontend/src/pages/PostDetailPage.tsx` — 调用 hook
|
||||||
|
|
||||||
|
在 `pageRef` 定义之后(L108 之后)、`useGlobalWheelScroll` 调用处附近添加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useScrollToTopOnMount } from '../hooks/useScrollToTopOnMount';
|
||||||
|
// ...
|
||||||
|
useScrollToTopOnMount(pageRef, [postId], !loading && !!post);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `deps: [postId]` — `/post/123` → `/post/456` 时重置
|
||||||
|
- `ready: !loading && !!post` — `.page-wrap` 仅在此时渲染(L446 loading return、L447 !post return),pageRef.current 才有效
|
||||||
|
|
||||||
|
### 不需要改动的部分
|
||||||
|
|
||||||
|
- **其他页面**(FavoritesPage、MessagesPage、ProfilePage 等):用户仅报告了帖子页问题。这些页面内容较短,滚动保留不明显。如后续报告可复用此 hook。
|
||||||
|
- **MainLayout / AdminLayout**:`useScrollRestoration` 不变。
|
||||||
|
- **`history.scrollRestoration`**:不设为 `'manual'`,避免影响 back/forward 时其他页面的浏览器原生恢复。
|
||||||
|
|
||||||
|
## 验证步骤
|
||||||
|
|
||||||
|
1. **SPA 导航进入帖子**:从首页点击帖子 → 滑到下方 → 返回首页 → 再次点击同一帖子 → 应从顶部开始。
|
||||||
|
2. **帖子间切换**:`/post/123` 滑到下方 → 直接导航到 `/post/456` → 应从顶部开始。
|
||||||
|
3. **刷新保持位置**:在帖子页滑到下方 → F5 刷新 → 应恢复到原位置(`useScrollRestoration` 生效,hook 跳过)。
|
||||||
|
4. **硬刷新保持位置**:同上但用 Ctrl+F5 → 应恢复到原位置。
|
||||||
|
5. **首次访问**:从未访问过的帖子 → 应从顶部开始(sessionStorage 无记录)。
|
||||||
|
6. **诊断无错误**:TypeScript 编译通过。
|
||||||
55
.trae/documents/fix-sticker-serving-plan.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 修复生产环境表情图片无法显示 Implementation Plan
|
||||||
|
|
||||||
|
## Repository Research
|
||||||
|
|
||||||
|
### 问题分析
|
||||||
|
|
||||||
|
表情图片在生产部署后无法加载,原因是 **Go 后端缺少 `/stickers/*` 路由的静态文件服务注册**。
|
||||||
|
|
||||||
|
当前架构:
|
||||||
|
1. 前端 emoji 数据中的 URL 为 `/stickers/{platform}/{file}.avif`(在 [emojiData.ts](file:///c:/Users/freefire/Documents/jiang13-forum/frontend/src/data/stickers/emojiData.ts) 中通过 `toLocalUrl()` 生成)
|
||||||
|
2. Vite 构建时将 `frontend/public/stickers/` 原样复制到 `embed_static/static/spa/stickers/`
|
||||||
|
3. Go 的 `//go:embed static/*` 会将 `embed_static/static/` 下所有文件(包括 stickers)打包进二进制
|
||||||
|
4. 但 [embed.go](file:///c:/Users/freefire/Documents/jiang13-forum/embed_static/embed.go) 的 `SetupEmbed()` **只注册了 `/assets/*filepath` 路由**(用于 JS/CSS chunk),没有注册 `/stickers/*filepath`
|
||||||
|
5. 同时 `IsSPARoute()` 函数也没有排除 `/stickers` 前缀,导致请求被 SPA NoRoute fallback 处理,返回 index.html 而非图片
|
||||||
|
|
||||||
|
开发模式之所以正常,是因为 Vite dev server 自动托管了 `public/stickers/` 下的静态文件。
|
||||||
|
|
||||||
|
### 当前代码状态
|
||||||
|
|
||||||
|
- `embed.go` 第 13 行: `//go:embed static/*` — 正确嵌入了所有静态资源(含 stickers)
|
||||||
|
- `embed.go` 第 33-43 行: `SetupEmbed()` — 只处理了 `static/spa/assets`,缺少 stickers 路由
|
||||||
|
- `embed.go` 第 63-77 行: `IsSPARoute()` — 缺少 `/stickers` 前缀排除
|
||||||
|
|
||||||
|
## Files and Modules
|
||||||
|
|
||||||
|
- `embed_static/embed.go`: 新增 `/stickers/*filepath` 文件服务路由;在 `IsSPARoute` 中排除 `/stickers` 前缀
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **在 `SetupEmbed` 中注册 `/stickers` 路由**
|
||||||
|
- 参照现有 `/assets` 路由的模式,添加对 `static/spa/stickers` 子目录的文件服务
|
||||||
|
- 使用 `fs.Sub(staticFS, "static/spa/stickers")` 获取 stickers 子文件系统
|
||||||
|
- 注册 `GET /stickers/*filepath` 路由,直接透传,不设置长期缓存(表情可能更新)
|
||||||
|
|
||||||
|
2. **在 `IsSPARoute` 中排除 `/stickers` 前缀**
|
||||||
|
- 在现有 `strings.HasPrefix(path, "/assets")` 附近添加 `strings.HasPrefix(path, "/stickers")`
|
||||||
|
- 确保表情请求不会被 SPA fallback 拦截
|
||||||
|
|
||||||
|
## Dependencies and Considerations
|
||||||
|
|
||||||
|
- stickers 目录下存储的是 `.avif` 图片文件,不需要设置特殊 MIME type(http.FileServer 会根据扩展名自动识别)
|
||||||
|
- stickers 资源不含哈希指纹,不应设置 `immutable` 缓存头(与 `/assets` 不同),但可以设置短过期缓存
|
||||||
|
- 需确保 `fs.Sub` 路径与实际构建输出路径一致:`embed_static/static/spa/stickers/`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- 重新构建前端:`cd frontend && npm run build`
|
||||||
|
- 重新构建 Go 二进制:`go build -o jiang13-linux-amd64`
|
||||||
|
- 部署后访问 `/stickers/tieba/tb_01.avif` 应能直接返回图片内容(HTTP 200)
|
||||||
|
- 在评论框中打开表情选择器,所有平台的表情应正常显示
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **风险**: 如果未来在 `public/` 下新增其他静态资源目录(如 `avatars/`、`flags/` 等),需要同样在 `embed.go` 中注册对应路由
|
||||||
|
- **应对**: 可考虑后续重构为自动扫描 `public/` 下所有子目录并自动注册路由,或改为统一的 SPA 静态文件服务方案
|
||||||
8
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "de7e4cff",
|
||||||
|
"configHash": "9a7296da",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "8c168d3c",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -4,14 +4,12 @@
|
|||||||
|
|
||||||
## 开发环境
|
## 开发环境
|
||||||
|
|
||||||
**要求:** Go 1.26+、Node.js 18+(仅构建 `web_src` 静态资源)
|
**要求:** Go 1.26+、Node.js 18+
|
||||||
|
|
||||||
本分支(`rebuild/gitea-ssr`)为 **Go 模板 SSR**;对照 React SPA 请 `git checkout main`。
|
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
REM Windows:请用 build.bat(内部 Bypass ExecutionPolicy)
|
REM Windows:一键启动后端 + 前端热更新(请用 build.bat)
|
||||||
build.bat -Target dev
|
build.bat -Target dev
|
||||||
REM 浏览器访问 http://localhost:3000
|
REM 浏览器访问 http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -22,14 +20,15 @@ make dev
|
|||||||
## 提交规范
|
## 提交规范
|
||||||
|
|
||||||
- 一个 PR 只做一件事,保持 diff 小而清晰
|
- 一个 PR 只做一件事,保持 diff 小而清晰
|
||||||
- 前端(`web_src` / 模板)改动请确认浅色 / 暗色主题下都正常
|
- 前端改动请确认浅色 / 暗色主题下都正常
|
||||||
- 涉及 UI 变更时,建议在 PR 中附上截图
|
- 涉及 UI 变更时,建议在 PR 中附上截图
|
||||||
- 功能语义以 [`docs/rebuild-spec/`](docs/rebuild-spec/) 为准
|
|
||||||
|
|
||||||
## 完整构建
|
## 完整构建
|
||||||
|
|
||||||
|
发布单二进制前需先构建前端并 embed:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
build.bat REM Windows:先 web_src,再 go build → dist/
|
build.bat REM Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -39,6 +38,15 @@ make build # Linux / macOS
|
|||||||
## 报告问题
|
## 报告问题
|
||||||
|
|
||||||
在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)):
|
在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)):
|
||||||
- 期望行为与实际行为
|
|
||||||
- 复现步骤、浏览器 / OS
|
1. 复现步骤
|
||||||
- 相关模板或 `routers/web` 路径(若已知)
|
2. 期望行为 vs 实际行为
|
||||||
|
3. 环境信息(系统、浏览器、Go/Node 版本)
|
||||||
|
4. 截图或日志(如有)
|
||||||
|
|
||||||
|
演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/)
|
||||||
|
已知问题与计划功能见 [ROADMAP.md](ROADMAP.md)。
|
||||||
|
|
||||||
|
## 行为准则
|
||||||
|
|
||||||
|
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。
|
||||||
|
|||||||
18
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
# 姜十三论坛 — 多阶段构建:web_src → Go 单二进制 → Alpine 运行镜像
|
# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像
|
||||||
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
|
||||||
#
|
#
|
||||||
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
|
||||||
@@ -8,12 +8,14 @@
|
|||||||
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
|
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
# ── Stage 1: SSR 渐进资源(web_src → public/assets)────────────────────────
|
# ── Stage 1: 前端构建(Vite → embed_static/static/spa)────────────────────
|
||||||
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS websrc
|
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS frontend
|
||||||
WORKDIR /src/web_src
|
WORKDIR /src/frontend
|
||||||
COPY web_src/package.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
COPY web_src/ ./
|
RUN npm config set registry https://registry.npmmirror.com \
|
||||||
RUN node build.mjs
|
&& npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0)────────────────────────
|
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0)────────────────────────
|
||||||
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
|
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
|
||||||
@@ -23,7 +25,7 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=websrc /src/public/assets ./public/assets
|
COPY --from=frontend /src/embed_static/static/spa ./embed_static/static/spa
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||||
-o /out/jiang13 ./cmd/jiang13
|
-o /out/jiang13 ./cmd/jiang13
|
||||||
|
|||||||
12
LICENSE
@@ -1,4 +1,6 @@
|
|||||||
Copyright (c) 2026 The Jiang13 Authors
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 freefire
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -7,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
The above copyright notice and this permission notice shall be included in all
|
||||||
all copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
THE SOFTWARE.
|
SOFTWARE.
|
||||||
|
|||||||
54
Makefile
@@ -1,50 +1,50 @@
|
|||||||
# 姜十三论坛 Jiang13 Forum - Makefile
|
# 姜十三论坛 Jiang13 Forum - Makefile
|
||||||
# Go 1.26 单二进制:templates SSR + web_src 渐进资源(本分支无 React SPA)
|
# Go 1.26 单二进制编译,与 Gitea 打包方式一致
|
||||||
|
|
||||||
APP_NAME := jiang13
|
APP_NAME := jiang13
|
||||||
MAIN_PKG := ./cmd/jiang13
|
MAIN_PKG := ./cmd/jiang13
|
||||||
BUILD_DIR := dist
|
BUILD_DIR := dist
|
||||||
DEV_DATA_DIR := dist/data
|
DEV_DATA_DIR := dist/data
|
||||||
VERSION := $(shell git rev-parse --short HEAD 2>/dev/null | sed 's/^/1.0.0+/' || echo 1.0.0)
|
VERSION := 1.1.7
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
||||||
|
|
||||||
GO := go
|
GO := go
|
||||||
GOFLAGS := -trimpath
|
GOFLAGS := -trimpath
|
||||||
|
|
||||||
.PHONY: all build build-windows build-linux build-darwin build-all clean run dev tidy help web-src-build docker compose-up compose-down
|
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build docker compose-up compose-down
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
web-src-build:
|
frontend-build:
|
||||||
cd web_src && npm run build
|
cd frontend && npm install && npm run build
|
||||||
|
|
||||||
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
||||||
build: web-src-build
|
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
|
## Windows amd64(先打包前端再 embed)
|
||||||
build-windows: web-src-build
|
build-windows: 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
|
## Linux amd64(先打包前端再 embed)
|
||||||
build-linux: web-src-build
|
build-linux: 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)
|
## macOS arm64 (Apple Silicon)(先打包前端再 embed)
|
||||||
build-darwin: web-src-build
|
build-darwin: 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"
|
||||||
|
|
||||||
## 跨平台全量编译(web_src 只跑一次)
|
## 跨平台全量编译(frontend-build 只跑一次)
|
||||||
build-all: web-src-build
|
build-all: 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)
|
||||||
@@ -56,13 +56,20 @@ build-all: web-src-build
|
|||||||
tidy:
|
tidy:
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
|
||||||
## 本地运行 SSR(先构建 web_src)
|
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致)
|
||||||
run: web-src-build
|
run:
|
||||||
@mkdir -p $(DEV_DATA_DIR)
|
@mkdir -p $(DEV_DATA_DIR)
|
||||||
$(GO) run $(MAIN_PKG) --work-path . --data $(DEV_DATA_DIR)
|
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR)
|
||||||
|
|
||||||
## 同 run(SPA 对照请 git checkout main)
|
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出;数据目录与 dist 二进制一致)
|
||||||
dev: run
|
dev:
|
||||||
|
@echo "前端热更新: http://localhost:5173"
|
||||||
|
@echo "后端 API : http://localhost:3000"
|
||||||
|
@echo "数据目录 : $(DEV_DATA_DIR) (与 dist 二进制一致)"
|
||||||
|
@mkdir -p $(DEV_DATA_DIR)
|
||||||
|
@trap 'kill 0' INT; \
|
||||||
|
$(GO) run $(MAIN_PKG) --dev --data $(DEV_DATA_DIR) & \
|
||||||
|
cd frontend && (test -d node_modules || npm install) && npm run dev
|
||||||
|
|
||||||
## 清理编译产物
|
## 清理编译产物
|
||||||
clean:
|
clean:
|
||||||
@@ -81,15 +88,14 @@ compose-down:
|
|||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "姜十三论坛编译命令 (rebuild/gitea-ssr):"
|
@echo "姜十三论坛编译命令:"
|
||||||
@echo " make web-src-build - 构建 SSR 渐进资源 (web_src)"
|
@echo " make build - 编译当前平台"
|
||||||
@echo " make build - web_src + 编译当前平台"
|
|
||||||
@echo " make build-windows - 编译 Windows"
|
@echo " make build-windows - 编译 Windows"
|
||||||
@echo " make build-linux - 编译 Linux"
|
@echo " make build-linux - 编译 Linux"
|
||||||
@echo " make build-darwin - 编译 macOS"
|
@echo " make build-darwin - 编译 macOS"
|
||||||
@echo " make build-all - 编译全部平台"
|
@echo " make build-all - 编译全部平台"
|
||||||
@echo " make run / make dev - 启动 SSR(:3000)"
|
@echo " make run - 仅启动后端(:3000)"
|
||||||
|
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
||||||
@echo " make docker - 构建 Docker 镜像"
|
@echo " make docker - 构建 Docker 镜像"
|
||||||
@echo " make compose-up - Docker Compose 启动"
|
@echo " make compose-up - Docker Compose 启动"
|
||||||
@echo " make compose-down - Docker Compose 停止"
|
@echo " make compose-down - Docker Compose 停止"
|
||||||
@echo " SPA 对照: git checkout main"
|
|
||||||
|
|||||||
258
README.md
@@ -5,8 +5,7 @@
|
|||||||
**能聊 · 好看 · 好装**
|
**能聊 · 好看 · 好装**
|
||||||
|
|
||||||
面向小圈子、团队与同好社群的轻量现代化论坛。
|
面向小圈子、团队与同好社群的轻量现代化论坛。
|
||||||
本分支(`rebuild/gitea-ssr`):Go 模板真 SSR + `web_src` 渐进增强,单二进制 + SQLite。
|
编译为单个 Go 二进制,前端 SPA(单页应用)内嵌,内置 SQLite,拷到服务器即可运行。
|
||||||
对照 React SPA 请见 `main` 分支。
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
@@ -14,13 +13,14 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
[](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](docs/rebuild-spec/08-gitea-ssr-architecture.md)
|
[](frontend/package.json)
|
||||||
[](#)
|
[](#)
|
||||||
|
|
||||||
[在线演示](https://bbs.iioio.com/) ·
|
[在线演示](https://bbs.iioio.com/) ·
|
||||||
[快速开始](#-快速开始) ·
|
[快速开始](#-快速开始) ·
|
||||||
[界面预览](#-界面预览) ·
|
[界面预览](#-界面预览) ·
|
||||||
[功能亮点](#-功能亮点) ·
|
[功能亮点](#-功能亮点) ·
|
||||||
|
[更新日志](docs/site-posts/changelog.md) ·
|
||||||
[路线图](ROADMAP.md) ·
|
[路线图](ROADMAP.md) ·
|
||||||
[参与贡献](CONTRIBUTING.md)
|
[参与贡献](CONTRIBUTING.md)
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)(现网多为 `main` SPA)
|
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)
|
||||||
> 本分支按 [Gitea 式 SSR 规格](docs/rebuild-spec/08-gitea-ssr-architecture.md) 重构;欢迎提 Issue / PR 共建。
|
> 项目积极开发中。管理后台已统一为 React SPA(`/admin`),欢迎提 Issue / PR 共建。
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<td width="50%" align="center">
|
<td width="50%" align="center">
|
||||||
<img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
|
<img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
|
||||||
<br><b>富文本渲染</b><br>
|
<br><b>富文本渲染</b><br>
|
||||||
<sub>Markdown 排版 · 图片 · 代码块 · 目录导航</sub>
|
<sub>TipTap 排版 · 图片 · 代码高亮 · 目录导航</sub>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -96,26 +96,26 @@
|
|||||||
|
|
||||||
| 特性 | 说明 |
|
| 特性 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **三栏布局** | 左栏板块导航 + 中间帖列表 + 右栏热门 / 标签 / 最新评论 |
|
| **三栏布局** | 左栏板块导航 + 中间虚拟滚动帖列表 + 右栏热门 / 标签 / 最新评论 |
|
||||||
| **真 SSR** | 公开页服务端渲染完整 HTML(Go `html/template`) |
|
| **虚拟滚动** | `@tanstack/react-virtual` 驱动长列表,浏览依然流畅 |
|
||||||
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
|
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
|
||||||
| **主题切换** | 浅色 / 暗色 / 跟随系统(`localStorage`) |
|
| **主题切换** | 浅色 / 暗色,跟随系统偏好并本地记忆 |
|
||||||
| **响应式** | ≤900px 隐藏右栏;搜索、发帖、登录触手可及 |
|
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
|
||||||
|
|
||||||
### 社区功能
|
### 社区功能
|
||||||
|
|
||||||
- 安装向导创建管理员;用户注册 / 登录(bcrypt + 会话 Cookie)
|
- 用户注册 / 登录(bcrypt + JWT Cookie);**首个注册用户自动成为管理员**
|
||||||
- 板块、发帖、Markdown 工具栏、正文图片上传、标签、置顶 / 精华
|
- 板块、发帖、TipTap 富文本、正文图片上传、标签、置顶 / 精华
|
||||||
- 帖子修订历史;可配置普通用户编辑时限
|
- 帖子修订历史与 diff(差异)对比;可配置普通用户编辑时限
|
||||||
- 楼层式评论:回复、嵌套树、内容门控(登录/回复/积分可见)
|
- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控
|
||||||
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
|
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
|
||||||
- SSR 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
|
- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
|
||||||
- 可选:邮件验证码、OIDC Provider、S3 兼容对象存储(Gitea 仓库同步后置)
|
- 可选:邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储
|
||||||
|
|
||||||
### 部署体验
|
### 部署体验
|
||||||
|
|
||||||
- **单二进制** — `go:embed` 打包模板与 SSR 资源
|
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
|
||||||
- **可切换数据库** — 默认 SQLite;可选 PostgreSQL / MySQL(Env 引导)
|
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||||
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||||
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
||||||
@@ -143,7 +143,7 @@ make build
|
|||||||
**手动分步(全平台):**
|
**手动分步(全平台):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web_src && npm run build
|
cd frontend && npm install && npm run build
|
||||||
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,7 +171,12 @@ docker compose up -d --build
|
|||||||
make compose-up
|
make compose-up
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器打开 `http://localhost:3000/install` 完成安装向导(站点名 + 管理员)。
|
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
|
||||||
|
|
||||||
|
版本变更与升级说明见 **[更新日志](docs/site-posts/changelog.md)**。Docker 与 Windows / Linux 单文件共用同一版本号。
|
||||||
|
|
||||||
|
- Docker 标签:[hub.docker.com/r/hangzhang714128/jiang13-forum/tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags)
|
||||||
|
- 预编译包:[Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases)
|
||||||
|
|
||||||
**拉取已构建镜像(Docker Hub):**
|
**拉取已构建镜像(Docker Hub):**
|
||||||
|
|
||||||
@@ -184,7 +189,7 @@ docker run -d --name jiang13 \
|
|||||||
hangzhang714128/jiang13-forum:latest
|
hangzhang714128/jiang13-forum:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**数据持久化:** 容器内 `/data` 对应 SQLite(默认)、上传、日志与 JWT/OIDC 密钥。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户)。
|
**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户),适配 1Panel 等面板挂载的目录。
|
||||||
|
|
||||||
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
||||||
|
|
||||||
@@ -194,11 +199,10 @@ docker run -d --name jiang13 \
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
|
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
|
||||||
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
|
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
|
||||||
| `JIANG13_DB_TYPE` | `sqlite`(默认)\| `postgres` \| `mysql` |
|
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||||
| `JIANG13_DB_DSN` | 完整 DSN(非 sqlite 时推荐) |
|
| `JIANG13_CONFIG` | 配置文件路径 |
|
||||||
| `JIANG13_WORK_PATH` | 工作目录 |
|
| `JIANG13_WORK_PATH` | 工作目录 |
|
||||||
|
| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
||||||
JWT 自动写入 `/data/.jwt_secret`,无需 Env。
|
|
||||||
|
|
||||||
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
||||||
|
|
||||||
@@ -208,15 +212,15 @@ JWT 自动写入 `/data/.jwt_secret`,无需 Env。
|
|||||||
docker login
|
docker login
|
||||||
.\build.bat -Target docker # Windows
|
.\build.bat -Target docker # Windows
|
||||||
# make docker # Linux/macOS
|
# make docker # Linux/macOS
|
||||||
docker push hangzhang714128/jiang13-forum:1.0.0
|
docker push hangzhang714128/jiang13-forum:1.1.7
|
||||||
docker push hangzhang714128/jiang13-forum:latest
|
docker push hangzhang714128/jiang13-forum:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
或直接构建:
|
或直接构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build --build-arg VERSION=1.0.0 -t hangzhang714128/jiang13-forum:1.0.0 -t hangzhang714128/jiang13-forum:latest .
|
docker build --build-arg VERSION=1.1.7 -t hangzhang714128/jiang13-forum:1.1.7 -t hangzhang714128/jiang13-forum:latest .
|
||||||
docker push hangzhang714128/jiang13-forum:1.0.0
|
docker push hangzhang714128/jiang13-forum:1.1.7
|
||||||
docker push hangzhang714128/jiang13-forum:latest
|
docker push hangzhang714128/jiang13-forum:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -237,72 +241,112 @@ docker push hangzhang714128/jiang13-forum:latest
|
|||||||
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
|
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
|
||||||
2. 端口映射 `3000:3000`
|
2. 端口映射 `3000:3000`
|
||||||
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
|
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
|
||||||
4. 首次访问 `http://服务器IP:3000/install` 完成安装
|
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
|
||||||
|
|
||||||
### 3. 直接启动(二进制)
|
### 3. 直接启动(二进制)
|
||||||
|
|
||||||
|
不想自己编译时,从 [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) 下载与 Docker 同版本号的文件即可:
|
||||||
|
|
||||||
|
| 文件 | 平台 |
|
||||||
|
|------|------|
|
||||||
|
| `jiang13-*-windows-amd64.exe` | Windows x64 |
|
||||||
|
| `jiang13-*-linux-amd64` | Linux x64 |
|
||||||
|
|
||||||
|
当前 **1.1.7:** [Windows](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-windows-amd64.exe) · [Linux](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-linux-amd64)
|
||||||
|
|
||||||
|
把文件放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows
|
# Windows
|
||||||
.\dist\jiang13.exe --data .\dist\data
|
.\jiang13-1.1.7-windows-amd64.exe
|
||||||
|
# 或改名为 jiang13.exe 后:
|
||||||
|
.\jiang13.exe
|
||||||
|
|
||||||
# Linux / macOS
|
# Linux
|
||||||
./dist/jiang13 --data ./data
|
chmod +x jiang13-1.1.7-linux-amd64
|
||||||
|
./jiang13-1.1.7-linux-amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
默认 SQLite,库文件在 `{DATA}/jiang13.db`。无 `app.ini`。
|
本地刚编译的产物在 `dist/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
.\dist\jiang13.exe
|
||||||
|
|
||||||
|
# Linux / macOS
|
||||||
|
./dist/jiang13
|
||||||
|
```
|
||||||
|
|
||||||
|
也可先复制示例配置再改端口 / 数据目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp app.ini.example /opt/jiang13/app.ini
|
||||||
|
# 编辑 app.ini 后:
|
||||||
|
./jiang13
|
||||||
|
```
|
||||||
|
|
||||||
### 4. 首次使用
|
### 4. 首次使用
|
||||||
|
|
||||||
1. 浏览器打开 `http://localhost:3000/install`
|
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||||
2. 填写站点名与管理员账号
|
2. **第一个注册的用户自动成为管理员**
|
||||||
3. 完成后登录,访问管理后台配置品牌等(热更新,无需重启)
|
3. 登录后访问 `http://localhost:3000/admin` 进入后台
|
||||||
|
|
||||||
### 配置分层(无 INI)
|
### 配置文件(`app.ini`)
|
||||||
|
|
||||||
| 层 | 内容 | 需重启 |
|
默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录)。
|
||||||
|----|------|--------|
|
|
||||||
| CLI / Env | 端口、数据目录、数据库类型与 DSN | 是 |
|
|
||||||
| `data/.jwt_secret`、`.oidc_rsa.pem` | 密钥 | 换密钥需重启 |
|
|
||||||
| DB `forum_settings` | 品牌、邮件、OIDC 开关、限流、存储… | 否 |
|
|
||||||
|
|
||||||
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
|
```ini
|
||||||
|
[server]
|
||||||
|
HTTP_PORT = 3000
|
||||||
|
|
||||||
|
[paths]
|
||||||
|
DATA = data
|
||||||
|
|
||||||
|
[security]
|
||||||
|
JWT_SECRET =
|
||||||
|
```
|
||||||
|
|
||||||
|
完整示例见 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。
|
||||||
|
|
||||||
|
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
|
||||||
|
|
||||||
### 启动参数
|
### 启动参数
|
||||||
|
|
||||||
| 参数 | 默认值 | 说明 |
|
| 参数 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `--work-path` | 可执行文件目录 | 工作目录 |
|
| `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) |
|
||||||
| `--port` | `3000` | HTTP 监听端口 |
|
| `--config` | `{work-path}/app.ini` | 配置文件路径 |
|
||||||
| `--http-addr` | (空) | 监听地址 |
|
| `--port` | (读配置 / `3000`) | HTTP 监听端口 |
|
||||||
| `--data` | `data` | 数据目录 |
|
| `--data` | (读配置 / `data`) | 数据目录 |
|
||||||
| `--db-type` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
|
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||||
| `--db-dsn` | (sqlite 默认 `{data}/jiang13.db`) | 完整 DSN |
|
|
||||||
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
||||||
|
|
||||||
**环境变量:** `JIANG13_HTTP_PORT`、`JIANG13_HTTP_ADDR`、`JIANG13_DATA`、`JIANG13_WORK_PATH`、`JIANG13_DB_TYPE`、`JIANG13_DB_DSN`、以及 `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE`。
|
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`、`JIANG13_COMMUNITY_HUB`(非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表)
|
||||||
|
|
||||||
PostgreSQL / MySQL 示例见 [`docs/rebuild-spec/07-config-ops.md`](docs/rebuild-spec/07-config-ops.md)。
|
|
||||||
|
|
||||||
### 5. 注册为系统服务(可选)
|
### 5. 注册为系统服务(可选)
|
||||||
|
|
||||||
|
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
|
||||||
|
|
||||||
|
**Ubuntu / Linux(systemd,需 root):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt/jiang13
|
sudo mkdir -p /opt/jiang13
|
||||||
sudo cp jiang13 /opt/jiang13/
|
sudo cp jiang13 /opt/jiang13/
|
||||||
sudo /opt/jiang13/jiang13 --work-path /opt/jiang13 --data /opt/jiang13/data --service install
|
sudo /opt/jiang13/jiang13 --service install
|
||||||
sudo /opt/jiang13/jiang13 --service start
|
sudo /opt/jiang13/jiang13 --service start
|
||||||
sudo systemctl enable jiang13
|
sudo systemctl enable jiang13
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows(管理员 PowerShell):**
|
**Windows(Windows Service,需管理员 PowerShell):**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
|
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
|
||||||
Copy-Item .\jiang13.exe C:\jiang13\
|
Copy-Item .\jiang13.exe C:\jiang13\
|
||||||
C:\jiang13\jiang13.exe --work-path C:\jiang13 --data C:\jiang13\data --service install
|
C:\jiang13\jiang13.exe --service install
|
||||||
C:\jiang13\jiang13.exe --service start
|
C:\jiang13\jiang13.exe --service start
|
||||||
```
|
```
|
||||||
|
|
||||||
> 改端口或 `DB_*` 后执行 `--service restart`(必要时重装服务以更新参数)。日志:`data/jiang13.log`。
|
> 改 `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -310,73 +354,95 @@ C:\jiang13\jiang13.exe --service start
|
|||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite / PostgreSQL / MySQL · `html/template` |
|
| **后端** | Go 1.26 · Gin · GORM · SQLite |
|
||||||
| **渐进资源** | `web_src/`(构建到 `public/assets/`,URL `/ssr-assets/`) |
|
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||||
| **构建** | `web_src` → `go:embed` templates + assets,单二进制发布 |
|
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
|
||||||
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
|
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
|
||||||
| **对照 SPA** | 仅 `main` 分支(React 18 · TipTap · Vite) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 本地开发(SSR)
|
## 前端开发
|
||||||
|
|
||||||
|
日常改前端不需要重新完整构建,Vite 支持秒级热更新(HMR,热模块替换):
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
build.bat -Target run
|
build.bat -Target dev
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器访问 `http://localhost:3000`。数据目录默认 `dist/data`。
|
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
|
||||||
|
|
||||||
改模板 / Go 后重启进程;改 `web_src` 后需再跑 `build.bat -Target web-src`(或完整 `build`)。
|
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。
|
||||||
|
|
||||||
需要对照旧 SPA UI:`git checkout main` 或 `git worktree add ../jiang13-spa main`。
|
**何时需要完整构建:**
|
||||||
|
|
||||||
|
- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build`
|
||||||
|
- 更新 README 界面截图 → 见下方「更新截图」
|
||||||
|
|
||||||
|
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
|
||||||
|
|
||||||
|
### 更新截图
|
||||||
|
|
||||||
|
默认从演示站抓取到 `docs/screenshots/`(需本机已安装 Playwright):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D playwright
|
||||||
|
npx playwright install chromium
|
||||||
|
node scripts/capture-screenshots.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
| 环境变量 | 说明 | 默认 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `J13_URL` | 抓取目标 | `https://bbs.iioio.com` |
|
||||||
|
| `J13_POST_ID` | 详情页帖子 ID | `1` |
|
||||||
|
| `J13_RICH_POST_ID` | 富文本展示帖 ID | `8` |
|
||||||
|
| `J13_USER` / `J13_PASS` | 发帖页登录(可选) | `admin` / `admin123` |
|
||||||
|
|
||||||
|
本地站点示例:`J13_URL=http://localhost:3000 node scripts/capture-screenshots.mjs`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
jiang13-forum/ # 分支 rebuild/gitea-ssr
|
jiang13-forum/
|
||||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||||
├── config/ # CLI / Env 引导配置(无 INI)
|
├── config/ # app.ini 与命令行配置
|
||||||
├── Dockerfile # web_src → Go → Alpine
|
├── app.ini.example # 配置文件示例
|
||||||
├── docker-compose.yml
|
├── Dockerfile # 多阶段 Docker 构建
|
||||||
├── models/ # GORM 模型
|
├── docker-compose.yml # 单容器 Compose 部署
|
||||||
├── services/ # 业务逻辑
|
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限)
|
||||||
├── routers/
|
├── .dockerignore
|
||||||
│ ├── setup.go # 路由总装
|
├── model/ # GORM 模型与数据库迁移
|
||||||
│ ├── web/ # HTML SSR
|
├── service/ # 业务逻辑
|
||||||
│ └── api/ # 机器入口(health / OIDC / SEO / thumb)
|
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||||
├── modules/
|
├── middleware/ # JWT 鉴权等
|
||||||
│ ├── auth/ # JWT / 限流
|
├── router/ # 路由注册
|
||||||
│ ├── webrender/ # 模板渲染
|
├── embed_static/ # go:embed 内嵌的 SPA
|
||||||
│ └── seo/
|
├── frontend/ # React 源码(Vite 构建)
|
||||||
├── templates/ # Go html/template(embed)
|
├── docs/screenshots/ # README 界面截图
|
||||||
├── web_src/ # 渐进 CSS/JS 源码
|
├── ROADMAP.md # 路线图与已知问题
|
||||||
├── public/assets/ # web_src 构建产物(embed)
|
└── scripts/ # 开发辅助脚本(含截图)
|
||||||
├── docs/rebuild-spec/ # 产品规格与 SSR 架构
|
|
||||||
├── docs/screenshots/
|
|
||||||
└── ROADMAP.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> SPA 源码树仅存在于 `main`(`frontend/`、`embed_static/`)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 数据目录
|
## 数据目录
|
||||||
|
|
||||||
```
|
```
|
||||||
data/
|
data/
|
||||||
├── jiang13.db # SQLite 主数据库
|
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||||
|
├── monitor.db # 网站监控 page_views
|
||||||
├── jiang13.log # 运行日志
|
├── jiang13.log # 运行日志
|
||||||
├── filter_words.txt # 敏感词配置
|
├── filter_words.txt # 敏感词配置
|
||||||
├── .jwt_secret # JWT 密钥(自动生成)
|
├── .jwt_secret # JWT 密钥(自动生成)
|
||||||
|
├── logs/access/ # 网站监控请求日志(按日 jsonl)
|
||||||
├── uploads/avatars/ # 用户头像
|
├── uploads/avatars/ # 用户头像
|
||||||
├── uploads/posts/ # 帖子正文图片
|
├── uploads/posts/ # 帖子正文图片
|
||||||
└── jiang13_backup_*.db # 后台导出的备份
|
└── jiang13_backup_*.db # 后台导出的主库备份
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -387,10 +453,10 @@ data/
|
|||||||
|
|
||||||
| 类型 | 示例 |
|
| 类型 | 示例 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| ✅ 已可用 | 三栏布局、主题、Feed 排序、楼层评论、嵌套树 |
|
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
|
||||||
| ✅ 发帖体验 | Markdown 工具栏、图片上传、修订历史、内容门控 |
|
| ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 |
|
||||||
| ✅ 管理后台 | SSR `/admin/*`(对照 SPA 见 `main`) |
|
| ✅ 管理后台 | React SPA:仪表盘、置顶 / 精华、禁言、系统设置 |
|
||||||
| 📋 计划中 | 见 [ROADMAP.md](ROADMAP.md) / [09-ssr-progress.md](docs/rebuild-spec/09-ssr-progress.md) |
|
| 📋 计划中 | 通知动态优化、邮件提醒 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -404,4 +470,4 @@ data/
|
|||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
[MIT](LICENSE)(与 [Gitea](https://github.com/go-gitea/gitea) 相同的 Expat 文本格式)— 自由使用、修改与分发。
|
[MIT](LICENSE) — 自由使用、修改与分发。
|
||||||
|
|||||||
69
ROADMAP.md
@@ -1,8 +1,7 @@
|
|||||||
# 路线图 ROADMAP
|
# 路线图 ROADMAP
|
||||||
|
|
||||||
> 姜十三论坛仍在积极开发中。
|
> 姜十三论坛仍在积极开发中,功能尚未完善。
|
||||||
> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
|
> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
|
||||||
> **本仓库默认开发分支**:`rebuild/gitea-ssr`(Gitea 式 SSR)。`main` = React SPA 对照。
|
|
||||||
|
|
||||||
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
|
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
|
||||||
|
|
||||||
@@ -12,14 +11,10 @@
|
|||||||
|
|
||||||
| 模块 | 状态 | 说明 |
|
| 模块 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 公开页 SSR | ✅ | Go `html/template`;首页 / 板块 / 帖详情 / 用户 / 消息等 |
|
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
|
||||||
| 管理后台 SSR | ✅ | `/admin/*` 表单;对照 SPA 见 `main` |
|
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
|
||||||
| 评论系统 | ✅ | 楼层 + 嵌套树(`ThreadParentID`) |
|
| 评论系统 | ✅ | 换行显示已修复 |
|
||||||
| Markdown 编辑 | ✅ | 工具栏 + `/compose/preview`(非 TipTap) |
|
| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO(管理后台配置 ROOT_URL 与 OAuth 应用) |
|
||||||
| 主题 | ✅ | 浅色 / 暗色 / 跟随系统 |
|
|
||||||
| OIDC Provider | ✅ | Discovery / Authorize / Token / UserInfo;Admin 配置面可继续打磨 |
|
|
||||||
|
|
||||||
细节进度见 [`docs/rebuild-spec/09-ssr-progress.md`](docs/rebuild-spec/09-ssr-progress.md)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,20 +28,48 @@ _当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_
|
|||||||
|
|
||||||
| 优先级 | 功能 | 说明 |
|
| 优先级 | 功能 | 说明 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| 中 | OIDC / 存储 Admin 打磨 | SSO 与 S3 热切换运维面 |
|
| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
|
||||||
| 低 | 编辑器增强 | 表格、表情贴纸(TipTap 不作为本分支默认) |
|
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
||||||
| 低 | Gitea `/projects` | 仓库列表页(后置) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 已完成(摘,本分支)
|
## 🚧 进行中(In Progress)
|
||||||
|
|
||||||
- [x] Gitea 式目录与 Go 模板 SSR
|
_当前无公开认领任务。_
|
||||||
- [x] 五种帖类型 + 核心闭环(赞/藏/门控/审核…)
|
|
||||||
- [x] Admin 仪表盘、板块、审核、用户、徽章、媒体、设置等
|
|
||||||
- [x] Markdown 发帖/评论编辑器与预览
|
|
||||||
- [x] 评论嵌套树、浅色/暗色主题
|
|
||||||
- [x] OIDC Provider 机器入口
|
|
||||||
- [x] 本分支删除未挂载论坛 JSON API(对照 `main`)
|
|
||||||
|
|
||||||
更全清单:[`docs/rebuild-spec/02-features.md`](docs/rebuild-spec/02-features.md)。
|
---
|
||||||
|
|
||||||
|
## ✅ 已完成(Done)
|
||||||
|
|
||||||
|
- [x] React 管理后台(仪表盘、板块、帖子、评论、用户、设置)
|
||||||
|
- [x] 帖子置顶(帖子详情 + 管理后台)
|
||||||
|
- [x] 评论回复换行正确显示
|
||||||
|
- [x] 三栏布局 + 虚拟滚动帖列表
|
||||||
|
- [x] 浅色 / 暗色主题切换
|
||||||
|
- [x] 移动端响应式适配
|
||||||
|
- [x] 用户注册登录、JWT 鉴权
|
||||||
|
- [x] OIDC Provider(对接 Gitea SSO:Discovery / Authorize / Token / UserInfo)
|
||||||
|
- [x] OAuth 应用管理(密钥哈希、多客户端、登出端点、groups 映射)
|
||||||
|
- [x] 板块管理、发帖、TipTap 富文本编辑
|
||||||
|
- [x] 帖子正文图片本地上传
|
||||||
|
- [x] 帖子修订历史与 diff 对比
|
||||||
|
- [x] Feed 排序(最新发帖 / 最新回复 / 热门讨论)
|
||||||
|
- [x] 可配置编辑时限与论坛参数(限流、字数上限等)
|
||||||
|
- [x] 楼层式评论、引用回复、@ 高亮
|
||||||
|
- [x] 点赞、收藏、热门帖
|
||||||
|
- [x] 敏感词过滤、发帖限流
|
||||||
|
- [x] 站内私信
|
||||||
|
- [x] 回复提醒与待审提醒(站内消息 + SMTP 邮件)
|
||||||
|
- [x] SQLite 备份、单二进制部署
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 如何参与
|
||||||
|
|
||||||
|
1. 在 Issues 挑选任务(预填内容见 [docs/issue-templates.md](docs/issue-templates.md))
|
||||||
|
2. Fork → 分支 → PR,详见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
3. 有新想法先开 Issue 讨论,避免重复劳动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_最后更新:2026-08-05_
|
||||||
|
|||||||
25
app.ini.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
; 姜十三论坛 Jiang13 Forum — 配置文件示例(风格类似 Gitea app.ini)
|
||||||
|
; 复制为程序工作目录下的 app.ini 后修改。也可直接启动程序,首次会自动生成。
|
||||||
|
; 修改后重启进程/服务生效。命令行 --port / --data 等优先级更高。
|
||||||
|
; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。
|
||||||
|
|
||||||
|
[server]
|
||||||
|
HTTP_PORT = 3000
|
||||||
|
|
||||||
|
[paths]
|
||||||
|
; 相对路径相对于工作目录(默认可执行文件所在目录)
|
||||||
|
DATA = data
|
||||||
|
|
||||||
|
[security]
|
||||||
|
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
||||||
|
JWT_SECRET =
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; 维护者选项(默认关闭;普通自托管无需开启)
|
||||||
|
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
|
||||||
|
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
|
||||||
|
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
|
||||||
|
; 等价环境变量:JIANG13_COMMUNITY_HUB=1
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; [community]
|
||||||
|
; HUB = false
|
||||||
81
build.ps1
@@ -1,10 +1,9 @@
|
|||||||
# Jiang13 Forum - Windows build script (replaces GNU Make)
|
# Jiang13 Forum - Windows build script (replaces GNU Make)
|
||||||
# Usage: .\build.ps1
|
# Usage: .\build.ps1
|
||||||
# .\build.ps1 -Target build-windows
|
# .\build.ps1 -Target build-windows
|
||||||
# Branch rebuild/gitea-ssr: Go templates SSR + web_src (no React SPA)
|
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'web-src', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
|
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
|
||||||
[string]$Target = 'build'
|
[string]$Target = 'build'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,13 +12,7 @@ $AppName = 'jiang13'
|
|||||||
$MainPkg = './cmd/jiang13'
|
$MainPkg = './cmd/jiang13'
|
||||||
$BuildDir = 'dist'
|
$BuildDir = 'dist'
|
||||||
$DevDataDir = 'dist/data'
|
$DevDataDir = 'dist/data'
|
||||||
$Version = '1.0.0'
|
$Version = '1.1.7'
|
||||||
try {
|
|
||||||
$gitSha = (git rev-parse --short HEAD 2>$null)
|
|
||||||
if ($LASTEXITCODE -eq 0 -and $gitSha) {
|
|
||||||
$Version = "1.0.0+$gitSha"
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
||||||
$Ldlags = "-s -w -X main.version=$Version"
|
$Ldlags = "-s -w -X main.version=$Version"
|
||||||
|
|
||||||
@@ -29,12 +22,15 @@ function Ensure-Dir($path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Build-WebSrc {
|
function Build-Frontend {
|
||||||
Write-Host '[web_src] npm run build...' -ForegroundColor Cyan
|
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan
|
||||||
Push-Location web_src
|
Push-Location frontend
|
||||||
try {
|
try {
|
||||||
|
if (-not (Test-Path node_modules)) {
|
||||||
|
npm install
|
||||||
|
}
|
||||||
npm run build
|
npm run build
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'web_src build failed' }
|
if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' }
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
@@ -73,23 +69,22 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
|
|||||||
|
|
||||||
switch ($Target) {
|
switch ($Target) {
|
||||||
'help' {
|
'help' {
|
||||||
Write-Host '.\build.ps1 build current platform (web_src + go)'
|
Write-Host '.\build.ps1 build current platform'
|
||||||
Write-Host '.\build.ps1 -Target web-src SSR progressive assets only'
|
Write-Host '.\build.ps1 -Target frontend frontend only'
|
||||||
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 SSR on :3000'
|
Write-Host '.\build.ps1 -Target run backend only (port 3000)'
|
||||||
Write-Host '.\build.ps1 -Target dev same as run (SSR; SPA is on main)'
|
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
|
||||||
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'
|
Write-Host '.\build.ps1 -Target docker build Docker image'
|
||||||
Write-Host '.\build.ps1 -Target compose-up'
|
Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build'
|
||||||
Write-Host '.\build.ps1 -Target compose-down'
|
Write-Host '.\build.ps1 -Target compose-down docker compose down'
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
||||||
Write-Host 'SPA reference: git checkout main (or origin/main).'
|
|
||||||
}
|
}
|
||||||
'web-src' { Build-WebSrc }
|
'frontend' { Build-Frontend }
|
||||||
'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 }
|
||||||
@@ -97,33 +92,49 @@ switch ($Target) {
|
|||||||
}
|
}
|
||||||
'run' {
|
'run' {
|
||||||
Ensure-Dir $DevDataDir
|
Ensure-Dir $DevDataDir
|
||||||
Build-WebSrc
|
go run $MainPkg --data $DevDataDir
|
||||||
go run $MainPkg --work-path . --data $DevDataDir
|
|
||||||
}
|
}
|
||||||
'dev' {
|
'dev' {
|
||||||
|
$root = (Get-Location).Path
|
||||||
Ensure-Dir $DevDataDir
|
Ensure-Dir $DevDataDir
|
||||||
Build-WebSrc
|
Write-Host ''
|
||||||
Write-Host '[dev] SSR: http://localhost:3000 (SPA 对照请 checkout main)' -ForegroundColor Green
|
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green
|
||||||
go run $MainPkg --work-path . --data $DevDataDir
|
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green
|
||||||
|
Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green
|
||||||
|
Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow
|
||||||
|
Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
'-NoExit', '-Command',
|
||||||
|
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev --data '$DevDataDir'"
|
||||||
|
) | Out-Null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Push-Location frontend
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path node_modules)) { npm install }
|
||||||
|
npm run dev
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
}
|
}
|
||||||
'build' {
|
'build' {
|
||||||
Build-WebSrc
|
Build-Frontend
|
||||||
Build-Go -OutFile $AppName
|
Build-Go -OutFile $AppName
|
||||||
}
|
}
|
||||||
'build-windows' {
|
'build-windows' {
|
||||||
Build-WebSrc
|
Build-Frontend
|
||||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||||
}
|
}
|
||||||
'build-linux' {
|
'build-linux' {
|
||||||
Build-WebSrc
|
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
|
||||||
|
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-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-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||||
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||||
@@ -137,10 +148,12 @@ switch ($Target) {
|
|||||||
}
|
}
|
||||||
'compose-up' {
|
'compose-up' {
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'compose up failed' }
|
if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' }
|
||||||
|
Write-Host '[ok] compose started' -ForegroundColor Green
|
||||||
}
|
}
|
||||||
'compose-down' {
|
'compose-down' {
|
||||||
docker compose down
|
docker compose down
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'compose down failed' }
|
if ($LASTEXITCODE -ne 0) { throw 'docker compose down failed' }
|
||||||
|
Write-Host '[ok] compose stopped' -ForegroundColor Green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
kardsvc "github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/config"
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
|
appsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
appsvc.SetAppVersion(version)
|
||||||
cfg, err := config.Parse()
|
cfg, err := config.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("配置解析失败: %v", err)
|
log.Fatalf("配置解析失败: %v", err)
|
||||||
@@ -25,7 +27,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prg := &program{cfg: cfg}
|
prg := &program{cfg: cfg}
|
||||||
svc, err := kardsvc.New(prg, svcCfg)
|
svc, err := service.New(prg, svcCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("创建系统服务失败: %v", err)
|
log.Fatalf("创建系统服务失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -9,11 +9,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
kardsvc "github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/config"
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
"git.iioio.com/freefire/jiang13-forum/models"
|
forumsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||||
"git.iioio.com/freefire/jiang13-forum/routers"
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -28,7 +29,7 @@ type program struct {
|
|||||||
server *http.Server
|
server *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *program) Start(s kardsvc.Service) error {
|
func (p *program) Start(s service.Service) error {
|
||||||
if err := p.setup(); err != nil {
|
if err := p.setup(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ func (p *program) Start(s kardsvc.Service) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *program) Stop(s kardsvc.Service) error {
|
func (p *program) Stop(s service.Service) error {
|
||||||
log.Println("收到关机信号,正在优雅关闭...")
|
log.Println("收到关机信号,正在优雅关闭...")
|
||||||
if p.server == nil {
|
if p.server == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -57,14 +58,13 @@ func (p *program) Stop(s kardsvc.Service) error {
|
|||||||
|
|
||||||
func (p *program) setup() error {
|
func (p *program) setup() error {
|
||||||
cfg := p.cfg
|
cfg := p.cfg
|
||||||
cfg.Version = version
|
|
||||||
|
|
||||||
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("打开日志文件失败: %w", err)
|
return fmt.Errorf("打开日志文件失败: %w", err)
|
||||||
}
|
}
|
||||||
// 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写
|
// 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写
|
||||||
if kardsvc.Interactive() {
|
if service.Interactive() {
|
||||||
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
||||||
} else {
|
} else {
|
||||||
log.SetOutput(logFile)
|
log.SetOutput(logFile)
|
||||||
@@ -76,56 +76,47 @@ func (p *program) setup() error {
|
|||||||
log.Printf(" 版本: %s", version)
|
log.Printf(" 版本: %s", version)
|
||||||
log.Println("========================================")
|
log.Println("========================================")
|
||||||
|
|
||||||
if err := models.InitDB(models.DatabaseConfig{
|
if err := model.InitDB(cfg.DBPath()); err != nil {
|
||||||
Type: cfg.DB.Type,
|
|
||||||
DSN: cfg.DB.DSN,
|
|
||||||
SQLitePath: cfg.DB.SQLitePath,
|
|
||||||
MaxOpenConns: cfg.DB.MaxOpenConns,
|
|
||||||
MaxIdleConns: cfg.DB.MaxIdleConns,
|
|
||||||
ConnMaxLifetimeSec: cfg.DB.ConnMaxLifetimeSec,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("数据库初始化失败: %w", err)
|
return fmt.Errorf("数据库初始化失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := forumsvc.BackfillModerationNotifyRefs(); err != nil {
|
||||||
|
log.Printf("待审通知关联字段回填警告: %v", err)
|
||||||
|
}
|
||||||
|
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
|
||||||
|
return fmt.Errorf("监控库初始化失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
engine, err := routers.Setup(cfg)
|
engine, err := router.Setup(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("路由初始化失败: %w", err)
|
return fmt.Errorf("路由初始化失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := cfg.ListenAddr()
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
p.server = &http.Server{
|
p.server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: engine,
|
Handler: engine,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("姜十三论坛已启动: http://localhost:%d", cfg.Port)
|
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
||||||
log.Printf("后台管理地址: http://localhost:%d/admin/dashboard", cfg.Port)
|
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
||||||
log.Printf("工作目录: %s", cfg.WorkPath)
|
log.Printf("工作目录: %s", cfg.WorkPath)
|
||||||
|
log.Printf("配置文件: %s", cfg.ConfigFile)
|
||||||
log.Printf("数据目录: %s", cfg.DataDir)
|
log.Printf("数据目录: %s", cfg.DataDir)
|
||||||
log.Printf("数据库: %s", cfg.DB.Type)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildServiceConfig(cfg *config.Config) (*kardsvc.Config, error) {
|
func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
|
||||||
// 服务绑定工作目录与数据目录;改端口 / DB_* 需重启进程(可用 Env 或重装服务参数)
|
// 服务只绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务
|
||||||
args := []string{
|
return &service.Config{
|
||||||
"--work-path", cfg.WorkPath,
|
|
||||||
"--data", cfg.DataDir,
|
|
||||||
"--port", fmt.Sprintf("%d", cfg.Port),
|
|
||||||
"--db-type", cfg.DB.Type,
|
|
||||||
}
|
|
||||||
if cfg.DB.Type == config.DBTypeSQLite {
|
|
||||||
args = append(args, "--db-dsn", cfg.DB.SQLitePath)
|
|
||||||
} else if cfg.DB.DSN != "" {
|
|
||||||
args = append(args, "--db-dsn", cfg.DB.DSN)
|
|
||||||
}
|
|
||||||
return &kardsvc.Config{
|
|
||||||
Name: svcName,
|
Name: svcName,
|
||||||
DisplayName: svcDisplayName,
|
DisplayName: svcDisplayName,
|
||||||
Description: svcDescription,
|
Description: svcDescription,
|
||||||
WorkingDirectory: cfg.WorkPath,
|
WorkingDirectory: cfg.WorkPath,
|
||||||
Arguments: args,
|
Arguments: []string{
|
||||||
Option: kardsvc.KeyValue{
|
"--work-path", cfg.WorkPath,
|
||||||
|
"--config", cfg.ConfigFile,
|
||||||
|
},
|
||||||
|
Option: service.KeyValue{
|
||||||
// systemd:异常退出后自动拉起
|
// systemd:异常退出后自动拉起
|
||||||
"Restart": "always",
|
"Restart": "always",
|
||||||
// Windows:崩溃后重启
|
// Windows:崩溃后重启
|
||||||
@@ -134,16 +125,16 @@ func buildServiceConfig(cfg *config.Config) (*kardsvc.Config, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runServiceControl(s kardsvc.Service, action string) error {
|
func runServiceControl(s service.Service, action string) error {
|
||||||
if action == "status" {
|
if action == "status" {
|
||||||
st, err := s.Status()
|
st, err := s.Status()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
switch st {
|
switch st {
|
||||||
case kardsvc.StatusRunning:
|
case service.StatusRunning:
|
||||||
fmt.Println("服务状态: 运行中 (running)")
|
fmt.Println("服务状态: 运行中 (running)")
|
||||||
case kardsvc.StatusStopped:
|
case service.StatusStopped:
|
||||||
fmt.Println("服务状态: 已停止 (stopped)")
|
fmt.Println("服务状态: 已停止 (stopped)")
|
||||||
default:
|
default:
|
||||||
fmt.Println("服务状态: 未知 (unknown)")
|
fmt.Println("服务状态: 未知 (unknown)")
|
||||||
@@ -151,7 +142,7 @@ func runServiceControl(s kardsvc.Service, action string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := kardsvc.Control(s, action); err != nil {
|
if err := service.Control(s, action); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
410
cmd/monitor-geo-check/main.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
// 只读诊断:检查 Geo 库与 page_views 地理字段,或对指定 IP 试查中文映射结果。
|
||||||
|
//
|
||||||
|
// go run ./cmd/monitor-geo-check -db data/monitor.db
|
||||||
|
// go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dbPath := flag.String("db", filepath.Join("data", "monitor.db"), "含 page_views 的 SQLite(默认 data/monitor.db)")
|
||||||
|
ipLookup := flag.String("ip", "", "对指定 IP 试查 BIN+ASN(不写库)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
abs, err := filepath.Abs(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("解析路径失败: %v", err)
|
||||||
|
}
|
||||||
|
dataDir := filepath.Dir(abs)
|
||||||
|
|
||||||
|
if *ipLookup == "" {
|
||||||
|
if st, err := os.Stat(abs); err != nil || st.IsDir() {
|
||||||
|
fatalf("数据库不存在: %s", abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("数据库: %s\n", abs)
|
||||||
|
fmt.Printf("数据目录: %s\n\n", dataDir)
|
||||||
|
|
||||||
|
suite := service.NewGeoIPService(dataDir)
|
||||||
|
defer suite.Close()
|
||||||
|
printGeoFiles(dataDir, suite)
|
||||||
|
|
||||||
|
if ip := strings.TrimSpace(*ipLookup); ip != "" {
|
||||||
|
lookupIP(suite, ip)
|
||||||
|
fmt.Println()
|
||||||
|
if _, err := os.Stat(abs); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(abs), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("打开数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("获取连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if !tableExists(db, "page_views") {
|
||||||
|
fatalf("表 page_views 不存在(请确认指向 data/monitor.db,或尚未写入过浏览量)")
|
||||||
|
}
|
||||||
|
|
||||||
|
have := columnSet(db)
|
||||||
|
printColumnPresence(have)
|
||||||
|
|
||||||
|
since := time.Now().AddDate(0, 0, -30)
|
||||||
|
var total, recentNonBot, withCountry, withRegion, withCity, withASN int64
|
||||||
|
_ = db.Table("page_views").Count(&total).Error
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ?", since, false).Count(&recentNonBot).Error
|
||||||
|
|
||||||
|
fmt.Println("=== page_views 统计(近 30 日非 bot)===")
|
||||||
|
fmt.Printf("总量: %d\n", total)
|
||||||
|
fmt.Printf("近 30 日非 bot: %d\n", recentNonBot)
|
||||||
|
|
||||||
|
if have["country"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND country <> ''", since, false).Count(&withCountry).Error
|
||||||
|
fmt.Printf("有国家: %d\n", withCountry)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有国家: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["region"] || have["region_iso"] {
|
||||||
|
cond := "created_at >= ? AND is_bot = ?"
|
||||||
|
parts := []string{}
|
||||||
|
if have["region"] {
|
||||||
|
parts = append(parts, "region <> ''")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
parts = append(parts, "region_iso <> ''")
|
||||||
|
}
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Where(cond+" AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||||
|
Count(&withRegion).Error
|
||||||
|
fmt.Printf("有省字段: %d\n", withRegion)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有省字段: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["city"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND city <> ''", since, false).Count(&withCity).Error
|
||||||
|
fmt.Printf("有城市: %d\n", withCity)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有城市: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["asn"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND asn > 0", since, false).Count(&withASN).Error
|
||||||
|
fmt.Printf("有 ASN: %d\n", withASN)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有 ASN: (列缺失)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("=== 近 30 日省级 Top(country / region / region_iso)===")
|
||||||
|
if !(have["region"] || have["region_iso"]) {
|
||||||
|
fmt.Println("(省列尚未迁移,跳过)")
|
||||||
|
} else {
|
||||||
|
type regionRow struct {
|
||||||
|
Country string
|
||||||
|
Region string
|
||||||
|
RegionISO string `gorm:"column:region_iso"`
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
parts := []string{}
|
||||||
|
if have["region"] {
|
||||||
|
parts = append(parts, "region <> ''")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
parts = append(parts, "region_iso <> ''")
|
||||||
|
}
|
||||||
|
selParts := []string{}
|
||||||
|
groupParts := []string{}
|
||||||
|
if have["country"] {
|
||||||
|
selParts = append(selParts, "country")
|
||||||
|
groupParts = append(groupParts, "country")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as country")
|
||||||
|
}
|
||||||
|
if have["region"] {
|
||||||
|
selParts = append(selParts, "region")
|
||||||
|
groupParts = append(groupParts, "region")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as region")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
selParts = append(selParts, "region_iso")
|
||||||
|
groupParts = append(groupParts, "region_iso")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as region_iso")
|
||||||
|
}
|
||||||
|
selParts = append(selParts, "COUNT(*) as count")
|
||||||
|
var regions []regionRow
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(strings.Join(selParts, ", ")).
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||||
|
Group(strings.Join(groupParts, ", ")).
|
||||||
|
Order("count DESC").
|
||||||
|
Limit(20).
|
||||||
|
Scan(®ions).Error
|
||||||
|
if len(regions) == 0 {
|
||||||
|
fmt.Println("(空)")
|
||||||
|
} else {
|
||||||
|
for i, r := range regions {
|
||||||
|
fmt.Printf("%2d. %s | %s | %s | %d\n", i+1, r.Country, r.Region, r.RegionISO, r.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("=== 对账抽样(近 30 日非 bot)===")
|
||||||
|
printGeoReconcileSamples(db, have, since)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
|
||||||
|
cols := []string{"id", "created_at", "path", "is_bot"}
|
||||||
|
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||||
|
if have[c] {
|
||||||
|
cols = append(cols, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type sample struct {
|
||||||
|
ID uint
|
||||||
|
CreatedAt time.Time
|
||||||
|
IP string
|
||||||
|
Country string
|
||||||
|
Region string
|
||||||
|
RegionISO string `gorm:"column:region_iso"`
|
||||||
|
City string
|
||||||
|
ASN uint
|
||||||
|
ASOrg string `gorm:"column:as_org"`
|
||||||
|
Path string
|
||||||
|
IsBot bool
|
||||||
|
}
|
||||||
|
var samples []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(strings.Join(cols, ", ")).
|
||||||
|
Where("created_at >= ?", since).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(10).
|
||||||
|
Scan(&samples).Error
|
||||||
|
if len(samples) == 0 {
|
||||||
|
fmt.Println("(无近 30 日 pageview)")
|
||||||
|
} else {
|
||||||
|
for _, s := range samples {
|
||||||
|
fmt.Printf("#%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q bot=%v path=%s\n",
|
||||||
|
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||||
|
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.IsBot, s.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
regionReady := have["region"] || have["region_iso"]
|
||||||
|
if !regionReady || withRegion == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
fmt.Fprintln(os.Stderr, "提示: 近 30 日无省级字段。中国地图「暂无省级访问数据」属预期。")
|
||||||
|
if !regionReady {
|
||||||
|
fmt.Fprintln(os.Stderr, "当前库缺 region/region_iso 列:请先启动一次主程序以完成 AutoMigrate。")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。")
|
||||||
|
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
|
||||||
|
fmt.Fprintln(os.Stderr, "注意: 请求日志有地理 ≠ 概览地图/排行有数据(后者只读 page_views)。")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printGeoReconcileSamples(db *gorm.DB, have map[string]bool, since time.Time) {
|
||||||
|
type sample struct {
|
||||||
|
ID uint
|
||||||
|
CreatedAt time.Time
|
||||||
|
IP string
|
||||||
|
Country string
|
||||||
|
Region string
|
||||||
|
RegionISO string `gorm:"column:region_iso"`
|
||||||
|
City string
|
||||||
|
ASN uint
|
||||||
|
ASOrg string `gorm:"column:as_org"`
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
selCols := []string{"id", "created_at", "path"}
|
||||||
|
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||||
|
if have[c] {
|
||||||
|
selCols = append(selCols, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sel := strings.Join(selCols, ", ")
|
||||||
|
|
||||||
|
printBlock := func(title string, n int64, rows []sample) {
|
||||||
|
fmt.Printf("%s: %d\n", title, n)
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range rows {
|
||||||
|
fmt.Printf(" #%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q path=%s\n",
|
||||||
|
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||||
|
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if have["city"] {
|
||||||
|
var n int64
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||||
|
Count(&n).Error
|
||||||
|
var rows []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(sel).
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(5).
|
||||||
|
Scan(&rows).Error
|
||||||
|
printBlock("英文城市名(可能未中文化)", n, rows)
|
||||||
|
} else {
|
||||||
|
fmt.Println("英文城市名: (city 列缺失)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if have["region"] || have["region_iso"] {
|
||||||
|
cond := "created_at >= ? AND is_bot = ? AND country <> ''"
|
||||||
|
emptyParts := []string{}
|
||||||
|
if have["region"] {
|
||||||
|
emptyParts = append(emptyParts, "(region = '' OR region IS NULL OR region = '-')")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
emptyParts = append(emptyParts, "(region_iso = '' OR region_iso IS NULL)")
|
||||||
|
}
|
||||||
|
where := cond + " AND (" + strings.Join(emptyParts, " AND ") + ")"
|
||||||
|
var n int64
|
||||||
|
_ = db.Table("page_views").Where(where, since, false).Count(&n).Error
|
||||||
|
var rows []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(sel).
|
||||||
|
Where(where, since, false).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(5).
|
||||||
|
Scan(&rows).Error
|
||||||
|
printBlock("有国家但省为空", n, rows)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有国家但省为空: (省列缺失)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if have["asn"] {
|
||||||
|
var n int64
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||||
|
Count(&n).Error
|
||||||
|
var rows []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(sel).
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(5).
|
||||||
|
Scan(&rows).Error
|
||||||
|
printBlock("asn=0(无运营商)", n, rows)
|
||||||
|
} else {
|
||||||
|
fmt.Println("asn=0: (asn 列缺失)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
|
||||||
|
fmt.Println("=== Geo 数据文件 ===")
|
||||||
|
v4, v6, asn, country := suite.Paths()
|
||||||
|
for _, item := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"IP2LOCATION-LITE-DB3.BIN", v4, suite.BINV4Available()},
|
||||||
|
{"IP2LOCATION-LITE-DB3.IPV6.BIN", v6, suite.BINV6Available()},
|
||||||
|
{"GeoLite2-ASN.mmdb", asn, suite.ASNAvailable()},
|
||||||
|
{"GeoLite2-Country.mmdb", country, suite.CountryAvailable()},
|
||||||
|
} {
|
||||||
|
st, err := os.Stat(item.path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("%s: 不存在\n", item.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loaded := "未加载"
|
||||||
|
if item.ok {
|
||||||
|
loaded = "已加载"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: 存在 (%d bytes, mtime %s) · %s\n", item.name, st.Size(), st.ModTime().Format(time.RFC3339), loaded)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupIP(suite *service.GeoIPService, ipStr string) {
|
||||||
|
fmt.Printf("=== IP 试查 %s(中文映射后)===\n", ipStr)
|
||||||
|
info := suite.Lookup(ipStr)
|
||||||
|
if info.Country == "" && info.Region == "" && info.City == "" && info.ASN == 0 {
|
||||||
|
fmt.Println("未解析到地理信息(请确认 BIN/ASN 文件存在且 IP 为公网地址)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("国家 ISO: %s (%s)\n", info.Country, service.CountryLabelZh(info.Country))
|
||||||
|
if info.Region != "" || info.RegionISO != "" {
|
||||||
|
fmt.Printf("省/州: %s / %s\n", info.Region, info.RegionISO)
|
||||||
|
}
|
||||||
|
if info.City != "" {
|
||||||
|
fmt.Printf("城市: %s\n", info.City)
|
||||||
|
}
|
||||||
|
if info.ASN > 0 || info.ASOrg != "" {
|
||||||
|
fmt.Printf("ASN: AS%d %s\n", info.ASN, info.ASOrg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableExists(db *gorm.DB, name string) bool {
|
||||||
|
var n int
|
||||||
|
err := db.Raw(
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
|
||||||
|
).Scan(&n).Error
|
||||||
|
return err == nil && n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnSet(db *gorm.DB) map[string]bool {
|
||||||
|
type colRow struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var cols []colRow
|
||||||
|
_ = db.Raw(`PRAGMA table_info(page_views)`).Scan(&cols).Error
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, c := range cols {
|
||||||
|
have[c.Name] = true
|
||||||
|
}
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
|
func printColumnPresence(have map[string]bool) {
|
||||||
|
need := []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"}
|
||||||
|
fmt.Println("=== page_views 列 ===")
|
||||||
|
missing := false
|
||||||
|
for _, n := range need {
|
||||||
|
if have[n] {
|
||||||
|
fmt.Printf("%s: 有\n", n)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s: 缺失\n", n)
|
||||||
|
missing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
if missing {
|
||||||
|
fmt.Fprintln(os.Stderr, "提示: 缺列时请先启动一次主程序以完成监控库 AutoMigrate。")
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatalf(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
337
config/config.go
@@ -1,79 +1,53 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||||
const (
|
const (
|
||||||
defaultPort = 3000
|
|
||||||
defaultDataRel = "data"
|
|
||||||
|
|
||||||
DBTypeSQLite = "sqlite"
|
|
||||||
DBTypePostgres = "postgres"
|
|
||||||
DBTypeMySQL = "mysql"
|
|
||||||
|
|
||||||
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
|
||||||
StorageTypeLocal = "local"
|
StorageTypeLocal = "local"
|
||||||
StorageTypeS3 = "s3"
|
StorageTypeS3 = "s3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DatabaseConfig 数据库引导配置(需重启)
|
// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
|
||||||
type DatabaseConfig struct {
|
|
||||||
Type string // sqlite | postgres | mysql
|
|
||||||
DSN string // 非空则优先
|
|
||||||
Host string
|
|
||||||
User string
|
|
||||||
Password string
|
|
||||||
Name string
|
|
||||||
SSLMode string // postgres
|
|
||||||
// SQLite 文件路径(Type=sqlite 时由 DataDir 推导或 DSN)
|
|
||||||
SQLitePath string
|
|
||||||
|
|
||||||
MaxOpenConns int
|
|
||||||
MaxIdleConns int
|
|
||||||
ConnMaxLifetimeSec int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config 进程引导配置:仅 CLI / 环境变量(无 INI)
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
WorkPath string
|
// 工作目录(默认可执行文件所在目录)
|
||||||
HTTPAddr string // 空表示 0.0.0.0
|
WorkPath string
|
||||||
Port int
|
// 配置文件绝对路径
|
||||||
DataDir string
|
ConfigFile string
|
||||||
JWTSecret string
|
// 监听端口
|
||||||
LogFile string
|
Port int
|
||||||
|
// 数据目录:SQLite、上传、日志(绝对路径)
|
||||||
|
DataDir string
|
||||||
|
// JWT 签名密钥
|
||||||
|
JWTSecret string
|
||||||
|
// 日志文件路径
|
||||||
|
LogFile string
|
||||||
|
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||||
ServiceAction string
|
ServiceAction string
|
||||||
DevMode bool
|
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
||||||
Version string // 构建版本(ldflags);用于 /ssr-assets ?v= 缓存穿透
|
DevMode bool
|
||||||
DB DatabaseConfig
|
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
|
||||||
|
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
|
||||||
|
CommunityHub bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse 解析命令行与环境变量并准备数据目录
|
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
|
||||||
// 优先级:命令行显式参数 > 环境变量 > 内置默认
|
//
|
||||||
|
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
|
||||||
func Parse() (*Config, error) {
|
func Parse() (*Config, error) {
|
||||||
|
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
|
||||||
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
||||||
portFlag := flag.Int("port", 0, "HTTP 监听端口(0 表示用环境变量或默认 3000)")
|
portFlag := flag.Int("port", 0, "HTTP 监听端口(覆盖配置文件;0 表示不覆盖)")
|
||||||
addrFlag := flag.String("http-addr", "", "HTTP 监听地址(默认空=全接口)")
|
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
|
||||||
dataFlag := flag.String("data", "", "数据存储目录")
|
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
|
||||||
dbTypeFlag := flag.String("db-type", "", "数据库类型:sqlite|postgres|mysql")
|
|
||||||
dbDSNFlag := flag.String("db-dsn", "", "数据库 DSN(优先于拆分参数)")
|
|
||||||
dbHostFlag := flag.String("db-host", "", "数据库主机")
|
|
||||||
dbUserFlag := flag.String("db-user", "", "数据库用户")
|
|
||||||
dbPassFlag := flag.String("db-pass", "", "数据库密码")
|
|
||||||
dbNameFlag := flag.String("db-name", "", "数据库名")
|
|
||||||
dbSSLFlag := flag.String("db-sslmode", "", "PostgreSQL sslmode")
|
|
||||||
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
||||||
devFlag := flag.Bool("dev", false, "开发模式")
|
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
|
||||||
_ = flag.String("config", "", "已废弃:不再使用 ini 配置文件")
|
|
||||||
_ = flag.String("jwt-secret", "", "已废弃:JWT 仅使用 data/.jwt_secret")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
||||||
@@ -81,13 +55,35 @@ func Parse() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
||||||
}
|
}
|
||||||
|
|
||||||
workPathInput := firstNonEmpty(*workFlag, envOrDefault(envWorkPath))
|
workPathInput := strings.TrimSpace(*workFlag)
|
||||||
|
if workPathInput == "" {
|
||||||
|
workPathInput = envOrDefault(envWorkPath)
|
||||||
|
}
|
||||||
workPath, err := resolveWorkPath(workPathInput)
|
workPath, err := resolveWorkPath(workPathInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
port := defaultPort
|
configInput := strings.TrimSpace(*configFlag)
|
||||||
|
if configInput == "" {
|
||||||
|
configInput = envOrDefault(envConfig)
|
||||||
|
}
|
||||||
|
configFile, err := resolveConfigPath(workPath, configInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCfg := defaultFileSettings()
|
||||||
|
configExists := false
|
||||||
|
if st, err := os.Stat(configFile); err == nil && !st.IsDir() {
|
||||||
|
configExists = true
|
||||||
|
fileCfg, err = loadAppINI(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
port := fileCfg.Port
|
||||||
if p := envIntOrZero(envHTTPPort); p > 0 {
|
if p := envIntOrZero(envHTTPPort); p > 0 {
|
||||||
port = p
|
port = p
|
||||||
}
|
}
|
||||||
@@ -95,35 +91,71 @@ func Parse() (*Config, error) {
|
|||||||
port = *portFlag
|
port = *portFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
httpAddr := firstNonEmpty(*addrFlag, envOrDefault(envHTTPAddr))
|
dataInput := fileCfg.DataRel
|
||||||
|
if v := envOrDefault(envData); v != "" {
|
||||||
dataInput := firstNonEmpty(*dataFlag, envOrDefault(envData), defaultDataRel)
|
dataInput = v
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*dataFlag) != "" {
|
||||||
|
dataInput = *dataFlag
|
||||||
|
}
|
||||||
absData, err := absPath(workPath, dataInput)
|
absData, err := absPath(workPath, dataInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbCfg, err := buildDatabaseConfig(absData, dbFlags{
|
jwtSecret := fileCfg.JWTSecret
|
||||||
Type: *dbTypeFlag, DSN: *dbDSNFlag, Host: *dbHostFlag,
|
if v := envOrDefault(envJWTSecret); v != "" {
|
||||||
User: *dbUserFlag, Pass: *dbPassFlag, Name: *dbNameFlag, SSL: *dbSSLFlag,
|
jwtSecret = v
|
||||||
})
|
}
|
||||||
if err != nil {
|
if strings.TrimSpace(*jwtFlag) != "" {
|
||||||
return nil, err
|
jwtSecret = strings.TrimSpace(*jwtFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
communityHub := fileCfg.CommunityHub
|
||||||
|
if v := envBoolOrNil(envCommunityHub); v != nil {
|
||||||
|
communityHub = *v
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
WorkPath: workPath,
|
WorkPath: workPath,
|
||||||
HTTPAddr: httpAddr,
|
ConfigFile: configFile,
|
||||||
Port: port,
|
Port: port,
|
||||||
DataDir: absData,
|
DataDir: absData,
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||||
ServiceAction: action,
|
ServiceAction: action,
|
||||||
DevMode: *devFlag,
|
DevMode: *devFlag,
|
||||||
DB: dbCfg,
|
CommunityHub: communityHub,
|
||||||
}
|
}
|
||||||
|
|
||||||
needDirs := action == "" || action == "install"
|
needDirs := action == "" || action == "install"
|
||||||
if needDirs {
|
if needDirs {
|
||||||
|
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||||||
|
if !configExists {
|
||||||
|
dataRel := resolveDataRelForINI(workPath, absData)
|
||||||
|
if err := writeAppINI(configFile, fileSettings{
|
||||||
|
Port: port,
|
||||||
|
DataRel: dataRel,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
|
||||||
|
} else if action == "install" {
|
||||||
|
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
|
||||||
|
dataRel := resolveDataRelForINI(workPath, absData)
|
||||||
|
iniJWT := fileCfg.JWTSecret
|
||||||
|
if strings.TrimSpace(*jwtFlag) != "" {
|
||||||
|
iniJWT = jwtSecret
|
||||||
|
}
|
||||||
|
if err := writeAppINI(configFile, fileSettings{
|
||||||
|
Port: port,
|
||||||
|
DataRel: dataRel,
|
||||||
|
JWTSecret: iniJWT,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("更新配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := ensureDataDirs(absData); err != nil {
|
if err := ensureDataDirs(absData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -135,96 +167,6 @@ func Parse() (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type dbFlags struct {
|
|
||||||
Type, DSN, Host, User, Pass, Name, SSL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDatabaseConfig(dataDir string, f dbFlags) (DatabaseConfig, error) {
|
|
||||||
typ := strings.ToLower(firstNonEmpty(f.Type, envOrDefault(envDBType), DBTypeSQLite))
|
|
||||||
switch typ {
|
|
||||||
case "sqlite", "sqlite3":
|
|
||||||
typ = DBTypeSQLite
|
|
||||||
case "postgres", "postgresql", "pg":
|
|
||||||
typ = DBTypePostgres
|
|
||||||
case "mysql", "mariadb":
|
|
||||||
typ = DBTypeMySQL
|
|
||||||
default:
|
|
||||||
return DatabaseConfig{}, fmt.Errorf("不支持的数据库类型 %q,可选:sqlite|postgres|mysql", typ)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := DatabaseConfig{
|
|
||||||
Type: typ,
|
|
||||||
DSN: firstNonEmpty(f.DSN, envOrDefault(envDBDSN)),
|
|
||||||
Host: firstNonEmpty(f.Host, envOrDefault(envDBHost)),
|
|
||||||
User: firstNonEmpty(f.User, envOrDefault(envDBUser)),
|
|
||||||
Password: firstNonEmpty(f.Pass, envOrDefault(envDBPass)),
|
|
||||||
Name: firstNonEmpty(f.Name, envOrDefault(envDBName)),
|
|
||||||
SSLMode: firstNonEmpty(f.SSL, envOrDefault(envDBSSLMode), "disable"),
|
|
||||||
MaxOpenConns: envIntDefault(envDBMaxOpen, 0),
|
|
||||||
MaxIdleConns: envIntDefault(envDBMaxIdle, 0),
|
|
||||||
ConnMaxLifetimeSec: envIntDefault(envDBConnLife, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
if typ == DBTypeSQLite {
|
|
||||||
if out.DSN != "" {
|
|
||||||
out.SQLitePath = out.DSN
|
|
||||||
} else {
|
|
||||||
out.SQLitePath = filepath.Join(dataDir, "jiang13.db")
|
|
||||||
out.DSN = out.SQLitePath
|
|
||||||
}
|
|
||||||
if out.MaxOpenConns == 0 {
|
|
||||||
out.MaxOpenConns = 1
|
|
||||||
}
|
|
||||||
if out.MaxIdleConns == 0 {
|
|
||||||
out.MaxIdleConns = 1
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if out.DSN == "" {
|
|
||||||
dsn, err := buildDSN(out)
|
|
||||||
if err != nil {
|
|
||||||
return DatabaseConfig{}, err
|
|
||||||
}
|
|
||||||
out.DSN = dsn
|
|
||||||
}
|
|
||||||
if out.MaxOpenConns == 0 {
|
|
||||||
out.MaxOpenConns = 25
|
|
||||||
}
|
|
||||||
if out.MaxIdleConns == 0 {
|
|
||||||
out.MaxIdleConns = 5
|
|
||||||
}
|
|
||||||
if out.ConnMaxLifetimeSec == 0 {
|
|
||||||
out.ConnMaxLifetimeSec = 300
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDSN(c DatabaseConfig) (string, error) {
|
|
||||||
if c.Host == "" || c.User == "" || c.Name == "" {
|
|
||||||
return "", fmt.Errorf("%s 需要 JIANG13_DB_DSN,或 JIANG13_DB_HOST/USER/NAME(及可选 PASS)", c.Type)
|
|
||||||
}
|
|
||||||
switch c.Type {
|
|
||||||
case DBTypePostgres:
|
|
||||||
u := url.URL{
|
|
||||||
Scheme: "postgres",
|
|
||||||
User: url.UserPassword(c.User, c.Password),
|
|
||||||
Host: c.Host,
|
|
||||||
Path: "/" + c.Name,
|
|
||||||
}
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("sslmode", c.SSLMode)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
return u.String(), nil
|
|
||||||
case DBTypeMySQL:
|
|
||||||
// 特殊字符密码请直接用 JIANG13_DB_DSN;此处为拆分参数简易拼接
|
|
||||||
return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true&loc=Local&charset=utf8mb4",
|
|
||||||
c.User, c.Password, c.Host, c.Name), nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("无法为 %s 拼接 DSN", c.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveWorkPath(flagVal string) (string, error) {
|
func resolveWorkPath(flagVal string) (string, error) {
|
||||||
if strings.TrimSpace(flagVal) != "" {
|
if strings.TrimSpace(flagVal) != "" {
|
||||||
abs, err := filepath.Abs(flagVal)
|
abs, err := filepath.Abs(flagVal)
|
||||||
@@ -236,6 +178,13 @@ func resolveWorkPath(flagVal string) (string, error) {
|
|||||||
return defaultWorkPath()
|
return defaultWorkPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveConfigPath(workPath, flagVal string) (string, error) {
|
||||||
|
if strings.TrimSpace(flagVal) != "" {
|
||||||
|
return absPath(workPath, flagVal)
|
||||||
|
}
|
||||||
|
return filepath.Join(workPath, defaultConfName), nil
|
||||||
|
}
|
||||||
|
|
||||||
func ensureDataDirs(dataDir string) error {
|
func ensureDataDirs(dataDir string) error {
|
||||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
return fmt.Errorf("创建数据目录失败: %w", err)
|
return fmt.Errorf("创建数据目录失败: %w", err)
|
||||||
@@ -244,6 +193,7 @@ func ensureDataDirs(dataDir string) error {
|
|||||||
filepath.Join(dataDir, "uploads", "avatars"),
|
filepath.Join(dataDir, "uploads", "avatars"),
|
||||||
filepath.Join(dataDir, "uploads", "posts"),
|
filepath.Join(dataDir, "uploads", "posts"),
|
||||||
filepath.Join(dataDir, "uploads", "site"),
|
filepath.Join(dataDir, "uploads", "site"),
|
||||||
|
filepath.Join(dataDir, "logs", "access"),
|
||||||
} {
|
} {
|
||||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
return fmt.Errorf("创建上传目录失败: %w", err)
|
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||||
@@ -254,25 +204,21 @@ func ensureDataDirs(dataDir string) error {
|
|||||||
|
|
||||||
func (c *Config) resolveJWT() error {
|
func (c *Config) resolveJWT() error {
|
||||||
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
|
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
|
||||||
if data, err := os.ReadFile(secretFile); err == nil && len(bytesTrimSpace(data)) > 0 {
|
if c.JWTSecret != "" {
|
||||||
c.JWTSecret = string(bytesTrimSpace(data))
|
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
sec, err := generateRandomSecret(32)
|
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
||||||
if err != nil {
|
c.JWTSecret = string(data)
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
c.JWTSecret = sec
|
c.JWTSecret = generateRandomSecret(32)
|
||||||
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
|
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
|
||||||
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
|
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func bytesTrimSpace(b []byte) []byte {
|
|
||||||
return []byte(strings.TrimSpace(string(b)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func validServiceAction(action string) bool {
|
func validServiceAction(action string) bool {
|
||||||
switch action {
|
switch action {
|
||||||
case "install", "uninstall", "start", "stop", "restart", "status":
|
case "install", "uninstall", "start", "stop", "restart", "status":
|
||||||
@@ -282,60 +228,41 @@ func validServiceAction(action string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenAddr 返回 host:port
|
// DBPath 返回主库 SQLite 路径
|
||||||
func (c *Config) ListenAddr() string {
|
|
||||||
if c.HTTPAddr == "" {
|
|
||||||
return fmt.Sprintf(":%d", c.Port)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s:%d", c.HTTPAddr, c.Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQLitePath 兼容旧调用:仅 sqlite 有意义
|
|
||||||
func (c *Config) DBPath() string {
|
func (c *Config) DBPath() string {
|
||||||
if c.DB.Type == DBTypeSQLite {
|
return filepath.Join(c.DataDir, "jiang13.db")
|
||||||
return c.DB.SQLitePath
|
|
||||||
}
|
|
||||||
return c.DB.DSN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MonitorDBPath 返回监控独立库路径(page_views)
|
||||||
|
func (c *Config) MonitorDBPath() string {
|
||||||
|
return filepath.Join(c.DataDir, "monitor.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarUploadDir 返回头像上传目录
|
||||||
func (c *Config) AvatarUploadDir() string {
|
func (c *Config) AvatarUploadDir() string {
|
||||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostImageUploadDir 返回帖子正文图片上传目录
|
||||||
func (c *Config) PostImageUploadDir() string {
|
func (c *Config) PostImageUploadDir() string {
|
||||||
return filepath.Join(c.DataDir, "uploads", "posts")
|
return filepath.Join(c.DataDir, "uploads", "posts")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteUploadDir 返回站点品牌资源(Logo / Favicon)目录
|
||||||
func (c *Config) SiteUploadDir() string {
|
func (c *Config) SiteUploadDir() string {
|
||||||
return filepath.Join(c.DataDir, "uploads", "site")
|
return filepath.Join(c.DataDir, "uploads", "site")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterWordsPath 返回敏感词配置文件路径
|
||||||
func (c *Config) FilterWordsPath() string {
|
func (c *Config) FilterWordsPath() string {
|
||||||
return filepath.Join(c.DataDir, "filter_words.txt")
|
return filepath.Join(c.DataDir, "filter_words.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateRandomSecret(n int) (string, error) {
|
func generateRandomSecret(n int) string {
|
||||||
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
if _, err := rand.Read(b); err != nil {
|
for i := range b {
|
||||||
return "", fmt.Errorf("生成密钥失败: %w", err)
|
b[i] = chars[i%len(chars)]
|
||||||
}
|
}
|
||||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
return string(b)
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmpty(vals ...string) string {
|
|
||||||
for _, v := range vals {
|
|
||||||
if strings.TrimSpace(v) != "" {
|
|
||||||
return strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func envIntDefault(key string, def int) int {
|
|
||||||
v := envOrDefault(key)
|
|
||||||
if v == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(v)
|
|
||||||
if err != nil || n < 0 {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 引导环境变量(无 INI)
|
// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认)
|
||||||
const (
|
const (
|
||||||
envWorkPath = "JIANG13_WORK_PATH"
|
envWorkPath = "JIANG13_WORK_PATH"
|
||||||
envHTTPPort = "JIANG13_HTTP_PORT"
|
envConfig = "JIANG13_CONFIG"
|
||||||
envHTTPAddr = "JIANG13_HTTP_ADDR"
|
envHTTPPort = "JIANG13_HTTP_PORT"
|
||||||
envData = "JIANG13_DATA"
|
envData = "JIANG13_DATA"
|
||||||
envDBType = "JIANG13_DB_TYPE"
|
envJWTSecret = "JIANG13_JWT_SECRET"
|
||||||
envDBDSN = "JIANG13_DB_DSN"
|
envCommunityHub = "JIANG13_COMMUNITY_HUB"
|
||||||
envDBHost = "JIANG13_DB_HOST"
|
|
||||||
envDBUser = "JIANG13_DB_USER"
|
|
||||||
envDBPass = "JIANG13_DB_PASS"
|
|
||||||
envDBName = "JIANG13_DB_NAME"
|
|
||||||
envDBSSLMode = "JIANG13_DB_SSLMODE"
|
|
||||||
envDBMaxOpen = "JIANG13_DB_MAX_OPEN"
|
|
||||||
envDBMaxIdle = "JIANG13_DB_MAX_IDLE"
|
|
||||||
envDBConnLife = "JIANG13_DB_CONN_MAX_LIFETIME_SEC"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func envOrDefault(key string) string {
|
func envOrDefault(key string) string {
|
||||||
@@ -39,3 +31,21 @@ func envIntOrZero(key string) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envBoolOrNil 解析布尔环境变量;未设置返回 nil
|
||||||
|
func envBoolOrNil(key string) *bool {
|
||||||
|
v := strings.ToLower(envOrDefault(key))
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
t := true
|
||||||
|
return &t
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
114
config/ini.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/ini.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPort = 3000
|
||||||
|
defaultDataRel = "data"
|
||||||
|
defaultConfName = "app.ini"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
|
||||||
|
type fileSettings struct {
|
||||||
|
Port int
|
||||||
|
DataRel string
|
||||||
|
JWTSecret string
|
||||||
|
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFileSettings() fileSettings {
|
||||||
|
return fileSettings{
|
||||||
|
Port: defaultPort,
|
||||||
|
DataRel: defaultDataRel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAppINI(path string) (fileSettings, error) {
|
||||||
|
out := defaultFileSettings()
|
||||||
|
cfg, err := ini.LoadSources(ini.LoadOptions{
|
||||||
|
IgnoreInlineComment: true,
|
||||||
|
}, path)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("读取配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sec, err := cfg.GetSection("server"); err == nil {
|
||||||
|
if k := sec.Key("HTTP_PORT"); k.String() != "" {
|
||||||
|
p, err := k.Int()
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("server.HTTP_PORT 无效: %w", err)
|
||||||
|
}
|
||||||
|
if p <= 0 || p > 65535 {
|
||||||
|
return out, fmt.Errorf("server.HTTP_PORT 超出范围: %d", p)
|
||||||
|
}
|
||||||
|
out.Port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sec, err := cfg.GetSection("paths"); err == nil {
|
||||||
|
if v := strings.TrimSpace(sec.Key("DATA").String()); v != "" {
|
||||||
|
out.DataRel = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sec, err := cfg.GetSection("security"); err == nil {
|
||||||
|
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if sec, err := cfg.GetSection("community"); err == nil {
|
||||||
|
if k := sec.Key("HUB"); k.String() != "" {
|
||||||
|
out.CommunityHub = k.MustBool(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAppINI 写入/覆盖 app.ini(安装服务或首次生成时使用)
|
||||||
|
func writeAppINI(path string, s fileSettings) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("; 姜十三论坛 Jiang13 Forum — 配置文件(风格类似 Gitea app.ini)\n")
|
||||||
|
b.WriteString("; 修改后重启进程/服务生效。命令行参数优先级高于本文件。\n")
|
||||||
|
b.WriteString("; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。\n")
|
||||||
|
b.WriteString(";\n")
|
||||||
|
b.WriteString("; 默认位置:程序工作目录下的 app.ini\n")
|
||||||
|
b.WriteString("; 可用 --config / --work-path 覆盖。\n")
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString("[server]\n")
|
||||||
|
b.WriteString("HTTP_PORT = ")
|
||||||
|
b.WriteString(strconv.Itoa(s.Port))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString("[paths]\n")
|
||||||
|
b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
|
||||||
|
b.WriteString("DATA = ")
|
||||||
|
b.WriteString(s.DataRel)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString("[security]\n")
|
||||||
|
b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)\n")
|
||||||
|
b.WriteString("JWT_SECRET = ")
|
||||||
|
b.WriteString(s.JWTSecret)
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
return os.WriteFile(path, []byte(b.String()), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDataRelForINI 把绝对数据目录尽量写成相对工作目录的路径,便于 app.ini 可读
|
||||||
|
func resolveDataRelForINI(workPath, absData string) string {
|
||||||
|
rel, err := filepath.Rel(workPath, absData)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return absData
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
# 姜十三论坛 — Docker Compose 单服务部署
|
# 姜十三论坛 — Docker Compose 单服务部署
|
||||||
# 启动:docker compose up -d --build
|
# 启动:docker compose up -d --build
|
||||||
# 或:make compose-up / build.bat -Target compose-up
|
# 或:make compose-up / build.bat -Target compose-up
|
||||||
#
|
|
||||||
# 默认 SQLite(数据在 /data)。换库示例:
|
|
||||||
# JIANG13_DB_TYPE=postgres
|
|
||||||
# JIANG13_DB_DSN=postgres://forum:secret@host:5432/jiang13?sslmode=disable
|
|
||||||
# JIANG13_DB_TYPE=mysql
|
|
||||||
# JIANG13_DB_DSN=forum:secret@tcp(host:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
jiang13:
|
jiang13:
|
||||||
@@ -20,10 +14,12 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- jiang13-data:/data
|
- jiang13-data:/data
|
||||||
|
# 可选:挂载自定义 app.ini(只读)
|
||||||
|
# - ./app.ini:/app/app.ini:ro
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
# JIANG13_DB_TYPE: sqlite
|
# 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret)
|
||||||
# JIANG13_HTTP_PORT: "3000"
|
# JIANG13_JWT_SECRET: your-secret-here
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 15s
|
stop_grace_period: 15s
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
105
docs/docker-data.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Docker 数据持久化说明
|
||||||
|
|
||||||
|
Docker 部署时**默认只挂载一个目录**,不是分别映射「数据库文件夹」和「附件文件夹」两个卷。
|
||||||
|
|
||||||
|
## 挂载对应关系
|
||||||
|
|
||||||
|
| 宿主机 | 容器内 |
|
||||||
|
|--------|--------|
|
||||||
|
| 任意目录或 named volume(如 `jiang13-data`) | **`/data`** |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name jiang13 \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /你的路径/jiang13-data:/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 `docker-compose.yml` 中:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- jiang13-data:/data
|
||||||
|
# 或绑定宿主机目录:
|
||||||
|
# - ./data:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
## `/data` 目录结构
|
||||||
|
|
||||||
|
数据库与附件都在同一个 `/data` 挂载内:
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/
|
||||||
|
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||||
|
├── monitor.db # 网站监控 page_views
|
||||||
|
├── uploads/ # 附件根目录
|
||||||
|
│ ├── avatars/ # 用户头像
|
||||||
|
│ └── posts/ # 帖子图片等
|
||||||
|
├── logs/
|
||||||
|
│ └── access/ # 网站监控请求日志(按日 jsonl)
|
||||||
|
│ └── YYYY-MM-DD.jsonl
|
||||||
|
├── IP2LOCATION-LITE-DB3.BIN # 可选:IPv4 城市库(监控)
|
||||||
|
├── IP2LOCATION-LITE-DB3.IPV6.BIN # 可选:IPv6 城市库
|
||||||
|
├── GeoLite2-ASN.mmdb # 可选:ASN/运营商
|
||||||
|
├── GeoLite2-Country.mmdb # 可选:国家兜底
|
||||||
|
├── .jwt_secret # JWT 密钥(自动生成)
|
||||||
|
├── filter_words.txt # 敏感词配置
|
||||||
|
└── jiang13.log # 运行日志(若启用)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 用途 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| 主数据库 | `/data/jiang13.db` |
|
||||||
|
| 监控浏览量 | `/data/monitor.db` |
|
||||||
|
| 附件 | `/data/uploads/` |
|
||||||
|
| 请求日志(监控) | `/data/logs/access/` |
|
||||||
|
| 地理库(可选) | `/data/IP2LOCATION-LITE-DB3*.BIN`、`GeoLite2-ASN.mmdb` 等 |
|
||||||
|
|
||||||
|
官方镜像不要求拆成两个 volume;挂好 `/data` 即可同时持久化 SQLite、上传文件与监控日志。
|
||||||
|
|
||||||
|
地理库与 BIN **不内置**于 Docker 镜像。详见 [网站监控设计](monitor.md)。管理端展示完整客户端 IP(不做脱敏)。
|
||||||
|
|
||||||
|
## 设计说明:为何只挂一个 `/data`
|
||||||
|
|
||||||
|
对本项目(单容器、SQLite、默认可本地存附件)而言,**一个数据卷是合理默认**:
|
||||||
|
|
||||||
|
- 部署成本最低,不易漏挂导致「重启丢图 / 丢库」
|
||||||
|
- 备份、迁移时拷贝或快照整个 `/data` 即可恢复站点状态
|
||||||
|
- 密钥(`.jwt_secret`)、敏感词与库同目录,避免「库还在、登录全失效」这类半残状态
|
||||||
|
|
||||||
|
若你习惯「数据库文件夹 / 附件文件夹」分开填写:在本镜像中它们分别是 **`/data` 下的文件与子目录**,不是两个独立容器路径。
|
||||||
|
|
||||||
|
附件特别大、希望对象存储时:优先使用管理后台的 **S3 兼容存储**,而不是拆 Docker 卷。
|
||||||
|
|
||||||
|
## 后续计划(可选增强,非必须)
|
||||||
|
|
||||||
|
按优先级,仅在有明确需求时做:
|
||||||
|
|
||||||
|
1. **文档与面板提示(优先)**
|
||||||
|
- README / 1Panel 说明中固定话术:「只需挂载 `/data`;数据库=`jiang13.db`,监控=`monitor.db`,附件=`uploads/`」。
|
||||||
|
- 本页保持为权威说明,避免用户误以为漏了一个卷。
|
||||||
|
|
||||||
|
2. **进阶:同一 DataDir 下拆挂子路径(文档级,无需改代码)**
|
||||||
|
若必须把库与附件分到不同宿主机磁盘,可在仍使用容器 `/data` 的前提下分别绑定,例如:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
# 推荐:父目录一次挂载
|
||||||
|
- /ssd/jiang13:/data
|
||||||
|
# 或仅把附件分到大容量盘(需保证 /data 下其余文件仍可写)
|
||||||
|
- /ssd/jiang13:/data
|
||||||
|
- /hdd/jiang13-uploads:/data/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
拆挂时仍要保证 `/data` 下 `.jwt_secret` 等文件可写;不熟悉 Docker 时请继续只用单个 `/data` 挂载。
|
||||||
|
|
||||||
|
3. **产品级:可配置上传根目录(中期)**
|
||||||
|
- 环境变量或 `app.ini` 支持 `upload_dir` 与 `data_dir` 分离。
|
||||||
|
- Docker 示例改为可选双卷;**默认仍单卷**,避免破坏现有部署。
|
||||||
|
|
||||||
|
4. **不做的事**
|
||||||
|
- 不为「看起来像 WordPress」而强制双卷。
|
||||||
|
- 不在未支持分离配置前,在 UI 里假装有两个独立数据根。
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
|
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
|
||||||
|
|
||||||
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:公开页用 **Go `html/template` 真 SSR**,`web_src` 做渐进增强 CSS/JS(`go:embed`),数据库默认 **SQLite**,拷贝到服务器就能跑。
|
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:前端 SPA(单页应用)通过 `go:embed` 内嵌,数据库用内置 **SQLite**,拷贝到服务器就能跑——没有复杂的中间件矩阵,也没有「先装一堆依赖再祈祷能起来」的仪式感。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,24 +60,24 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
|
|||||||
|
|
||||||
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
|
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
|
||||||
|
|
||||||
### 发帖:Markdown,够用就好
|
### 发帖:富文本,够用就好
|
||||||
|
|
||||||
发帖使用 **Markdown 工具栏**(标题、粗斜体、列表、代码块、图片上传、内容门控插入)与服务端预览;标签、板块选择日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
|
发帖使用 **TipTap** 富文本编辑器:标题、排版、标签、板块选择、正文图片本地上传,日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**,可做 diff(差异)对比。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
|
||||||
|
|
||||||
### 讨论:楼层回复,聊得清楚
|
### 讨论:楼层回复,聊得清楚
|
||||||
|
|
||||||
评论是楼层式的:可回复指定楼层、引用原文;支持嵌套展示。点赞、收藏、热门帖,把活跃内容自然推到前面。
|
评论是楼层式的:可回复指定楼层、引用原文、@ 高亮。点赞、收藏、热门帖,把活跃内容自然推到前面。
|
||||||
|
|
||||||
### 管理:SSR 后台
|
### 管理:后台也是 SPA
|
||||||
|
|
||||||
管理后台统一在 `/admin`,与公开页同一套模板布局:
|
管理后台统一在 `/admin`,与前台同一套 React 体验:
|
||||||
|
|
||||||
- 仪表盘、板块与帖子管理
|
- 仪表盘、板块与帖子管理
|
||||||
- 用户禁言、删帖删评
|
- 用户禁言、删帖删评
|
||||||
- 论坛参数、限流、敏感词
|
- 论坛参数、限流、敏感词
|
||||||
- SQLite **一键备份**
|
- SQLite **一键备份**
|
||||||
|
|
||||||
权限模型很简单:**普通用户 / 管理员**;首次通过 **安装向导** 创建管理员账号。
|
权限模型很简单:**普通用户 / 管理员**;站点 **第一个注册用户自动成为管理员**,省去安装向导里的一堆步骤。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,19 +87,19 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
编译 → 得到一个 jiang13(或 jiang13.exe)
|
编译 → 得到一个 jiang13(或 jiang13.exe)
|
||||||
放到目录里运行 → Env / CLI 引导(默认 SQLite)
|
放到目录里运行 → 自动生成 app.ini
|
||||||
打开浏览器完成安装向导 → 创建管理员
|
打开浏览器注册 → 第一个账号就是管理员
|
||||||
```
|
```
|
||||||
|
|
||||||
要点:
|
要点:
|
||||||
|
|
||||||
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
|
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
|
||||||
- **零外部数据库**:SQLite 落在数据目录,备份就是拷贝文件;
|
- **零外部数据库**:SQLite 落在数据目录,备份就是拷贝文件;
|
||||||
- **Env / CLI 引导**:端口、数据目录、数据库类型;业务项在管理后台热更新;
|
- **app.ini 配置**:风格类似 Gitea,端口与数据目录一眼能改;业务项在管理后台配置;
|
||||||
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
|
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
|
||||||
- **跨平台**:Windows / Linux / macOS 均可编译与运行。
|
- **跨平台**:Windows / Linux / macOS 均可编译与运行。
|
||||||
|
|
||||||
典型启动后访问 `http://localhost:3000`,完成安装后即可开始。
|
典型启动后访问 `http://localhost:3000`,注册即可开始。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -107,26 +107,26 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
|
|||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 后端 / SSR | Go · Gin · GORM · SQLite · `html/template` |
|
| 后端 | Go · Gin · GORM · SQLite |
|
||||||
| 渐进资源 | `web_src` → `public/assets`(`/ssr-assets/`) |
|
| 前端 | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||||
| 构建 | `web_src` + `go:embed` templates/assets,单二进制 |
|
| 构建 | Vite 构建 SPA,再由 `go:embed` 打进二进制 |
|
||||||
| 认证 | bcrypt + 会话 Cookie;可选 OIDC Provider,对接 Gitea 等 SSO |
|
| 认证 | bcrypt + JWT Cookie;可选 OIDC Provider,对接 Gitea 等 SSO |
|
||||||
|
|
||||||
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:改模板/Go 后重启进程;改 `web_src` 后跑 `build.bat -Target web-src`。
|
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:前后端分离开发,`dev` 模式下 Vite HMR(热模块替换)可秒级预览前端改动。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 现在走到哪一步
|
## 现在走到哪一步
|
||||||
|
|
||||||
项目仍在积极开发中,核心体验已经可用(分支 `rebuild/gitea-ssr`):
|
项目仍在积极开发中,核心体验已经可用:
|
||||||
|
|
||||||
- ✅ 三栏布局、主题切换、Feed 排序、真 SSR 公开页
|
- ✅ 三栏布局、主题切换、虚拟滚动、Feed 排序
|
||||||
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史(Markdown 编辑器)
|
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史
|
||||||
- ✅ SSR 管理后台与论坛参数配置
|
- ✅ React 管理后台与论坛参数配置
|
||||||
- ✅ OIDC Provider(可作 Gitea 等站点的登录源)
|
- ✅ OIDC Provider(可作 Gitea 等站点的登录源)
|
||||||
- ✅ 单二进制部署与系统服务
|
- ✅ 单二进制部署与系统服务
|
||||||
|
|
||||||
计划中的方向见仓库 [ROADMAP.md](../ROADMAP.md) 与 [09-ssr-progress.md](rebuild-spec/09-ssr-progress.md)。
|
计划中的方向包括通知动态优化、搜索增强、邮件提醒等——完整列表见仓库 [ROADMAP.md](../ROADMAP.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Issue 预填模板
|
# Issue 预填模板
|
||||||
|
|
||||||
以下可直接复制到本仓库 Issues 创建。本分支为 **SSR**(`rebuild/gitea-ssr`);对照 SPA 请注明 `main`。
|
以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Issue · Bug 模板
|
## Issue #1 · 评论回复换行不显示
|
||||||
|
|
||||||
**标题:** `[Bug] 简短描述`
|
**标题:** `[Bug] 评论回复换行不显示`
|
||||||
|
|
||||||
**标签:** `bug` `ui/ux`
|
**标签:** `bug` `ui/ux`
|
||||||
|
|
||||||
@@ -14,52 +14,79 @@
|
|||||||
|
|
||||||
### 问题描述
|
### 问题描述
|
||||||
|
|
||||||
(一句话)
|
在帖子详情页的评论框中输入多行文字(按 Enter 换行),提交后评论展示区域不保留换行,所有文字合并为一行。
|
||||||
|
|
||||||
### 复现步骤
|
### 复现步骤
|
||||||
|
|
||||||
1. …
|
1. 打开任意帖子详情页(如 `/post/2`)
|
||||||
2. …
|
2. 在底部评论框输入:
|
||||||
|
```
|
||||||
|
第一行
|
||||||
|
第二行
|
||||||
|
第三行
|
||||||
|
```
|
||||||
|
3. 点击发送
|
||||||
|
4. 查看刚发布的评论
|
||||||
|
|
||||||
### 期望行为 / 实际行为
|
### 期望行为
|
||||||
|
|
||||||
### 相关代码(若已知)
|
评论正文按输入时的换行分段显示,行与行之间有明显间隔。
|
||||||
|
|
||||||
- 模板:`templates/…`
|
### 实际行为
|
||||||
- 路由:`routers/web/…`
|
|
||||||
- 样式/脚本:`web_src/…`
|
多行内容被渲染成单行连续文字。
|
||||||
|
|
||||||
|
### 相关代码
|
||||||
|
|
||||||
|
- `frontend/src/components/CommentContent.tsx`
|
||||||
|
- `frontend/src/utils/content.ts`(`highlightMentions` 中的 `\n` → `<br>` 转换)
|
||||||
|
- `frontend/src/styles/global.css`(`.floor-body` 的 `white-space: pre-wrap`)
|
||||||
|
|
||||||
|
### 可能原因
|
||||||
|
|
||||||
|
- 仅处理了 `\n`,未处理 Windows 的 `\r\n`
|
||||||
|
- `dangerouslySetInnerHTML` 与 `pre-wrap` 样式叠加导致表现异常
|
||||||
|
- 服务端 `strings.TrimSpace` 或其他处理误删换行(待排查)
|
||||||
|
|
||||||
### 环境
|
### 环境
|
||||||
|
|
||||||
- 分支:`rebuild/gitea-ssr`(或 `main` SPA)
|
- 前台:React SPA(`:3000` 嵌入版或 `:5173` 开发版)
|
||||||
- 浏览器 / OS
|
- 浏览器:Chrome / Edge 最新版
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Issue · 功能增强模板
|
## Issue #2 · 示例:管理能力扩展(模板文案)
|
||||||
|
|
||||||
**标题:** `[Feature] 简短描述`
|
**标题:** `[Feature] 管理后台增加某某能力`
|
||||||
|
|
||||||
**标签:** `enhancement`
|
**标签:** `enhancement` `ui/ux` `good first issue`
|
||||||
|
|
||||||
**正文:**
|
**正文:**
|
||||||
|
|
||||||
### 要解决的问题
|
### 要解决的问题
|
||||||
|
|
||||||
|
描述管理员在 React SPA 管理后台 / 前台中缺少的操作入口或能力。
|
||||||
|
|
||||||
### 现状
|
### 现状
|
||||||
|
|
||||||
| 能力 | 状态 |
|
| 能力 | 状态 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 规格(`docs/rebuild-spec`) | ✅ / ❌ |
|
| 数据模型与业务逻辑 | ✅ / ❌ |
|
||||||
| SSR 页面 / 表单 | ✅ / ❌ |
|
| JSON API(如 `POST /api/admin/...`) | ✅ / ❌ |
|
||||||
| 业务 `services/` | ✅ / ❌ |
|
| React 管理后台入口 | ✅ / ❌ |
|
||||||
|
| React 前台操作入口(如适用) | ✅ / ❌ |
|
||||||
|
|
||||||
### 期望方案
|
### 期望方案
|
||||||
|
|
||||||
1. …
|
1. 在对应页面为管理员增加操作入口(仅 `role === 'admin'` 可见)
|
||||||
2. …
|
2. 调用已有或新增的 `/api/admin/*` JSON API
|
||||||
|
3. 成功后刷新列表/详情,无需离开当前页面
|
||||||
|
|
||||||
### 相关代码
|
### 相关代码
|
||||||
|
|
||||||
- `routers/web/`、`templates/`、`services/`
|
- 后端:`service/`、`handler/api.go`、`router/router.go`
|
||||||
- 规格:`docs/rebuild-spec/02-features.md` / `06-pages-ux.md`
|
- 前端:`frontend/src/pages/admin/`、`frontend/src/api/client.ts`
|
||||||
|
|
||||||
|
### 备注
|
||||||
|
|
||||||
|
适合作为 `good first issue` 时,优先选择 API 已就绪、只需补 UI 的小改动。
|
||||||
|
|||||||
79
docs/monitor.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 网站监控设计说明
|
||||||
|
|
||||||
|
管理端「网站监控」:轻量自建访问统计,对标 1Panel 思路——**请求流水写文件、访客地理写独立库**,开启后不拖慢前台读帖。
|
||||||
|
|
||||||
|
## 双通道数据
|
||||||
|
|
||||||
|
| 数据 | 存储 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| 请求日志 | `{DataDir}/logs/access/YYYY-MM-DD.jsonl` | 请求日志页、请求数/流量/4xx·5xx、实时曲线 |
|
||||||
|
| page_views | `{DataDir}/monitor.db`(SQLite) | 浏览量/访客、国家/省/城市/运营商排行与地图 |
|
||||||
|
|
||||||
|
请求日志**只写 jsonl**,不进 SQLite。删除过期 `.jsonl` 即释放磁盘。
|
||||||
|
|
||||||
|
`page_views` **不进主库** `jiang13.db`。后台「导出备份」只含主库,不含浏览量。
|
||||||
|
|
||||||
|
### 文件行格式(JSONL)
|
||||||
|
|
||||||
|
每行一条 JSON,字段包括:`t`(RFC3339)、`method`、`path`、`status`、`bytes`、`duration_ms`、`ip`、`ua`、`referer`、`country`、`region`、`region_iso`、`city`、`asn`、`as_org`、`is_bot`。
|
||||||
|
|
||||||
|
### 保留天数
|
||||||
|
|
||||||
|
- `monitor_retention_days`:仅 **page_views**(默认 30)
|
||||||
|
- `monitor_access_log_retention_days`:请求日志文件(默认 7)
|
||||||
|
|
||||||
|
## 地理数据文件
|
||||||
|
|
||||||
|
放在数据目录(与 `jiang13.db` 同级),有则加载、无则静默降级:
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `IP2LOCATION-LITE-DB3.BIN` | IPv4 国家/省/城市 |
|
||||||
|
| `IP2LOCATION-LITE-DB3.IPV6.BIN` | IPv6 国家/省/城市 |
|
||||||
|
| `GeoLite2-ASN.mmdb` | ASN / 运营商 |
|
||||||
|
| `GeoLite2-Country.mmdb` | 可选:BIN 未命中时国家兜底 |
|
||||||
|
|
||||||
|
CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat/Lon/Zip/TimeZone。管理端展示**完整客户端 IP**。
|
||||||
|
|
||||||
|
写入时做本地中文映射(省 / 中国城市按 IP2Location 英文名 / 运营商);同音城市(如苏州/宿州)按省份歧义。`country` 存 ISO2,展示用中文名。历史 `page_views` / 请求日志不会回写,仅影响新写入。
|
||||||
|
|
||||||
|
**口径提示:** 访客地图与城市/运营商排行只聚合 `page_views`(前台浏览);请求日志里的地理来自 access JSONL。仅有请求日志、没有前台浏览时,地图与排行可为空——属预期。
|
||||||
|
|
||||||
|
## 写入路径(性能)
|
||||||
|
|
||||||
|
1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘)。
|
||||||
|
2. 后台 flush:出队 → Geo + 中文映射 → append 当日 jsonl + 更新内存实时环。
|
||||||
|
3. 队列上限 8192,满则丢弃新日志(保护内存与磁盘)。
|
||||||
|
4. pageview 信标 QPS 低,可在写入时同步 Geo 后写独立 `monitor.db`(不占用主库连接)。
|
||||||
|
|
||||||
|
**热路径零 Geo、零写库、零写文件。**
|
||||||
|
|
||||||
|
## 请求日志口径(排除规则)
|
||||||
|
|
||||||
|
默认只关心**前台访客**,排除管理员刷后台产生的壳请求:
|
||||||
|
|
||||||
|
- `/admin`、`/api/admin/`
|
||||||
|
- `/api/me`、`/api/site-branding`
|
||||||
|
- `/health`、`/uploads/`、`/media/`、静态后缀、`/api/monitor/pageview` 等
|
||||||
|
|
||||||
|
设置页可「恢复推荐排除规则」。不强制覆盖用户已保存的自定义列表。
|
||||||
|
|
||||||
|
浏览量/访客仍来自前台路由 pageview(后台路径本身不上报)。
|
||||||
|
|
||||||
|
## 实时与聚合
|
||||||
|
|
||||||
|
- 近 1 分钟 / 近 1 小时:内存环(flush 时更新),避免扫全文件。
|
||||||
|
- 今日请求数等:内存日累加器(进程内;重启后自新流量累积,论坛量级可接受)。
|
||||||
|
- 地理排行:仅 `page_views`。
|
||||||
|
|
||||||
|
## 诊断
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||||
|
```
|
||||||
|
|
||||||
|
会输出 Geo 文件状态、近 30 日省级 Top,以及对账抽样(英文城市名、有国家但省为空、`asn=0`)。
|
||||||
|
## 明确不做
|
||||||
|
|
||||||
|
- 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库
|
||||||
|
- 不做街道级地图;Docker 镜像不内置 BIN/MMDB
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# 01 · 产品定位与模块地图
|
|
||||||
|
|
||||||
> **读者**:重构架构师 / 产品对齐
|
|
||||||
> **前置**:[README.md](README.md)
|
|
||||||
> **后续**:[02-features.md](02-features.md)
|
|
||||||
> **源码**:[docs/introduction.md](../introduction.md)、[README.md](../../README.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 定位
|
|
||||||
|
|
||||||
姜十三论坛不做大而全公网社区,只服务「几人到几百人」的内部交流:
|
|
||||||
|
|
||||||
| 场景 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
|
|
||||||
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
|
|
||||||
| 项目配套社区 | 可与 Gitea 通过 OIDC(开放身份连接)做 SSO(单点登录) |
|
|
||||||
| 个人站长 | 希望数据自管、部署简单 |
|
|
||||||
|
|
||||||
**产品口号气质**:能聊 · 好看 · 好装(部署简单)。
|
|
||||||
|
|
||||||
**规模预期**:非百万用户级;信息密度接近 V2EX / NGA 一类,而非大留白营销站。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 产品 vs 运维(拆开看待)
|
|
||||||
|
|
||||||
| 维度 | 当前实现 | 重构时 |
|
|
||||||
|------|----------|--------|
|
|
||||||
| **产品** | 论坛功能全集(见模块地图) | **必须对齐**功能与规则 |
|
|
||||||
| **运维** | 单二进制 + Env 引导 + SQLite/PG/MySQL + `forum_settings` 热更 | **本分支已落地**;无 `app.ini` |
|
|
||||||
|
|
||||||
规格文档把「用户能做什么」写死;把「怎么打包发布」放在 [07-config-ops.md](07-config-ops.md) 供参考。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 角色模型
|
|
||||||
|
|
||||||
| 角色 | 识别 | 能力摘要 |
|
|
||||||
|------|------|----------|
|
|
||||||
| **游客** | 未登录 | 浏览公开内容;可发表游客评论(需昵称等);门控内容按规则遮盖 |
|
|
||||||
| **登录用户** | JWT Cookie 有效且未禁言 | 发帖、评论、点赞收藏、私信、签到抽奖、友链申请、举报等 |
|
|
||||||
| **认证用户** | `user.verified = true` | 与管理员一样:**发帖/评论免审**(`SkipsModeration`) |
|
|
||||||
| **管理员** | `role = admin` | 全部后台能力;免审;看待审/被拒内容 |
|
|
||||||
| **系统** | 私信 `from_user_id = 0` | 发系统通知(审核、回复提醒、举报结果等) |
|
|
||||||
|
|
||||||
**引导规则**:站点**第一个注册用户自动成为管理员**(无安装向导单独建管步骤)。
|
|
||||||
|
|
||||||
禁言用户(`banned`):带鉴权的写接口被拒绝;浏览策略以实现为准,前端通常视为不可正常互动。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 模块地图
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph core [核心社区]
|
|
||||||
auth[认证与账号]
|
|
||||||
board[板块]
|
|
||||||
feed[Feed与搜索]
|
|
||||||
post[帖子与特殊类型]
|
|
||||||
comment[评论]
|
|
||||||
gate[内容门控]
|
|
||||||
end
|
|
||||||
subgraph social [社交]
|
|
||||||
like[点赞收藏]
|
|
||||||
msg[私信与通知]
|
|
||||||
profile[用户主页]
|
|
||||||
report[举报]
|
|
||||||
end
|
|
||||||
subgraph economy [经济与成长]
|
|
||||||
points[积分钱包]
|
|
||||||
checkin[签到抽奖]
|
|
||||||
badge[徽章]
|
|
||||||
level[等级Exp]
|
|
||||||
end
|
|
||||||
subgraph site [站点扩展]
|
|
||||||
page[自定义单页]
|
|
||||||
links[友情链接]
|
|
||||||
gitea[Gitea码桶]
|
|
||||||
brand[品牌与SEO]
|
|
||||||
end
|
|
||||||
subgraph admin [管理与集成]
|
|
||||||
mod[审核回收站]
|
|
||||||
settings[系统设置]
|
|
||||||
oidc[OIDC Provider]
|
|
||||||
storage[本地或S3存储]
|
|
||||||
mail[SMTP邮件]
|
|
||||||
end
|
|
||||||
auth --> feed
|
|
||||||
board --> feed
|
|
||||||
feed --> post
|
|
||||||
post --> comment
|
|
||||||
post --> gate
|
|
||||||
post --> like
|
|
||||||
comment --> msg
|
|
||||||
points --> gate
|
|
||||||
points --> checkin
|
|
||||||
auth --> oidc
|
|
||||||
settings --> mail
|
|
||||||
settings --> storage
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.1 认证与账号
|
|
||||||
|
|
||||||
- 注册 / 登录 / 登出;可选邮箱验证码;忘记密码重置
|
|
||||||
- 图形验证码(注册场景)
|
|
||||||
- 个人资料:昵称、签名、头像、改密
|
|
||||||
- 邮件未配置时可能关闭公开注册(见业务规则)
|
|
||||||
|
|
||||||
### 4.2 板块与 Feed
|
|
||||||
|
|
||||||
- 多板块;图标与色板;排序
|
|
||||||
- 首页「全部」+ `/board/:id`
|
|
||||||
- 排序:最新发帖 / 最新回复 / 热门
|
|
||||||
- 搜索:关键词、标签、作者、仅标题
|
|
||||||
- 列表样式:仅标题 / 摘要 / 缩略图(后台可配)
|
|
||||||
|
|
||||||
### 4.3 帖子
|
|
||||||
|
|
||||||
- 富文本正文(HTML,TipTap 产出)+ 可选 Markdown 编辑面
|
|
||||||
- 标签、修订历史与 diff
|
|
||||||
- 五种类型:`normal` | `question` | `poll` | `bounty` | `lottery`
|
|
||||||
- 运营标记:全局置顶、版内置顶、精华、禁止编辑、禁止评论
|
|
||||||
- 审核状态:`pending` | `published` | `rejected`;软删回收站
|
|
||||||
|
|
||||||
### 4.4 内容门控
|
|
||||||
|
|
||||||
正文内嵌自定义标签(非独立表行,存在 `posts.content` HTML 中):
|
|
||||||
|
|
||||||
| 标签 | 含义 |
|
|
||||||
|------|------|
|
|
||||||
| `<members-only>` | 登录可见 |
|
|
||||||
| `<reply-only>` | 本帖已回复可见 |
|
|
||||||
| `<points-only data-cost="N">` | 积分解锁;按块计费 |
|
|
||||||
|
|
||||||
### 4.5 评论
|
|
||||||
|
|
||||||
- 楼层号;回复指定楼;嵌套展示;引用;@ 提及
|
|
||||||
- 游客评论字段;私密评论(仅相关人可见)
|
|
||||||
- 点赞、编辑时限、审核、软删
|
|
||||||
|
|
||||||
### 4.6 积分经济与成长
|
|
||||||
|
|
||||||
- **Points**:可消费积分(签到、抽奖、解锁、悬赏托管等)
|
|
||||||
- **Exp**:不可消费经验 → 等级 Lv1–10
|
|
||||||
- **CreatorIncomeTotal**:创作分成累计(徽章指标)
|
|
||||||
- 徽章:自动(门槛)+ 限定(管理员发放)
|
|
||||||
|
|
||||||
### 4.7 私信与通知
|
|
||||||
|
|
||||||
统一走 `private_messages` 表,用 `kind` 区分用户私信与系统事件。
|
|
||||||
|
|
||||||
### 4.8 友链与站点页
|
|
||||||
|
|
||||||
- 管理员维护品牌友链 JSON;用户申请审核(可选回链检测)
|
|
||||||
- 自定义单页(关于、版规等):slug、发布、导航/页脚展示
|
|
||||||
|
|
||||||
### 4.9 Gitea 开源码桶
|
|
||||||
|
|
||||||
后台配置后定时同步公开仓库到 `gitea_repos`,前台 `/projects` 展示。
|
|
||||||
|
|
||||||
### 4.10 OIDC Provider
|
|
||||||
|
|
||||||
本站作为 IdP:Discovery / Authorize / Token / UserInfo / Logout / JWKS;多 OAuth 客户端。
|
|
||||||
|
|
||||||
### 4.11 管理后台
|
|
||||||
|
|
||||||
仪表盘、板块、单页、友链、帖/评、举报、用户、徽章、媒体、系统设置、SQLite 备份。
|
|
||||||
|
|
||||||
### 4.12 SEO / 发现
|
|
||||||
|
|
||||||
`robots.txt`、`sitemap.xml`、Open Graph / Twitter / JSON-LD;当前另有爬虫 HTML。新站应用 SSR 统一,但 **meta 字段集合应保留**(见 [07-config-ops.md](07-config-ops.md))。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 权限一句话
|
|
||||||
|
|
||||||
- **读公开内容**:人人可(含游客)
|
|
||||||
- **写内容**:登录(部分评论允许游客)
|
|
||||||
- **审与运营**:管理员
|
|
||||||
- **免审写**:管理员或 `verified`
|
|
||||||
|
|
||||||
细节状态机见 [05-business-rules.md](05-business-rules.md)。
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# 02 · 功能清单(验收级)
|
|
||||||
|
|
||||||
> **读者**:实现与验收
|
|
||||||
> **前置**:[01-product.md](01-product.md)
|
|
||||||
> **交叉**:[05-business-rules.md](05-business-rules.md)、[06-pages-ux.md](06-pages-ux.md)
|
|
||||||
> **源码对照**:[`frontend/src/App.tsx`]((仅 main)frontend/src/App.tsx)、[`router/router.go`](../../routers/setup.go)、[`README.md`](../../README.md)
|
|
||||||
|
|
||||||
用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 浏览与布局
|
|
||||||
|
|
||||||
- [x] 三栏布局:左导航 / 中 Feed / 右栏小组件 — SSR(桌面;≤900px 隐藏右栏)
|
|
||||||
- [x] 浅色 / 暗色主题;跟随系统偏好并本地记忆 — CSS `prefers-color-scheme` + `data-theme` 显式覆盖;顶栏循环:跟随系统 / 浅色 / 暗色(`j13-theme`)
|
|
||||||
- [ ] 响应式:平板/手机收起侧栏
|
|
||||||
- [ ] 长列表虚拟滚动或等价流畅方案
|
|
||||||
- [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
|
|
||||||
- [x] 板块筛选:全部 + 单板块
|
|
||||||
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
|
|
||||||
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`)
|
|
||||||
- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin `/admin/settings` 侧栏组件编辑器
|
|
||||||
- [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`)
|
|
||||||
- [ ] 下拉刷新(移动端)
|
|
||||||
- [x] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)— SSR `/admin/settings` 伪静态;公开路径 301 规范 URL
|
|
||||||
- [x] 404 页
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 认证与个人中心
|
|
||||||
|
|
||||||
- [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register`
|
|
||||||
- [x] 图形验证码接口(注册流程)— SSR 注册页强制;`GET /api/captcha` + `POST /api/register` 校验
|
|
||||||
- [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销
|
|
||||||
- [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪)
|
|
||||||
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
|
|
||||||
- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
|
|
||||||
- [x] 个人中心:改昵称、签名、密码、上传头像、积分钱包/签到/抽奖 — SSR `/profile`(裁剪未做)
|
|
||||||
- [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id`
|
|
||||||
- [x] 公开用户主页 `/user/:id`(无邮箱)
|
|
||||||
- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 板块
|
|
||||||
|
|
||||||
- [x] 列出板块(含帖数等展示字段)— SSR `/boards` + 侧栏 + Admin
|
|
||||||
- [x] 管理员:创建 / 改 / 删板块 — SSR `/admin/boards`
|
|
||||||
- [x] 板块名称、描述、图标、色板索引、排序
|
|
||||||
- [x] 默认板块保障(空站可引导创建)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 帖子(通用)
|
|
||||||
|
|
||||||
- [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`(normal)
|
|
||||||
- [x] 正文图片上传 — `/compose/upload` + Markdown 插入
|
|
||||||
- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— **本分支不做 TipTap**;改用 Markdown 工具栏渐进增强
|
|
||||||
- [x] Markdown 编辑(工具栏 + `/compose/preview`;发帖/改帖/评论共用 `shared/md_editor`)— 非 TipTap 双模
|
|
||||||
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
|
|
||||||
- [x] 删除帖子 → 软删进回收站 — SSR Admin(帖详情 + `/admin/trash`)
|
|
||||||
- [x] 修订历史列表与单条详情(可做 diff)— SSR `/post/:id/revisions`(作者/管理员;无 diff)
|
|
||||||
- [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites`
|
|
||||||
- [x] 浏览量 — 详情页计数
|
|
||||||
- [x] 举报帖子 — SSR 表单 + `/admin/reports`
|
|
||||||
- [x] 内容审核状态展示(作者可见待审/被拒)— SSR 帖详情横幅 + 评论楼层标签
|
|
||||||
|
|
||||||
### D.1 帖子类型
|
|
||||||
|
|
||||||
- [x] `normal` 普通讨论 — SSR compose 默认
|
|
||||||
- [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换
|
|
||||||
- [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
|
|
||||||
- [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)— SSR compose + 详情条 + award/refund
|
|
||||||
- [x] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 — SSR compose + 详情卡 + draw
|
|
||||||
|
|
||||||
### D.2 运营标记(管理员)
|
|
||||||
|
|
||||||
- [x] 全局置顶 / 取消 — SSR 帖详情
|
|
||||||
- [x] 版内置顶 / 取消(仅板块列表抬升)— SSR 帖详情
|
|
||||||
- [x] 精华 / 取消 — SSR 帖详情
|
|
||||||
- [x] 禁止编辑(edit lock)— SSR 帖详情
|
|
||||||
- [x] 禁止评论 / 结贴(comments lock)— SSR 帖详情
|
|
||||||
- [x] 审核通过 / 拒绝(拒绝可通知作者)— SSR `/admin/moderation`
|
|
||||||
- [x] 回收站:恢复 / 彻底删除 — SSR `/admin/trash`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. 内容门控
|
|
||||||
|
|
||||||
- [x] 编辑器可插入「登录可见」块 — compose 工具栏
|
|
||||||
- [x] 编辑器可插入「回复可见」块 — compose 工具栏
|
|
||||||
- [x] 编辑器可插入「积分可见」块(可设价格)— compose 工具栏 + prompt
|
|
||||||
- [x] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 — 锁定壳 UI
|
|
||||||
- [x] 已登录未回复:遮盖 reply-only(作者与管理员始终可见)
|
|
||||||
- [x] 积分块:未解锁遮盖;`POST /post/:id/unlock` 扣积分并返回 inner HTML
|
|
||||||
- [x] 搜索 / SEO 出口对门控内容做红action,不泄露正文(既有)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. 评论
|
|
||||||
|
|
||||||
- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;`ThreadParentID` 嵌套树 + 全局 `#floor` 缩进
|
|
||||||
- [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments`
|
|
||||||
- [ ] 游客评论(公开接口可写,字段 guest_*)
|
|
||||||
- [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站
|
|
||||||
- [x] 评论点赞 — `POST /post/:id/comments/:cid/like`
|
|
||||||
- [x] 评论举报 — SSR
|
|
||||||
- [x] @ 提及 → 通知 — SSR 发评/审通调用 `NotifyCommentMentions`
|
|
||||||
- [x] 回复提醒(站内信 + 可选邮件)— SSR 发评/审通调用 `NotifyCommentPublished`
|
|
||||||
- [x] 审核中 / 被拒评论可见性规则 — 服务层已有;SSR 楼层标签(作者/管理员)
|
|
||||||
- [x] 管理员:通过 / 拒绝待审评论 — SSR `/admin/moderation`(回收站/修订未迁)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. 私信与通知
|
|
||||||
|
|
||||||
- [x] 会话列表(含系统会话 peer=0)— SSR `/messages`
|
|
||||||
- [x] 会话消息(分页 / before 游标)— SSR `/messages/with/:peerId`
|
|
||||||
- [x] 发送私信 — 表单 PRG;用户主页「发私信」入口
|
|
||||||
- [x] 未读数(可分私信 / 通知)— 列表分项 + 导航角标
|
|
||||||
- [x] 标记会话已读 / 通知已读 / 全部已读 — 打开会话自动已读;全部标已读
|
|
||||||
- [x] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等(写入已有;`/messages/with/0?kind=` 筛选)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. 积分、签到、抽奖、徽章、等级
|
|
||||||
|
|
||||||
- [x] 积分流水查询 — SSR `/profile` 钱包近 N 条
|
|
||||||
- [x] 每日签到:基础 5,连签每日 +1,封顶 15 — `POST /profile/checkin`
|
|
||||||
- [x] 每日抽奖:奖池加权(0/2/5/10/20),成本 0 — `POST /profile/lottery`
|
|
||||||
- [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务
|
|
||||||
- [x] 短龄同 IP 互刷拒绝分成 — `UnlockPointsBlock` / `suspiciousUnlockPair`(双方注册未满 7 天且 LastLoginIP 相同)
|
|
||||||
- [x] Exp → 等级 Lv1–10 — 门槛推导 + 发帖/评论/获赞加 Exp;Admin 设等级 SSR `/admin/users`
|
|
||||||
- [x] 自动徽章(注册天数 / 获赞 / 创作分成)— 种子 + `EvaluateAuto`(用户页/资料页访问时评估);无独立定时调度 UI
|
|
||||||
- [x] 限定徽章:管理员定义与授予/撤销 — SSR `/admin/badges`
|
|
||||||
- [x] 管理员调整积分、设定认证(免审)、设定等级 — SSR `/admin/users`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. 友链
|
|
||||||
|
|
||||||
- [x] 前台友链页;导航/页脚入口可配 — SSR `/links`;`nav_show_friend_links` / `footer_show_friend_links`
|
|
||||||
- [x] 用户申请(名称、URL、Logo、是否上首页、回链页)— `POST /links/apply`
|
|
||||||
- [x] Logo 上传 — 表单 multipart 或 `POST /links/logo`
|
|
||||||
- [x] 我的申请列表;取消待审 — `/links`(修改待审:取消后重提)
|
|
||||||
- [x] 管理员审核:通过 / 拒绝 / 回链复检 — SSR `/admin/friend-links`(创建时可异步检测)
|
|
||||||
- [x] 管理员维护品牌友链列表 — `/admin/friend-links` 增删
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. 站点单页
|
|
||||||
|
|
||||||
- [x] 公开:`/page/:slug` 列表入口(nav/footer)— SSR
|
|
||||||
- [x] 管理员 CRUD;发布开关;排序;nav/footer 展示开关 — SSR `/admin/pages`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## K. Gitea 码桶(**后置**)
|
|
||||||
|
|
||||||
> 本迭代**不做**产品化同步:不启后台定时任务、不挂管理入口。表结构与 settings 键可保留兼容。
|
|
||||||
|
|
||||||
- [ ] (后置)后台开关、Base URL、Token、同步间隔
|
|
||||||
- [ ] (后置)手动同步 + 后台定时同步
|
|
||||||
- [ ] (后置)前台 `/projects` 列表与搜索
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L. OIDC Provider
|
|
||||||
|
|
||||||
- [ ] Discovery、JWKS、Authorize、Token、UserInfo、Logout
|
|
||||||
- [ ] 多 OAuth 客户端 CRUD;密钥哈希存储;PKCE 字段支持
|
|
||||||
- [ ] groups claim 映射 admin/user 组
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M. 媒体与存储
|
|
||||||
|
|
||||||
- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置)— 后端已支持;Admin 热切换 UI 未迁
|
|
||||||
- [x] 头像 / 帖图 / 站点品牌资源分类 — 上传路径 + Admin 分类筛选
|
|
||||||
- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`)— 路由已有;全站默认策略未产品化
|
|
||||||
- [x] 管理后台媒体列表与删除 — SSR `/admin/media`
|
|
||||||
- [x] 媒体索引表同步 — 启动扫盘 + Admin「同步索引」
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## N. 管理后台其它
|
|
||||||
|
|
||||||
- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard`
|
|
||||||
- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
|
|
||||||
- [x] 基础限流(post/comment/register/login/window)— SSR `/admin/settings`
|
|
||||||
- [x] 完整 Limits(字数 / 编辑窗 / 分页 / 签名头像 / Feed 样式 / 新标签)— SSR `/admin/settings/content-limits`
|
|
||||||
- [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区
|
|
||||||
- [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings`
|
|
||||||
- [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear`;`<head>` favicon + og:image
|
|
||||||
- [x] SQLite 一键备份与下载 — SSR `/admin/settings`(仅 sqlite;写入 data 目录后下载)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## O. 基础设施
|
|
||||||
|
|
||||||
- [x] `GET /health` — `routers/setup.go`
|
|
||||||
- [x] `robots.txt` / `sitemap.xml` — SSR 同源路径
|
|
||||||
- [x] 静态上传文件可达 — `Static /uploads` → data/uploads
|
|
||||||
- [x] 限流:发帖、评论、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 明确不在当前规格内(计划中可后做)
|
|
||||||
|
|
||||||
摘自 [`ROADMAP.md`](../../ROADMAP.md),**不是**现网必交验收项:
|
|
||||||
|
|
||||||
- 通知动态 UX 大幅优化
|
|
||||||
- 帖子搜索增强(组合筛选更强)
|
|
||||||
|
|
||||||
若新站一并实现,可作为加分项,不阻塞「功能对等」验收。
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
# 03 · 数据模型
|
|
||||||
|
|
||||||
> **读者**:实现数据库与领域层的 AI
|
|
||||||
> **前置**:[01-product.md](01-product.md)
|
|
||||||
> **后续**:[04-api.md](04-api.md)、[05-business-rules.md](05-business-rules.md)
|
|
||||||
> **源码**:[`model/models.go`](../../models/models.go)、[`model/oauth.go`](../../models/oauth.go)、[`model/gitea.go`](../../models/gitea.go)、[`model/level.go`](../../models/level.go)、[`model/db.go`](../../models/db.go)、[`model/user_view.go`](../../models/user_view.go)、[`service/settings.go`](../../services/settings.go)
|
|
||||||
|
|
||||||
当前无独立 SQL migration;表由 GORM `AutoMigrate` 创建。新站可用正式 migration,但**字段语义应对齐**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ER 概览
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
User ||--o{ Post : authors
|
|
||||||
User ||--o{ Comment : authors
|
|
||||||
Board ||--o{ Post : contains
|
|
||||||
Post ||--o{ Comment : has
|
|
||||||
Post ||--o{ PostLike : likes
|
|
||||||
Post ||--o{ PostFavorite : favorites
|
|
||||||
Post ||--o{ PostRevision : revisions
|
|
||||||
Comment ||--o{ CommentLike : likes
|
|
||||||
Comment ||--o{ CommentRevision : revisions
|
|
||||||
Post ||--o| Poll : poll
|
|
||||||
Poll ||--o{ PollOption : options
|
|
||||||
PollOption ||--o{ PollVote : votes
|
|
||||||
Post ||--o{ PostLotteryWinner : winners
|
|
||||||
Post ||--o{ PostContentUnlock : unlocks
|
|
||||||
User ||--o{ PointLedger : ledger
|
|
||||||
User ||--o{ CheckIn : checkins
|
|
||||||
User ||--o{ LotteryDraw : draws
|
|
||||||
User ||--o{ UserBadge : earns
|
|
||||||
BadgeDef ||--o{ UserBadge : defines
|
|
||||||
User ||--o{ PrivateMessage : sends
|
|
||||||
User ||--o{ PostReport : reports
|
|
||||||
User ||--o{ FriendLinkApply : applies
|
|
||||||
User ||--o{ Media : uploads
|
|
||||||
```
|
|
||||||
|
|
||||||
另有:`ForumSetting`(键值)、`Session`(浏览器 opaque 会话)、`OAuthClient` / `OAuthAuthCode`、`GiteaRepo`、`SitePage`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 表与字段
|
|
||||||
|
|
||||||
说明:`json:"-"` 表示默认 API 序列化隐藏;软删列 `deleted_at` 表示 GORM soft delete。
|
|
||||||
|
|
||||||
### 2.0 sessions
|
|
||||||
|
|
||||||
| 列 | 类型 | 说明 |
|
|
||||||
|----|------|------|
|
|
||||||
| id | string(64) PK | 密码学随机 opaque id(Cookie `jiang13_session` 的值) |
|
|
||||||
| user_id | uint index | 用户 |
|
|
||||||
| expires_at | time index | 过期;默认 TTL 7 天,滑动续期 |
|
|
||||||
| created_at / last_seen_at | time | |
|
|
||||||
| ip / user_agent | string | 可选审计 |
|
|
||||||
|
|
||||||
登出删单行;禁言 / 改密删该用户全部 session。每次请求以 DB 中 `users.role` / 禁言为准。
|
|
||||||
|
|
||||||
### 2.1 users
|
|
||||||
|
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| id | uint PK | | |
|
|
||||||
| username | string(128) | unique, not null | 登录名 |
|
|
||||||
| email | string(128) | index, default '' | 公开主页不返回 |
|
|
||||||
| password | string(128) | not null | bcrypt 哈希,永不返回 |
|
|
||||||
| nickname | string(64) | | 展示名 |
|
|
||||||
| signature | string(512) | default '' | 个人签名 |
|
|
||||||
| avatar | string(512) | | 相对或绝对 URL |
|
|
||||||
| role | string(16) | default `user` | `user` \| `admin` |
|
|
||||||
| verified | bool | index | 站长认证,免审 |
|
|
||||||
| exp | int | default 0 | 经验(不可消费) |
|
|
||||||
| points | int | default 0 | 可用积分 |
|
|
||||||
| creator_income_total | int | default 0 | 创作分成累计 |
|
|
||||||
| banned | bool | | 禁言 |
|
|
||||||
| banned_at | *time | | |
|
|
||||||
| last_login_at | *time | json 隐藏 | |
|
|
||||||
| last_login_ip | string(45) | json 隐藏 | |
|
|
||||||
| last_access_at | *time | json 隐藏 | 带鉴权访问 |
|
|
||||||
| created_at / updated_at | time | | |
|
|
||||||
| deleted_at | soft | | |
|
|
||||||
|
|
||||||
**非落库展示字段**:`level`(由 Exp 推导)、`badges`(附加)。
|
|
||||||
|
|
||||||
视图结构:`UserPublic` / `UserSelf` / `UserAdmin`(见 [`model/user_view.go`](../../models/user_view.go))。
|
|
||||||
|
|
||||||
### 2.2 boards
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| id, name(64), description(512) | |
|
|
||||||
| icon(64), color_index(default -1) | -1=按 id 自动取色 |
|
|
||||||
| sort_order | 升序 |
|
|
||||||
| created_at, updated_at, deleted_at | |
|
|
||||||
|
|
||||||
### 2.3 posts
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| id, board_id, user_id | FK 索引 |
|
|
||||||
| title(256), content(text) | HTML 正文 |
|
|
||||||
| content_plain(text) | 纯文本搜索索引,`json:"-"` |
|
|
||||||
| tags(256) | 逗号或空格分隔标签串 |
|
|
||||||
| post_type | `normal`\|`question`\|`poll`\|`bounty`\|`lottery` |
|
|
||||||
| question_resolved | 仅问答 |
|
|
||||||
| bounty_points, bounty_status, bounty_comment_id | 悬赏 |
|
|
||||||
| lottery_winner_count, lottery_status | 抽奖帖 |
|
|
||||||
| pinned | 全局置顶 |
|
|
||||||
| board_pinned | 版内置顶 |
|
|
||||||
| featured | 精华 |
|
|
||||||
| edit_locked | 禁止编辑 |
|
|
||||||
| comments_locked | 禁止新评论 |
|
|
||||||
| status | `pending`\|`published`\|`rejected` |
|
|
||||||
| like_count, view_count | |
|
|
||||||
| timestamps + soft delete | |
|
|
||||||
|
|
||||||
关联:Board, User, Comments。
|
|
||||||
|
|
||||||
### 2.4 post_revisions
|
|
||||||
|
|
||||||
每次修改前保存旧版:`post_id`, `editor_id`, `title`, `content`, `tags`, `created_at`。
|
|
||||||
|
|
||||||
### 2.5 comments
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| post_id, user_id | user_id=0 表示游客 |
|
|
||||||
| floor | 楼层号 |
|
|
||||||
| content | HTML/富文本 |
|
|
||||||
| reply_to | *uint 回复目标评论 |
|
|
||||||
| guest_nick / guest_email / guest_url | 游客信息 |
|
|
||||||
| is_private | 私密评论 |
|
|
||||||
| status | pending\|published\|rejected |
|
|
||||||
| like_count | |
|
|
||||||
| soft delete | |
|
|
||||||
|
|
||||||
**非落库**:`reply_target`, `thread_parent_id`, `content_hidden`, `liked`。
|
|
||||||
|
|
||||||
### 2.6 comment_revisions
|
|
||||||
|
|
||||||
`comment_id`, `editor_id`, `content`, `created_at`(管理员可查)。
|
|
||||||
|
|
||||||
### 2.7 post_likes / comment_likes / post_favorites
|
|
||||||
|
|
||||||
唯一索引:(post_id|comment_id, user_id)。收藏带 Post 关联。
|
|
||||||
|
|
||||||
### 2.8 private_messages
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| from_user_id | 0=系统 |
|
|
||||||
| to_user_id | |
|
|
||||||
| subject(256), content(text) | |
|
|
||||||
| kind | 见枚举 |
|
|
||||||
| related_post_id, related_report_id | 可选 |
|
|
||||||
| is_read | |
|
|
||||||
| created_at | |
|
|
||||||
|
|
||||||
### 2.9 post_reports
|
|
||||||
|
|
||||||
帖或评举报:`post_id` 必填;`comment_id` 有值则为评论举报。
|
|
||||||
`reason`, `detail`, `status`, `handler_id`, `handle_note`, `handled_at`。
|
|
||||||
|
|
||||||
### 2.10 friend_link_applies
|
|
||||||
|
|
||||||
申请字段:name, url, description, logo, reciprocal_page_url, link_on_homepage,
|
|
||||||
reciprocal_verified / check_note / checked_at, status, review_note, reviewed_at + soft delete。
|
|
||||||
|
|
||||||
### 2.11 media
|
|
||||||
|
|
||||||
上传索引:`category`=`avatars`\|`posts`\|`site`;`name`, `url`(unique), `size`, `content_type`, `storage_type`=`local`\|`s3`, `user_id`。
|
|
||||||
|
|
||||||
### 2.12 point_ledgers
|
|
||||||
|
|
||||||
`user_id`, `delta`, `balance`(变动后), `reason`, `ref_type`, `ref_id`, `note`, `created_at`。
|
|
||||||
|
|
||||||
### 2.13 check_ins
|
|
||||||
|
|
||||||
唯一 `(user_id, day)`,day=`YYYY-MM-DD`;`points`, `streak`。
|
|
||||||
|
|
||||||
### 2.14 lottery_draws
|
|
||||||
|
|
||||||
每日抽奖唯一 `(user_id, day)`;`points` 可为 0。
|
|
||||||
|
|
||||||
### 2.15 post_content_unlocks
|
|
||||||
|
|
||||||
唯一 `(user_id, post_id, block_key)`;`cost`。
|
|
||||||
|
|
||||||
### 2.16 site_pages
|
|
||||||
|
|
||||||
`title`, `slug`(unique), `content`, `published`, `sort_order`, `show_in_footer`, `show_in_nav` + soft delete。
|
|
||||||
|
|
||||||
### 2.17 polls / poll_options / poll_votes
|
|
||||||
|
|
||||||
- Poll:`post_id` unique;`multi`, `max_choices`, `closed`, `ends_at`
|
|
||||||
- Option:`post_id`, `text`(64), `sort_order`, `vote_count`
|
|
||||||
- Vote:唯一 `(post_id, option_id, user_id)`(多选时多行)
|
|
||||||
|
|
||||||
### 2.18 post_lottery_winners
|
|
||||||
|
|
||||||
`post_id`, `user_id`, `comment_id`, `created_at`。
|
|
||||||
|
|
||||||
### 2.19 badge_defs / user_badges
|
|
||||||
|
|
||||||
BadgeDef:`code` unique, `name`, `description`, `icon`, `kind`=`auto`\|`limited`, `metric`, `threshold`, `sort_order`, `enabled`。
|
|
||||||
UserBadge:唯一 `(user_id, badge_id)`;`awarded_at`, `awarded_by`(0=系统)。
|
|
||||||
|
|
||||||
### 2.20 forum_settings
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| key | PK string(64) |
|
|
||||||
| value | string(2048) |
|
|
||||||
|
|
||||||
### 2.21 oauth_clients / oauth_auth_codes
|
|
||||||
|
|
||||||
Client:`client_id` unique, `client_secret_hash`, `name`, `redirect_uris`(可多行), `enabled`。
|
|
||||||
AuthCode:一次性码 + PKCE 字段 + `expires_at` + `used`。
|
|
||||||
|
|
||||||
### 2.22 gitea_repos
|
|
||||||
|
|
||||||
同步缓存:`gitea_id` unique, owner/name/full_name, description, html_url, language, stars/forks, private, updated_at_remote, forum_user_id, synced_at。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 枚举全集
|
|
||||||
|
|
||||||
### 3.1 角色 Role
|
|
||||||
|
|
||||||
`user` | `admin`
|
|
||||||
|
|
||||||
### 3.2 内容状态 ContentStatus
|
|
||||||
|
|
||||||
`pending` | `published` | `rejected`
|
|
||||||
|
|
||||||
### 3.3 帖类型 PostType
|
|
||||||
|
|
||||||
`normal` | `question` | `poll` | `bounty` | `lottery`
|
|
||||||
|
|
||||||
### 3.4 悬赏 BountyStatus
|
|
||||||
|
|
||||||
`open` | `awarded` | `refunded`(空串视为非悬赏)
|
|
||||||
|
|
||||||
### 3.5 帖内抽奖 PostLotteryStatus
|
|
||||||
|
|
||||||
`open` | `drawn`
|
|
||||||
|
|
||||||
### 3.6 私信 kind
|
|
||||||
|
|
||||||
| 值 | 含义 |
|
|
||||||
|----|------|
|
|
||||||
| user | 用户互发 |
|
|
||||||
| system | 系统通知 |
|
|
||||||
| reject | 帖/评被拒 |
|
|
||||||
| report_result | 举报处理结果 |
|
|
||||||
| reply | 被回复 |
|
|
||||||
| mention | 被 @ |
|
|
||||||
| moderation | 待审提醒管理员 |
|
|
||||||
|
|
||||||
### 3.7 举报
|
|
||||||
|
|
||||||
Status:`pending` | `resolved` | `dismissed`
|
|
||||||
Reason:`spam` | `abuse` | `illegal` | `irrelevant` | `other`
|
|
||||||
|
|
||||||
### 3.8 友链申请
|
|
||||||
|
|
||||||
`pending` | `approved` | `rejected`
|
|
||||||
|
|
||||||
### 3.9 积分 reason
|
|
||||||
|
|
||||||
| 值 | 含义 |
|
|
||||||
|----|------|
|
|
||||||
| check_in | 签到 |
|
|
||||||
| lottery | 每日抽奖 |
|
|
||||||
| unlock_spend | 解锁消费 |
|
|
||||||
| creator_income | 创作分成 |
|
|
||||||
| admin_adjust | 管理员调账 |
|
|
||||||
| bounty_escrow | 悬赏托管 |
|
|
||||||
| bounty_award | 悬赏发放 |
|
|
||||||
| bounty_refund | 悬赏退回 |
|
|
||||||
|
|
||||||
### 3.10 徽章
|
|
||||||
|
|
||||||
Kind:`auto` | `limited`
|
|
||||||
Metric:`tenure_days` | `likes_received` | `creator_income`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 等级(Exp → Level)
|
|
||||||
|
|
||||||
源:[`model/level.go`](../../models/level.go)
|
|
||||||
|
|
||||||
| Level | 最低 Exp |
|
|
||||||
|-------|----------|
|
|
||||||
| 1 | 0 |
|
|
||||||
| 2 | 20 |
|
|
||||||
| 3 | 50 |
|
|
||||||
| 4 | 100 |
|
|
||||||
| 5 | 200 |
|
|
||||||
| 6 | 400 |
|
|
||||||
| 7 | 800 |
|
|
||||||
| 8 | 1500 |
|
|
||||||
| 9 | 3000 |
|
|
||||||
| 10 | 5000 |
|
|
||||||
|
|
||||||
管理员设等级时,应把 Exp 调到该等级门槛(见后台 API)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 内置自动徽章(seed)
|
|
||||||
|
|
||||||
源:[`model/db.go`](../../models/db.go) `seedDefaultBadges`
|
|
||||||
|
|
||||||
| code | 名称 | metric | threshold |
|
|
||||||
|------|------|--------|-----------|
|
|
||||||
| tenure_30 | 初来乍到 | tenure_days | 30 |
|
|
||||||
| tenure_365 | 资深居民 | tenure_days | 365 |
|
|
||||||
| likes_10 | 小有人气 | likes_received | 10 |
|
|
||||||
| likes_100 | 人气作者 | likes_received | 100 |
|
|
||||||
| likes_1000 | 人气巨星 | likes_received | 1000 |
|
|
||||||
| income_100 | 小有进账 | creator_income | 100 |
|
|
||||||
| income_1000 | 创作达人 | creator_income | 1000 |
|
|
||||||
|
|
||||||
已存在同 `code` 则跳过插入。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. forum_settings 键与默认值
|
|
||||||
|
|
||||||
源:[`service/settings.go`](../../services/settings.go)、[`service/permalink.go`](../../services/permalink.go)
|
|
||||||
|
|
||||||
### 6.1 论坛限制
|
|
||||||
|
|
||||||
| Key | 默认 | 说明 |
|
|
||||||
|-----|------|------|
|
|
||||||
| post_edit_window_hours | 24 | 0 可表示特殊策略,以实现为准 |
|
|
||||||
| comment_edit_window_minutes | 3 | |
|
|
||||||
| rate_limit_post | 10 | 窗口内次数 |
|
|
||||||
| rate_limit_comment | 10 | |
|
|
||||||
| rate_limit_register | 10 | |
|
|
||||||
| rate_limit_login | 10 | |
|
|
||||||
| rate_limit_window_sec | 60 | |
|
|
||||||
| post_title_max | 128 | |
|
|
||||||
| post_tags_max | 256 | |
|
|
||||||
| post_content_max | 50000 | |
|
|
||||||
| comment_max | 5000 | |
|
|
||||||
| search_keyword_min | 1 | |
|
|
||||||
| search_keyword_max | 50 | |
|
|
||||||
| page_size_default | 30 | API 硬上限 100 |
|
|
||||||
| password_min_len | 6 | |
|
|
||||||
| avatar_max_mb | 2 | |
|
|
||||||
| signature_max | 200 | |
|
|
||||||
| open_posts_in_new_tab | 1 | |
|
|
||||||
| open_content_links_in_new_tab | 1 | |
|
|
||||||
|
|
||||||
### 6.2 Feed / 侧栏 / 友链展示
|
|
||||||
|
|
||||||
| Key | 默认 |
|
|
||||||
|-----|------|
|
|
||||||
| feed_list_style | `title`(另有 `excerpt` / `thumbnail`) |
|
|
||||||
| aside_show_tag_cloud | 0 |
|
|
||||||
| aside_show_recent_comments | 0 |
|
|
||||||
| aside_show_friend_links | 1 |
|
|
||||||
| aside_widgets | JSON 数组,见下 |
|
|
||||||
| nav_show_friend_links | 1 |
|
|
||||||
| footer_show_friend_links | 1 |
|
|
||||||
| friend_link_reciprocal_check | 0 |
|
|
||||||
| permalink_enabled | 0 |
|
|
||||||
| permalink_ext | `html` |
|
|
||||||
|
|
||||||
默认 `aside_widgets`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{"id":"tag_cloud","enabled":false},
|
|
||||||
{"id":"recent_comments","enabled":false},
|
|
||||||
{"id":"friend_links","enabled":true}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
合法 widget id:`tag_cloud` | `recent_comments` | `recent_users` | `friend_links`。
|
|
||||||
|
|
||||||
### 6.3 SMTP
|
|
||||||
|
|
||||||
| Key | 默认 |
|
|
||||||
|-----|------|
|
|
||||||
| smtp_enabled | 0 |
|
|
||||||
| smtp_host | |
|
|
||||||
| smtp_port | 465 |
|
|
||||||
| smtp_username / smtp_password | |
|
|
||||||
| smtp_from | |
|
|
||||||
| smtp_from_name | 姜十三论坛 |
|
|
||||||
| smtp_encryption | `ssl`(另有 `none` / `starttls`) |
|
|
||||||
|
|
||||||
### 6.4 OIDC
|
|
||||||
|
|
||||||
| Key | 默认 |
|
|
||||||
|-----|------|
|
|
||||||
| oidc_enabled | 0 |
|
|
||||||
| oidc_root_url | |
|
|
||||||
| oidc_group_claim | groups |
|
|
||||||
| oidc_admin_group | gitea-admin |
|
|
||||||
| oidc_user_group | gitea-users |
|
|
||||||
| oidc_rsa_private_pem | (空)启用 OIDC 时懒生成并写入;未启用不落盘 `.oidc_rsa.pem` |
|
|
||||||
|
|
||||||
### 6.4b 敏感词
|
|
||||||
|
|
||||||
| Key | 默认 |
|
|
||||||
|-----|------|
|
|
||||||
| filter_words | 默认词表文本;启动时从旧 `filter_words.txt` 导入(若键为空) |
|
|
||||||
|
|
||||||
### 6.5 Gitea 同步(**后置**,键保留兼容)
|
|
||||||
|
|
||||||
| Key | 默认 |
|
|
||||||
|-----|------|
|
|
||||||
| gitea_sync_enabled | 0 |
|
|
||||||
| gitea_base_url | |
|
|
||||||
| gitea_token | |
|
|
||||||
| gitea_sync_interval_min | 60 |
|
|
||||||
|
|
||||||
本迭代不启同步任务;见 [02-features.md](02-features.md) §K。
|
|
||||||
|
|
||||||
### 6.6 存储
|
|
||||||
|
|
||||||
| Key | 默认 |
|
|
||||||
|-----|------|
|
|
||||||
| storage_type | local |
|
|
||||||
| storage_endpoint / region / bucket | region 默认 us-east-1 |
|
|
||||||
| storage_access_key / storage_secret_key | |
|
|
||||||
| storage_public_base_url / storage_prefix | |
|
|
||||||
| storage_force_path_style | 1 |
|
|
||||||
| storage_image_delivery | webp(或 original) |
|
|
||||||
|
|
||||||
### 6.7 站点品牌
|
|
||||||
|
|
||||||
| Key | 默认 |
|
|
||||||
|-----|------|
|
|
||||||
| site_name | 姜十三论坛 |
|
|
||||||
| site_slogan | 拾三一隅,自在交流 |
|
|
||||||
| site_description / site_keywords | 空 |
|
|
||||||
| site_logo_mark | 姜 |
|
|
||||||
| site_logo / site_favicon / site_og_image | 空 |
|
|
||||||
| site_icp_beian | 空 |
|
|
||||||
| site_icp_beian_url | https://beian.miit.gov.cn/ |
|
|
||||||
| site_friend_links | `[]` JSON,最多 20 条 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 升级兼容补丁(现网 InitDB)
|
|
||||||
|
|
||||||
[`model/db.go`](../../models/db.go) 在 AutoMigrate 后:
|
|
||||||
|
|
||||||
- 空 `status` 的帖/评 → `published`
|
|
||||||
- 空 `post_type` → `normal`
|
|
||||||
- Exp=0 用户按存量内容粗算经验:`posts*10 + comments*2 + like_sum`
|
|
||||||
|
|
||||||
新站若从空库开始可忽略;若迁移旧库需保留等价 backfill。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 内容门控在库中的形态
|
|
||||||
|
|
||||||
**无独立表**存放门控块;存在 `posts.content` HTML 中,例如:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<members-only>...</members-only>
|
|
||||||
<reply-only>...</reply-only>
|
|
||||||
<points-only data-cost="10">...</points-only>
|
|
||||||
```
|
|
||||||
|
|
||||||
积分解锁 `block_key` = `sha256(innerHTML)[:16]`(hex),见 [`service/unlock.go`](../../services/unlock.go)。
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
# 04 · HTTP API 合约
|
|
||||||
|
|
||||||
> **读者**:机器客户端 / 集成方;浏览器 UI **不**使用本文件作为主路径
|
|
||||||
> **前置**:[03-data-model.md](03-data-model.md)、[08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
|
|
||||||
> **源码**:[`routers/setup.go`](../../routers/setup.go)、[`modules/auth/auth.go`](../../modules/auth/auth.go)
|
|
||||||
|
|
||||||
本分支(`rebuild/gitea-ssr`)浏览器走 **`routers/web` 模板 + 表单**。
|
|
||||||
**当前进程仅注册** health / OIDC / robots / sitemap / media thumb 等机器相关路由;论坛 CRUD 的 `/api/*` **handler 已从本分支删除**(对照实现见 `main`,合约形状见下文历史章节)。
|
|
||||||
|
|
||||||
`main` 分支 SPA 仍完整实现下表;对照请 checkout `main`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 本分支已注册的机器入口
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/health` | 探活 |
|
|
||||||
| GET | `/robots.txt` | 抓取规则 |
|
|
||||||
| GET | `/sitemap.xml` | 站点地图 |
|
|
||||||
| GET/POST | `/oauth/*`、`/.well-known/openid-configuration` | OIDC Provider |
|
|
||||||
| GET | `/media/thumb/*`、`/uploads/*` | 媒体 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 历史 JSON 合约(main / 对照,本分支默认不挂载)
|
|
||||||
|
|
||||||
以下章节描述原 SPA 使用的 `/api` 形状,便于迁移业务语义;**实现 UI 时请用 web 表单,勿恢复双轨。**
|
|
||||||
|
|
||||||
### 通用约定(历史)
|
|
||||||
|
|
||||||
| 项 | 约定 |
|
|
||||||
|----|------|
|
|
||||||
| Base | 同源;`credentials: 'same-origin'` |
|
|
||||||
| 成功 | HTTP 2xx + JSON body |
|
|
||||||
| 失败 | 非 2xx + `{ "error": "..." }` |
|
|
||||||
| 鉴权 | Cookie `jiang13_session`(opaque);机器 OIDC 用 Bearer |
|
|
||||||
|
|
||||||
### 分页形态差异
|
|
||||||
|
|
||||||
| 场景 | 典型字段 |
|
|
||||||
|------|----------|
|
|
||||||
| 前台帖列表 | `posts`, `total`, `page`, `size`, `has_more` |
|
|
||||||
| 后台多数列表 | `total`, `page`, `total_pages` + 实体数组 |
|
|
||||||
| 私信会话消息 | `before` 游标式 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 基础设施 / SEO / 静态(节选,仍有效)
|
|
||||||
|
|
||||||
| 方法 | 路径 | 鉴权 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/health` | 无 | `{ "status": "ok" }` |
|
|
||||||
| GET | `/robots.txt` | 无 | 文本 |
|
|
||||||
| GET | `/sitemap.xml` | 无 | XML |
|
|
||||||
| GET | `/media/thumb/*filepath` | 无 | 缩略图 / WebP 等 |
|
|
||||||
| GET | `/uploads/*` | 无 | 静态上传文件 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. OIDC Provider
|
|
||||||
|
|
||||||
| 方法 | 路径 | 鉴权 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/.well-known/openid-configuration` | 无 | Discovery |
|
|
||||||
| GET | `/oauth/jwks` | 无 | JWKS |
|
|
||||||
| GET | `/oauth/authorize` | OptionalAuth | 授权码流程 |
|
|
||||||
| POST | `/oauth/token` | 无(客户端凭证) | 换 token |
|
|
||||||
| GET/POST | `/oauth/userinfo` | Bearer | 用户信息 |
|
|
||||||
| GET/POST | `/oauth/logout` | 视实现 | 登出 |
|
|
||||||
|
|
||||||
细节以 [`service/oidc.go`](../../services/oidc.go) / [`handler/oidc.go`](../../routers/api/oidc.go) 为准。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 公开 API(`/api` + OptionalAuth)
|
|
||||||
|
|
||||||
### 4.1 会话与站点
|
|
||||||
|
|
||||||
| 方法 | 路径 | 响应要点 |
|
|
||||||
|------|------|----------|
|
|
||||||
| GET | `/api/me` | `{ user: UserSelf \| null }` |
|
|
||||||
| GET | `/api/stats` | `{ users, posts, boards, comments }` |
|
|
||||||
| GET | `/api/forum-limits` | `ForumLimitsPublic`(无限流内部字段) |
|
|
||||||
| GET | `/api/site-branding` | `SiteBranding`(可含 `site_url`) |
|
|
||||||
| GET | `/api/captcha` | `{ id, image }` image 为 data URL 或 base64 |
|
|
||||||
| GET | `/api/register/config` | 见下 |
|
|
||||||
|
|
||||||
**RegisterConfig**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"is_first_user": true,
|
|
||||||
"mail_ready": false,
|
|
||||||
"require_email_code": false,
|
|
||||||
"register_open": true,
|
|
||||||
"email_code_len": 6
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 认证(限流)
|
|
||||||
|
|
||||||
| 方法 | 路径 | Body | 响应 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| POST | `/api/register` | Form/JSON: username, password, nickname, email, captcha_id, captcha, email_code? | 成功后通常种 cookie |
|
|
||||||
| POST | `/api/login` | Form: username, password | 种 cookie |
|
|
||||||
| POST | `/api/register/email-code` | JSON `{ email }` | `{ message }` |
|
|
||||||
| POST | `/api/password-reset/email-code` | JSON `{ email }` | `{ message }` |
|
|
||||||
| POST | `/api/password-reset` | JSON `{ email, email_code, new_password }` | `{ message }` |
|
|
||||||
|
|
||||||
### 4.3 内容只读
|
|
||||||
|
|
||||||
| 方法 | 路径 | Query / 说明 |
|
|
||||||
|------|------|----------------|
|
|
||||||
| GET | `/api/boards` | `{ boards: Board[] }` |
|
|
||||||
| GET | `/api/posts` | 见下表 |
|
|
||||||
| GET | `/api/posts/hot` | 热门列表 |
|
|
||||||
| GET | `/api/posts/:id` | `skip_view=1` 可选;返回 `PostDetailResponse` |
|
|
||||||
| GET | `/api/posts/:id/comments` | `my_ids` 可选(逗号分隔,便于标自己的楼) |
|
|
||||||
| GET | `/api/tags` | `limit` 默认 40 → `{ tags: [{name,count}] }` |
|
|
||||||
| GET | `/api/comments/recent` | `{ comments: RecentComment[] }` |
|
|
||||||
| GET | `/api/users/search` | `q`, `limit` |
|
|
||||||
| GET | `/api/users/recent` | `{ users: RecentUser[] }` |
|
|
||||||
| GET | `/api/users/:id` | `{ user: UserPublic, stats }` |
|
|
||||||
| GET | `/api/pages` | 已发布摘要列表 |
|
|
||||||
| GET | `/api/pages/:slug` | 单页详情 |
|
|
||||||
| GET | `/api/projects` | `page`, `limit`, `q` |
|
|
||||||
|
|
||||||
**GET `/api/posts` Query**
|
|
||||||
|
|
||||||
| 参数 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| page | 默认 1 |
|
|
||||||
| size | 默认 page_size_default,上限 100 |
|
|
||||||
| board_id | 0 或不传=全部 |
|
|
||||||
| user_id | 某用户的帖 |
|
|
||||||
| keyword | 搜索词 |
|
|
||||||
| tag | 标签 |
|
|
||||||
| author | 用户名优先,否则昵称精确匹配 |
|
|
||||||
| title_only | `1`/`true` 仅搜标题 |
|
|
||||||
| sort | `latest` \| `reply` \| `hot` |
|
|
||||||
|
|
||||||
**响应示例**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"posts": [ /* PostItem */ ],
|
|
||||||
"total": 100,
|
|
||||||
"page": 1,
|
|
||||||
"size": 30,
|
|
||||||
"has_more": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**PostDetailResponse 要点**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"post": { /* PostItem + content */ },
|
|
||||||
"comment_count": 0,
|
|
||||||
"liked": false,
|
|
||||||
"favorited": false,
|
|
||||||
"has_replied": false,
|
|
||||||
"can_edit": true,
|
|
||||||
"edit_block_reason": "",
|
|
||||||
"is_edited": false,
|
|
||||||
"post_edit_window_hours": 24,
|
|
||||||
"poll": { /* PollView 可选 */ },
|
|
||||||
"lottery": { /* PostLotteryView 可选 */ },
|
|
||||||
"bounty_can_refund": false,
|
|
||||||
"bounty_refund_block_reason": "",
|
|
||||||
"bounty_eligible_reply_count": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 游客可写评论
|
|
||||||
|
|
||||||
| 方法 | 路径 | 限流 | Body |
|
|
||||||
|------|------|------|------|
|
|
||||||
| POST | `/api/posts/:id/comments` | comment | Form: content, reply_to?, is_private?, 以及游客字段(以实现为准) |
|
|
||||||
|
|
||||||
登录用户发评也走此路径(RequireAuth 组外公开组已注册该路由)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 需登录 API(`/api` + RequireAuth)
|
|
||||||
|
|
||||||
### 5.1 会话与资料
|
|
||||||
|
|
||||||
| 方法 | 路径 | Body | 响应 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| POST | `/api/logout` | | 清 cookie |
|
|
||||||
| GET | `/api/favorites` | | `{ favorites, total }` |
|
|
||||||
| GET | `/api/profile/stats` | | `{ stats: UserActivityStats }` |
|
|
||||||
| POST | `/api/profile/nickname` | Form nickname | |
|
|
||||||
| POST | `/api/profile/signature` | Form signature | `{ message, user }` |
|
|
||||||
| POST | `/api/profile/password` | Form old_password, new_password | |
|
|
||||||
| POST | `/api/profile/avatar` | Form avatar=file | `{ avatar }` |
|
|
||||||
| POST | `/api/uploads/image` | Form image=file | `{ url }` |
|
|
||||||
|
|
||||||
### 5.2 帖子写操作
|
|
||||||
|
|
||||||
| 方法 | 路径 | Body | 响应 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| POST | `/api/posts` | Form: board_id, title, content, tags?, post_type?, poll_options?, bounty_points?, lottery_winner_count? | `{ message, post_id, status }` |
|
|
||||||
| PUT | `/api/posts/:id` | Form: title, content, tags?, board_id?, post_type? | `{ message }` |
|
|
||||||
| DELETE | `/api/posts/:id` | | 软删 |
|
|
||||||
| GET | `/api/posts/:id/revisions` | | `{ revisions }` |
|
|
||||||
| GET | `/api/posts/:id/revisions/:revId` | | `{ revision }` |
|
|
||||||
| POST | `/api/posts/:id/like` | | `{ liked, like_count }` |
|
|
||||||
| POST | `/api/posts/:id/favorite` | | `{ favorited }` |
|
|
||||||
| POST | `/api/posts/:id/resolve` | Form resolved=`1`\|`0` | `{ question_resolved }` |
|
|
||||||
| POST | `/api/posts/:id/poll/vote` | JSON `{ option_ids: number[] }` | `{ poll }` |
|
|
||||||
| POST | `/api/posts/:id/poll/close` | | `{ poll }` |
|
|
||||||
| POST | `/api/posts/:id/bounty/award` | Form comment_id | |
|
|
||||||
| POST | `/api/posts/:id/bounty/refund` | | |
|
|
||||||
| POST | `/api/posts/:id/lottery/draw` | | `{ lottery }` |
|
|
||||||
| POST | `/api/posts/:id/report` | JSON `{ reason, detail? }` | `{ report }` |
|
|
||||||
| POST | `/api/posts/:id/unlock` | JSON `{ block_key }` | 见下 |
|
|
||||||
|
|
||||||
**poll_options JSON 示例**(Form 字段字符串)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"multi": false,
|
|
||||||
"max_choices": 1,
|
|
||||||
"ends_at": "2026-09-01T12:00:00Z",
|
|
||||||
"options": [{ "text": "选项A" }, { "text": "选项B" }]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**unlock 响应**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "...",
|
|
||||||
"unlock": {
|
|
||||||
"block_key": "abcdef0123456789",
|
|
||||||
"cost": 10,
|
|
||||||
"points_balance": 90,
|
|
||||||
"inner_html": "<p>...</p>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 评论写操作
|
|
||||||
|
|
||||||
| 方法 | 路径 | Body |
|
|
||||||
|------|------|------|
|
|
||||||
| POST | `/api/comments/:id/like` | → `{ liked, like_count }` |
|
|
||||||
| POST | `/api/comments/:id/report` | JSON `{ reason, detail? }` |
|
|
||||||
| PUT | `/api/comments/:id` | Form content |
|
|
||||||
| DELETE | `/api/comments/:id` | |
|
|
||||||
|
|
||||||
### 5.4 私信
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/api/messages/unread-count` | `{ count, dm_count?, notify_count? }` |
|
|
||||||
| GET | `/api/messages/notifications` | page, size, kind |
|
|
||||||
| POST | `/api/messages/notifications/read` | |
|
|
||||||
| GET | `/api/messages/conversations` | page, size |
|
|
||||||
| GET | `/api/messages/conversations/:peerId` | size, before;peerId=0 为系统 |
|
|
||||||
| POST | `/api/messages/conversations/:peerId/read` | |
|
|
||||||
| POST | `/api/messages` | JSON `{ to_user_id, subject?, content }` |
|
|
||||||
| POST | `/api/messages/read-all` | |
|
|
||||||
|
|
||||||
### 5.5 经济
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/api/me/points` | page;含 ledger、check_in、lottery |
|
|
||||||
| GET/POST | `/api/me/check-in` | 状态 / 执行签到 |
|
|
||||||
| GET/POST | `/api/me/lottery` | 状态 / 抽奖 |
|
|
||||||
|
|
||||||
### 5.6 友链申请
|
|
||||||
|
|
||||||
| 方法 | 路径 | Body |
|
|
||||||
|------|------|------|
|
|
||||||
| POST | `/api/friend-links/apply` | JSON name, url, logo, link_on_homepage, reciprocal_page_url? |
|
|
||||||
| POST | `/api/friend-links/logo` | Form logo=file → `{ url }` |
|
|
||||||
| GET | `/api/friend-links/my-applies` | |
|
|
||||||
| PUT | `/api/friend-links/applies/:id` | 同申请字段 |
|
|
||||||
| DELETE | `/api/friend-links/applies/:id` | 取消 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 管理 API(`/api/admin` + Auth + Admin)
|
|
||||||
|
|
||||||
### 6.1 仪表盘与设置
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/dashboard` | AdminDashboard |
|
|
||||||
| GET | `/settings` | AdminSettings 聚合 |
|
|
||||||
| PUT | `/settings/forum` | ForumLimits |
|
|
||||||
| PUT | `/settings/mail` | MailConfig |
|
|
||||||
| POST | `/settings/mail/test` | `{ to }` |
|
|
||||||
| PUT | `/settings/oidc` | OIDCConfig |
|
|
||||||
| PUT | `/settings/gitea` | **后置**(501) |
|
|
||||||
| POST | `/settings/gitea/sync` | **后置**(501) |
|
|
||||||
| PUT | `/settings/storage` | StorageConfig |
|
|
||||||
| PUT | `/settings/branding` | SiteBranding |
|
|
||||||
| POST | `/settings/branding/upload` | Form kind=`logo`\|`favicon`\|`og_image`, file |
|
|
||||||
| POST | `/settings/branding/clear` | JSON `{ kind }` |
|
|
||||||
| GET/PUT | `/settings/filter-words` | GET 读;PUT `{ content }` |
|
|
||||||
|
|
||||||
(上表路径均相对于 `/api/admin`。)
|
|
||||||
|
|
||||||
### 6.2 OAuth 客户端
|
|
||||||
|
|
||||||
| 方法 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| GET/POST | `/oauth/clients` |
|
|
||||||
| PUT/DELETE | `/oauth/clients/:id` |
|
|
||||||
|
|
||||||
创建/更新 body:`name`, `redirect_uris`, `client_id?`, `enabled?`, `client_secret?`, `rotate_secret?`。
|
|
||||||
|
|
||||||
### 6.3 板块 / 单页 / 友链
|
|
||||||
|
|
||||||
| 方法 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| POST/PUT/DELETE | `/boards`, `/boards/:id` |
|
|
||||||
| GET/POST | `/pages` |
|
|
||||||
| GET/PUT/DELETE | `/pages/:id` |
|
|
||||||
| PUT | `/pages/:id/published` → `{ published }` |
|
|
||||||
| GET | `/friend-link-applies` |
|
|
||||||
| PUT | `/friend-link-settings` |
|
|
||||||
| POST | `/friend-link-applies/:id/approve` \| `reject` \| `recheck` |
|
|
||||||
|
|
||||||
### 6.4 帖子审核与运营
|
|
||||||
|
|
||||||
| 方法 | 路径 | Body |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/posts` | page, keyword, status |
|
|
||||||
| GET | `/posts/trash` | |
|
|
||||||
| POST | `/posts/:id/pin` | `{ pinned }` |
|
|
||||||
| POST | `/posts/:id/board-pin` | `{ board_pinned }` |
|
|
||||||
| POST | `/posts/:id/feature` | `{ featured }` |
|
|
||||||
| POST | `/posts/:id/lock` | `{ locked }` → edit_locked |
|
|
||||||
| POST | `/posts/:id/comments-lock` | `{ locked }` |
|
|
||||||
| POST | `/posts/:id/approve` | |
|
|
||||||
| POST | `/posts/:id/reject` | `{ reason }` |
|
|
||||||
| POST | `/posts/:id/restore` | |
|
|
||||||
| DELETE | `/posts/:id/purge` | 硬删 |
|
|
||||||
| DELETE | `/posts/:id` | 软删 |
|
|
||||||
|
|
||||||
### 6.5 评论 / 举报 / 用户 / 徽章 / 媒体 / 备份
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/comments`, `/comments/trash` | |
|
|
||||||
| GET | `/comments/:id/revisions` | |
|
|
||||||
| POST | `/comments/:id/approve` \| `reject` \| `restore` | reject 可带 reason |
|
|
||||||
| DELETE | `/comments/:id`, `/comments/:id/purge` | |
|
|
||||||
| GET | `/reports` | page, status |
|
|
||||||
| POST | `/reports/:id/handle` | `{ action, handle_note?, reject_reason? }`;action=`dismiss`\|`resolve`\|`reject_post`\|`reject_comment` |
|
|
||||||
| GET | `/users` | page, keyword, filter |
|
|
||||||
| POST | `/users/:id/ban` | `{ banned }` |
|
|
||||||
| POST | `/users/:id/verify` | `{ verified }` |
|
|
||||||
| POST | `/users/:id/level` | `{ level }` |
|
|
||||||
| POST | `/users/:id/points` | `{ delta, note? }` |
|
|
||||||
| POST | `/users/:id/badges` | `{ badge_id, revoke? }` |
|
|
||||||
| GET/POST | `/badges` | 列表 / upsert |
|
|
||||||
| GET | `/media` | category, page, size, q |
|
|
||||||
| POST | `/media/delete` | `{ urls: string[] }` |
|
|
||||||
| POST | `/backup` | `{ filename, download }` |
|
|
||||||
| GET | `/backup/download/:name` | 文件下载 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 核心类型速查(与前端对齐)
|
|
||||||
|
|
||||||
详见 [`frontend/src/api/types.ts`]((仅 main)frontend/src/api/types.ts)。实现时至少对齐:
|
|
||||||
|
|
||||||
- `User` / `UserPublic` / `UserActivityStats`
|
|
||||||
- `Board` / `PostItem` / `PostDetailResponse` / `Comment`
|
|
||||||
- `ForumLimits` / `ForumLimitsPublic` / `SiteBranding`
|
|
||||||
- `PollView` / `PostLotteryView`
|
|
||||||
- `PrivateMessage` / `MessageConversation`
|
|
||||||
- `PostReport` / `FriendLinkApply` / `BadgeDef` / `PointLedger`
|
|
||||||
- `CheckInStatus` / `LotteryStatus`
|
|
||||||
- `AdminDashboard` / `AdminSettings` / `StorageConfig` / `MailConfig` / `OIDCConfig`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 鉴权错误语义(现网)
|
|
||||||
|
|
||||||
中间件对未登录 / 过期 / 禁言返回 JSON error(并可能清 cookie)。前端统一 `throw new Error(data.error)`。新站应保持可区分的错误文案或错误码,避免前端无法提示。
|
|
||||||
|
|
||||||
源:[`middleware/auth.go`](../../modules/auth/auth.go)。
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
# 05 · 业务规则与状态机
|
|
||||||
|
|
||||||
> **读者**:实现领域逻辑的 AI(最易「看起来像但算错」)
|
|
||||||
> **前置**:[03-data-model.md](03-data-model.md)、[04-api.md](04-api.md)
|
|
||||||
> **源码**:[`service/`](../../services/)、[`model/models.go`](../../models/models.go)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 注册与引导
|
|
||||||
|
|
||||||
源:[`routers/web/auth.go`](../../routers/web/auth.go)、[`services/auth.go`](../../services/auth.go)
|
|
||||||
|
|
||||||
| 规则 | 细节 |
|
|
||||||
|------|------|
|
|
||||||
| 管理员 | **仅** `/install` 向导创建(不再「首注册变管理员」) |
|
|
||||||
| 开放注册 | 安装完成后开放;不依赖 SMTP |
|
|
||||||
| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时可无码注册 |
|
|
||||||
| 会话 | Cookie `jiang13_session` = opaque id;表 `sessions`;HttpOnly + SameSite=Lax;HTTPS 时 Secure;TTL 7 天滑动续期 |
|
|
||||||
| 吊销 | 登出删当前 session;禁言 / 重置密码删该用户全部 session |
|
|
||||||
| HMAC 密钥 | `{DATA}/.jwt_secret` 仅 CSRF 等 HMAC(**不是**浏览器登录 JWT) |
|
|
||||||
|
|
||||||
密码:bcrypt;最小长度来自 `password_min_len`(默认 6)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 内容审核
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> pending: 普通用户发帖或评论
|
|
||||||
[*] --> published: admin或verified免审
|
|
||||||
pending --> published: 管理员通过
|
|
||||||
pending --> rejected: 管理员拒绝
|
|
||||||
published --> pending: 非免审用户编辑后可再进审
|
|
||||||
```
|
|
||||||
|
|
||||||
| 规则 | 细节 |
|
|
||||||
|------|------|
|
|
||||||
| 免审 | `role=admin` 或 `verified=true`(`SkipsModeration`) |
|
|
||||||
| 可见性 | `pending`/`rejected`:**仅作者与管理员**可见(对外表现为 404) |
|
|
||||||
| 列表 | 公开 Feed 只出 `published` |
|
|
||||||
| 拒绝 | 可写原因;通知作者(站内信 kind=`reject`,可选邮件) |
|
|
||||||
| 待审提醒 | 通知管理员(kind=`moderation`) |
|
|
||||||
| 游客评论 | 通常直接或按实现进入审核;勿假设与登录用户完全相同 |
|
|
||||||
|
|
||||||
源:[`service/post.go`](../../services/post.go) `CanViewPost`、[`service/comment.go`](../../services/comment.go)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 编辑时限与锁
|
|
||||||
|
|
||||||
| 对象 | 规则 |
|
|
||||||
|------|------|
|
|
||||||
| 帖子 | 普通用户在 `post_edit_window_hours`(默认 24)内可编;超时不可(管理员除外) |
|
|
||||||
| 帖子 | `edit_locked=true` 时非管理员不可编 |
|
|
||||||
| 评论 | `comment_edit_window_minutes`(默认 3) |
|
|
||||||
| 评论 | 帖子 `comments_locked` 时禁止新评论 |
|
|
||||||
|
|
||||||
详情接口返回 `can_edit`、`edit_block_reason`、`post_edit_window_hours`。
|
|
||||||
|
|
||||||
修订:每次成功修改前写入 `post_revisions` / `comment_revisions`(旧内容快照)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 经验 Exp(不可消费)
|
|
||||||
|
|
||||||
| 事件 | Delta |
|
|
||||||
|------|------|
|
|
||||||
| 发帖成功(公开路径) | +10 |
|
|
||||||
| 评论成功 | +2 |
|
|
||||||
| 帖子被点赞 | +1(作者) |
|
|
||||||
|
|
||||||
源:[`service/post.go`](../../services/post.go)、[`service/comment.go`](../../services/comment.go)、[`service/badge.go`](../../services/badge.go) `AddExp`。
|
|
||||||
|
|
||||||
等级门槛见 [03-data-model.md](03-data-model.md) §4。管理员设 level 时应同步 Exp 到门槛值。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 内容门控(红action)
|
|
||||||
|
|
||||||
源:[`service/content.go`](../../services/content.go)、[`handler/api.go`](../../routers/api/api.go) `APIPostDetail`、[`service/unlock.go`](../../services/unlock.go)
|
|
||||||
|
|
||||||
### 5.1 出口顺序(详情)
|
|
||||||
|
|
||||||
1. `SanitizePostHTML`(消毒)
|
|
||||||
2. 若 **游客**:`RedactMembersOnlyHTML` + `RedactReplyOnlyHTML`
|
|
||||||
3. 若 **已登录** 且非管理员、非作者、且未回复:仅 `RedactReplyOnlyHTML`
|
|
||||||
4. 作者与管理员:members/reply 块不遮
|
|
||||||
5. 积分块:按已解锁 key 集合 `RedactPointsOnlyHTML`;作者/管理员策略以实现为准(作者可免费解锁记录)
|
|
||||||
|
|
||||||
搜索 / SEO:`RedactGatedPostHTML` = members + reply + points 全遮。
|
|
||||||
|
|
||||||
### 5.2 积分解锁
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|----|-----|
|
|
||||||
| block_key | `hex(sha256(innerHTML))[:16]` |
|
|
||||||
| cost | `data-cost`,最小 1 |
|
|
||||||
| 读者 | 扣 `cost`(reason=`unlock_spend`) |
|
|
||||||
| 作者分成 | `cost * 70 / 100`(`CreatorSharePercent`),reason=`creator_income`;并累加 `creator_income_total` |
|
|
||||||
| 平台留存 | 剩余 30%(无单独流水,表现为读者扣全额、作者只加 70%) |
|
|
||||||
| 作者自己 | cost=0 记解锁,无分成 |
|
|
||||||
| 已解锁 | 返回错误「已解锁」 |
|
|
||||||
| 防刷 | 双方账号注册未满 **7 天** 且 **LastLoginIP 相同** → 拒绝整单 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 特殊帖类型
|
|
||||||
|
|
||||||
### 6.1 问答 question
|
|
||||||
|
|
||||||
- `question_resolved` 布尔;作者(或管理员)可切换
|
|
||||||
- 列表/详情用图标展示已解决状态
|
|
||||||
|
|
||||||
### 6.2 投票 poll
|
|
||||||
|
|
||||||
| 规则 | 细节 |
|
|
||||||
|------|------|
|
|
||||||
| 选项数 | 2–10;单选项 ≤64 字 |
|
|
||||||
| 多选 | `multi`;`max_choices` 钳制在 1..选项数 |
|
|
||||||
| 截止 | `ends_at` 可选;过期或 `closed` 不可再投 |
|
|
||||||
| 投票 | 每用户每帖;已投不可改(`ErrPollAlreadyVoted`) |
|
|
||||||
| 结束 | 作者或管理员 `poll/close` |
|
|
||||||
|
|
||||||
### 6.3 悬赏 bounty
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> open: 发帖托管积分
|
|
||||||
open --> awarded: 采纳他人已发布评论
|
|
||||||
open --> refunded: 退款
|
|
||||||
```
|
|
||||||
|
|
||||||
| 规则 | 细节 |
|
|
||||||
|------|------|
|
|
||||||
| 发帖 | 积分 ≥1;立即 `bounty_escrow` 扣作者积分 |
|
|
||||||
| 采纳 | 不能采纳自己的回复;评论须 published;全额给评论作者 `bounty_award` |
|
|
||||||
| 退款 | 状态 open;作者在**无他人已发布回复**时可退;**管理员始终可强制退** |
|
|
||||||
| 退款后 | status=`refunded`,`bounty_points=0`,积分退回作者 |
|
|
||||||
|
|
||||||
### 6.4 抽奖帖 lottery
|
|
||||||
|
|
||||||
| 规则 | 细节 |
|
|
||||||
|------|------|
|
|
||||||
| 中奖人数 | 1–20 |
|
|
||||||
| 参与者 | 已发布评论且 **非楼主**;按用户去重(保留最早评论) |
|
|
||||||
| 开奖 | 作者/管理员;人数不足报错;随机抽取;写 `post_lottery_winners`;status=`drawn` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 签到与每日抽奖
|
|
||||||
|
|
||||||
源:[`service/points.go`](../../services/points.go)
|
|
||||||
|
|
||||||
### 签到
|
|
||||||
|
|
||||||
- 自然日 `YYYY-MM-DD`(服务器本地时区)每用户一次
|
|
||||||
- 连续:若昨日报到则 streak+1,否则 1
|
|
||||||
- 奖励:`5 + (streak-1)`,封顶 **15**,保底 5
|
|
||||||
- 写 `check_ins` + `point_ledgers` reason=`check_in`
|
|
||||||
|
|
||||||
### 每日抽奖
|
|
||||||
|
|
||||||
- 每天一次;`cost=0`
|
|
||||||
- 奖池权重:0×40, 2×30, 5×18, 10×10, 20×2
|
|
||||||
- 中奖积分入账 reason=`lottery`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 评论特殊规则
|
|
||||||
|
|
||||||
| 规则 | 细节 |
|
|
||||||
|------|------|
|
|
||||||
| 楼层 | 按帖递增 |
|
|
||||||
| 私密评论 | 仅作者、帖作者、管理员、以及相关可见链可见(见 `canViewPrivate`) |
|
|
||||||
| 嵌套 | `thread_parent_id` 在父不可见时回挂祖先 |
|
|
||||||
| @提及 | 解析后发 kind=`mention` |
|
|
||||||
| 回复提醒 | kind=`reply`;可选 SMTP |
|
|
||||||
| HasUserReplied | 已发布或审核中的评论算「已回复」(不含被拒),用于 reply-only |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 举报处理
|
|
||||||
|
|
||||||
管理员 `handle` action:
|
|
||||||
|
|
||||||
| action | 效果 |
|
|
||||||
|--------|------|
|
|
||||||
| dismiss | 驳回举报 |
|
|
||||||
| resolve | 标记已处理(不必然删内容) |
|
|
||||||
| reject_post | 拒绝/下架帖 |
|
|
||||||
| reject_comment | 拒绝评论 |
|
|
||||||
|
|
||||||
结果通知举报人(kind=`report_result`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 友链
|
|
||||||
|
|
||||||
| 规则 | 细节 |
|
|
||||||
|------|------|
|
|
||||||
| 申请 | 登录用户;可上传 logo |
|
|
||||||
| 回链检测 | 设置开启时抓取 reciprocal 页检查是否含本站链接 |
|
|
||||||
| 通过 | 可写入品牌 `site_friend_links`(视实现:首页展示链接) |
|
|
||||||
| 展示开关 | nav / footer / aside 独立 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 敏感词与限流
|
|
||||||
|
|
||||||
- 敏感词:`forum_settings.filter_words`(Admin SSR 可改并热更);旧文件可导入
|
|
||||||
- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings(Admin 可改基础四项+窗口)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 徽章自动授予
|
|
||||||
|
|
||||||
定期或触发时检查 `BadgeDef`(kind=auto):tenure_days / likes_received / creator_income 达阈值则写入 `user_badges`。限定徽章仅管理员发放。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 置顶排序语义
|
|
||||||
|
|
||||||
| 标记 | 首页全部 Feed | 板块 Feed |
|
|
||||||
|------|---------------|-----------|
|
|
||||||
| `pinned` | 抬升 | 抬升 |
|
|
||||||
| `board_pinned` | **不**抬升 | 抬升 |
|
|
||||||
| `featured` | 标记展示,不一定改变排序 | 同左 |
|
|
||||||
|
|
||||||
具体 SQL/排序实现见 [`service/post.go`](../../services/post.go) ListItems。
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
# 06 · 页面、交互与信息架构
|
|
||||||
|
|
||||||
> **读者**:实现前台 / 后台 UI 的 AI
|
|
||||||
> **前置**:[02-features.md](02-features.md)
|
|
||||||
> **源码**:[`frontend/src/App.tsx`]((仅 main)frontend/src/App.tsx)、[`frontend/src/pages/`]((仅 main)frontend/src/pages/)、[`frontend/src/components/`]((仅 main)frontend/src/components/)、[`frontend/src/layouts/`]((仅 main)frontend/src/layouts/)
|
|
||||||
|
|
||||||
视觉可重设(见 [10-design-system.md](10-design-system.md));**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 路由表
|
|
||||||
|
|
||||||
> **本分支(`rebuild/gitea-ssr`)**:浏览器 UI 走 `routers/web` 模板 + 表单,**不依赖**论坛 JSON `/api`。下表「SSR」列表示是否已迁。
|
|
||||||
|
|
||||||
### 1.1 认证
|
|
||||||
|
|
||||||
| 路径 | 说明 | SSR |
|
|
||||||
|------|------|-----|
|
|
||||||
| `/login` | 登录 / 登出 | 已迁 |
|
|
||||||
| `/register` | 注册(图形验证码;邮件就绪时要邮箱验证码) | 已迁 |
|
|
||||||
| `/forgot-password` | 忘记密码 | 已迁 |
|
|
||||||
|
|
||||||
### 1.2 前台
|
|
||||||
|
|
||||||
| 路径 | 说明 | SSR |
|
|
||||||
|------|------|-----|
|
|
||||||
| `/` | Feed | 已迁 |
|
|
||||||
| `/board/:id` | 板块 Feed | 已迁 |
|
|
||||||
| `/boards` | 板块索引 | 已迁 |
|
|
||||||
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
|
||||||
| `/compose` | 发帖(类型字段 + Markdown 工具栏/预览/图片/门控) | 已迁 |
|
|
||||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
|
||||||
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
|
||||||
| `/user/:id` | 公开用户页 | 已迁 |
|
|
||||||
| `/favorites` | 收藏 | 已迁 |
|
|
||||||
| `/projects` | Gitea 码桶 | 后置 |
|
|
||||||
| `/links` | 友链 | 已迁 |
|
|
||||||
| `/messages` | 私信/通知会话列表 | 已迁 |
|
|
||||||
| `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 |
|
|
||||||
| `/page/:slug` | 站点单页 | 已迁 |
|
|
||||||
| `*` | 404 / pending | 已迁 |
|
|
||||||
|
|
||||||
### 1.3 后台(Admin SSR,表单 + CSRF,不挂管理 JSON `/api`)
|
|
||||||
|
|
||||||
| 路径 | 说明 | SSR |
|
|
||||||
|------|------|-----|
|
|
||||||
| `/admin` | 重定向 dashboard | 已迁 |
|
|
||||||
| `/admin/dashboard` | 概览计数 | 已迁 |
|
|
||||||
| `/admin/boards` | 板块 CRUD | 已迁 |
|
|
||||||
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
|
|
||||||
| `/admin/settings` | 品牌(含 Logo/Favicon/OG)+ 限流 + 内容限制 + 伪静态 + 敏感词 + SMTP + 备份 | 已迁 |
|
|
||||||
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
|
|
||||||
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
|
|
||||||
| `/admin/reports` | 举报处理 | 已迁 |
|
|
||||||
| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 |
|
|
||||||
| `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 |
|
|
||||||
| `/admin/media` | 媒体库分类浏览、删除、同步索引 | 已迁 |
|
|
||||||
| `/admin/trash` | 帖回收站 | 已迁 |
|
|
||||||
| `/admin/login` | 重定向前台登录 | 已迁 |
|
|
||||||
|
|
||||||
未迁(原 SPA):OIDC·Gitea·存储热切换 Admin 等。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 三栏布局(桌面)
|
|
||||||
|
|
||||||
```text
|
|
||||||
+------------------+---------------------------+------------------+
|
|
||||||
| Sidebar | Feed / 主内容 | RightPanel |
|
|
||||||
| - 全部/收藏/码桶 | - FeedHeader / SortBar | - 签到条(登录) |
|
|
||||||
| - 板块列表 | - VirtualPostList | - 热门帖 |
|
|
||||||
| - 站点页/友链 | - 或 PostDetail 等 | - aside_widgets |
|
|
||||||
| - 管理入口 | | 标签云/评论/ |
|
|
||||||
| | | 用户/友链 |
|
|
||||||
+------------------+---------------------------+------------------+
|
|
||||||
| Footer: ICP / 友链入口 / 站点页链接 |
|
|
||||||
+------------------------------------------------------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.1 左栏 Sidebar
|
|
||||||
|
|
||||||
- 全部帖子、我的收藏(登录)、开源码桶
|
|
||||||
- 板块列表(图标 + 色点 + 名称);空站引导「创建第一个板块」
|
|
||||||
- 站点区:友链入口(`nav_show_friend_links`)、`show_in_nav` 的站点页
|
|
||||||
- 管理员:管理后台入口
|
|
||||||
|
|
||||||
### 2.2 中栏
|
|
||||||
|
|
||||||
**Feed**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题;已迁 SSR GET)+ 列表项(标题、作者、板块徽章、回复数、置顶/精华)。虚拟滚动 / `feed_list_style` 未迁。
|
|
||||||
|
|
||||||
**帖子详情**:标题区操作(赞、藏、举报、编辑、管理操作)→ 待审/被拒横幅(作者/管理员)→ 特殊组件(投票卡 / 悬赏条 / 抽奖卡)→ 正文 `PostContent`(门控块 UI)→ 文章目录 → 作者卡片 → 修订入口 → 评论线程 + 评论框。
|
|
||||||
|
|
||||||
### 2.3 右栏 RightPanel
|
|
||||||
|
|
||||||
- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **已迁** SSR 右栏;流水/详情仍在 `/profile#wallet`
|
|
||||||
- 热门帖 — **已迁** SSR(固定块)
|
|
||||||
- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin `/admin/settings` 侧栏组件开关与排序;友链页 aside 勾选共用同一 JSON
|
|
||||||
- 桌面三列;≤900px 隐藏右栏
|
|
||||||
|
|
||||||
### 2.4 移动端
|
|
||||||
|
|
||||||
- 侧栏抽屉化;顶栏搜索/发帖/登录触手可及
|
|
||||||
- `PullToRefresh` 下拉刷新
|
|
||||||
- 触控友好列表行高
|
|
||||||
|
|
||||||
### 2.5 主题
|
|
||||||
|
|
||||||
- 浅色 / 暗色;默认 **跟随系统**(CSS `@media (prefers-color-scheme)`);`localStorage` 键 `j13-theme`(`system` | `light` | `dark`)
|
|
||||||
- 显式浅/深时设 `<html data-theme="light|dark">`;`system` 时去掉该属性,交给媒体查询
|
|
||||||
- 顶栏按钮循环切换;兼容旧 SPA 仅存 `light`/`dark` 的值
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 发帖页 Compose
|
|
||||||
|
|
||||||
组件:`ComposeHeader`、`ComposeContextBar`(帖类型)、`ComposeSpecialFields`、`ComposeDocument` / `ArticleEditor`。
|
|
||||||
|
|
||||||
### 3.1 帖类型切换
|
|
||||||
|
|
||||||
| 类型 | 附加 UI |
|
|
||||||
|------|---------|
|
|
||||||
| 讨论 | 无 |
|
|
||||||
| 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 |
|
|
||||||
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
|
|
||||||
| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 |
|
|
||||||
| 抽奖 | 中奖人数 1–20 — SSR compose |
|
|
||||||
|
|
||||||
### 3.2 编辑器能力(本分支:Markdown 渐进增强)
|
|
||||||
|
|
||||||
源对照:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx)(TipTap,**不迁**)
|
|
||||||
|
|
||||||
| 能力 | SSR 状态 |
|
|
||||||
|------|----------|
|
|
||||||
| 标题 h2–h6(`#` 映射为 h2) | Markdown 工具栏 + `ComposeBodyToHTML` |
|
|
||||||
| 粗体/斜体/删除线/行内代码 | 已迁 |
|
|
||||||
| 链接 | prompt 插入 |
|
|
||||||
| 围栏代码块 | 已迁(语言 class) |
|
|
||||||
| 列表 / 引用 | 已迁 |
|
|
||||||
| 图片上传 | `/compose/upload` |
|
|
||||||
| 登录/回复/积分可见门控 | compose 工具栏 |
|
|
||||||
| 预览 | `POST /compose/preview`(同消毒管线) |
|
|
||||||
| Tab 缩进 / 未保存离开 | `beforeunload` |
|
|
||||||
| 表格 / 图片组 / 表情贴纸 / TipTap 双模 | **未做**(后置) |
|
|
||||||
|
|
||||||
共用片段:`templates/shared/md_editor.tmpl`(发帖、改帖、评论、改评)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 帖子详情关键交互
|
|
||||||
|
|
||||||
| 模块 | 行为 |
|
|
||||||
|------|------|
|
|
||||||
| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
|
|
||||||
| 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR |
|
|
||||||
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
|
|
||||||
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` |
|
|
||||||
| 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` |
|
|
||||||
| 评论 | 楼层列表、`reply_to` 引用、嵌套树(`ThreadParentID` + 缩进,全局 `#floor`)、私密/赞/举报/编辑/删除、回复/@ 通知 — SSR |
|
|
||||||
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) |
|
|
||||||
| 图片 | Lightbox 查看 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 消息页
|
|
||||||
|
|
||||||
- 左:会话列表(系统会话单独)— SSR `/messages`;快捷链到回复/提及筛选
|
|
||||||
- 右:消息时间线;发送框 — SSR `/messages/with/:peerId`
|
|
||||||
- 顶:未读角标(全局导航也可显示)
|
|
||||||
- 通知筛选(kind)— 系统会话 `?kind=reply|mention|…`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 个人中心 / 公开主页
|
|
||||||
|
|
||||||
- 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile`
|
|
||||||
- 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱)
|
|
||||||
- 收藏列表 — SSR `/favorites`
|
|
||||||
- 积分钱包面板(余额、近 N 条流水、签到/抽奖表单 PRG)— SSR `/profile`;导航展示当前积分
|
|
||||||
- 徽章展示:用户主页 / 个人中心 — SSR(访问时 `EvaluateAuto`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 友链页
|
|
||||||
|
|
||||||
- 展示品牌友链 — SSR `/links`
|
|
||||||
- 登录申请表单:名称、URL、Logo(地址或上传)、是否上首页、回链页 — `POST /links/apply`
|
|
||||||
- 我的申请状态列表;待审可取消
|
|
||||||
- Admin:`/admin/friend-links` 品牌增删、申请通过/拒绝、nav/footer/回链检测开关
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 管理后台操作流(按页)
|
|
||||||
|
|
||||||
| 页 | 关键操作 |
|
|
||||||
|----|----------|
|
|
||||||
| Dashboard | 看计数与待办;点进对应列表 |
|
|
||||||
| Boards | 拖拽或数字排序;图标/色板选择;增删改 |
|
|
||||||
| Pages | 列表发布开关;编辑正文(HTML textarea);nav/footer 勾选 — SSR `/admin/pages` |
|
|
||||||
| Links | 品牌友链增删;申请通过/拒绝/回链复检;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(aside 与 Settings 侧栏组件共用 `aside_widgets`) |
|
|
||||||
| Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` |
|
|
||||||
| Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 |
|
|
||||||
| Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
|
|
||||||
| Users | 搜索;禁言;认证;调积分;设等级 — SSR `/admin/users`(授徽章在 `/admin/badges`) |
|
|
||||||
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
|
|
||||||
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` |
|
|
||||||
| Media | 分类浏览;批量删 |
|
|
||||||
| Settings | 品牌(含 Logo/Favicon/OG)、限流、内容限制、伪静态、侧栏、敏感词、邮件、SQLite 备份 — SSR `/admin/settings`(OIDC/Gitea/存储热切换未迁) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 全局 UX 细节
|
|
||||||
|
|
||||||
- Toast(sonner)反馈成功/失败
|
|
||||||
- 路由级 ErrorBoundary / AppRouteError
|
|
||||||
- 懒加载页面 + retry(`lazyWithRetry`)
|
|
||||||
- 新标签打开帖子 / 正文外链:受 `open_posts_in_new_tab`、`open_content_links_in_new_tab` 控制
|
|
||||||
- 文档标题:`站点名 - 标语`;详情页应换成帖标题(SSR 时首屏即正确)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. SSR 重构提示(交互层)
|
|
||||||
|
|
||||||
当前 SPA 在客户端挂载后才拉 `/api/posts/:id`。新站应:
|
|
||||||
|
|
||||||
1. 服务端渲染列表项与帖文 HTML(已按门控红action)
|
|
||||||
2. 水合后接上赞/评/解锁等交互
|
|
||||||
3. 管理后台仍可为 CSR,但前台公开页优先 SSR
|
|
||||||
|
|
||||||
勿再维护「爬虫一套 HTML、用户一套空壳」双轨,除非过渡期兼容。
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
# 07 · 配置、运维与 SEO
|
|
||||||
|
|
||||||
> **读者**:部署与运维、以及实现配置层的 AI
|
|
||||||
> **前置**:[README.md](README.md)
|
|
||||||
> **源码**:[`config/`](../../config/)、[`README.md`](../../README.md)、[`routers/api/seo.go`](../../routers/api/seo.go)、[`routers/install/`](../../routers/install/)
|
|
||||||
|
|
||||||
运维形态可改;下列描述**本分支**行为。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 首次安装(Gitea 式)
|
|
||||||
|
|
||||||
| 项 | 说明 |
|
|
||||||
|----|------|
|
|
||||||
| 锁文件 | `data/install.lock` |
|
|
||||||
| 向导 | `GET/POST /install`(`templates/install.tmpl`) |
|
|
||||||
| 未锁定 | 除 `/install`、`/ssr-assets/*`、`/health` 外重定向到向导 |
|
|
||||||
| 管理员 | 仅安装向导创建;不再「首个注册用户变管理员」 |
|
|
||||||
| 向导内容 | 站点名 + 管理员账号(数据库已在进程启动时连上) |
|
|
||||||
| 旧数据 | 启动时若已有用户且无锁,自动补写锁 |
|
|
||||||
|
|
||||||
**无 `app.ini`。** 引导仅 CLI / Env。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 配置分层与重启边界
|
|
||||||
|
|
||||||
| 层 | 存什么 | 变更方式 | 需重启 |
|
|
||||||
|----|--------|----------|--------|
|
|
||||||
| **Bootstrap** | `DATA`、`HTTP_PORT`/`ADDR`、`DB_TYPE` + DSN/连接参数、工作目录 | CLI / Env | **是** |
|
|
||||||
| **密钥文件** | App HMAC(`data/.jwt_secret`,文件名历史遗留);OIDC RSA **仅启用时**写入 settings(可选遗留文件迁移) | 自动生成 | HMAC 换钥需重启 |
|
|
||||||
| **站点运行时** | 品牌、邮件、OIDC 开关、限流、敏感词、存储、伪静态… | DB `forum_settings` | **否**(热更) |
|
|
||||||
|
|
||||||
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
|
|
||||||
|
|
||||||
### 进程引导
|
|
||||||
|
|
||||||
| CLI | 环境变量 | 默认 | 说明 |
|
|
||||||
|-----|----------|------|------|
|
|
||||||
| `--port` | `JIANG13_HTTP_PORT` | 3000 | 监听端口 |
|
|
||||||
| `--http-addr` | `JIANG13_HTTP_ADDR` | (空=全接口) | 监听地址 |
|
|
||||||
| `--data` | `JIANG13_DATA` | `data` | 数据目录 |
|
|
||||||
| `--work-path` | `JIANG13_WORK_PATH` | 可执行文件目录 | 工作目录 |
|
|
||||||
| `--db-type` | `JIANG13_DB_TYPE` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
|
|
||||||
| `--db-dsn` | `JIANG13_DB_DSN` | (sqlite 默认 `{DATA}/jiang13.db`) | 完整 DSN,优先 |
|
|
||||||
| `--db-host` 等 | `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE` | | DSN 为空时拼接(pg/mysql) |
|
|
||||||
| `--service` | | | install/uninstall/start/stop/restart/status |
|
|
||||||
|
|
||||||
`{DATA}/.jwt_secret`:**App HMAC 密钥**(CSRF 双提交等),启动时自动生成。**不是**浏览器登录 JWT。`--config` / `--jwt-secret` / `JIANG13_JWT_SECRET` 已废弃。
|
|
||||||
|
|
||||||
浏览器登录:DB `sessions` + Cookie `jiang13_session`。OIDC 对外 token 仍为 JWT,私钥在 `forum_settings.oidc_rsa_private_pem`(启用时懒加载;未启用不生成 `.oidc_rsa.pem`)。
|
|
||||||
|
|
||||||
业务配置(邮件、OIDC、存储、品牌、敏感词等)在 **DB `forum_settings`**,管理后台热更新。
|
|
||||||
|
|
||||||
### 数据库 Env 示例
|
|
||||||
|
|
||||||
**SQLite(默认):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
JIANG13_DATA=/data
|
|
||||||
# 可不设 DB_*;库文件 = $JIANG13_DATA/jiang13.db
|
|
||||||
```
|
|
||||||
|
|
||||||
**PostgreSQL:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
JIANG13_DB_TYPE=postgres
|
|
||||||
JIANG13_DB_DSN="postgres://forum:secret@db:5432/jiang13?sslmode=disable"
|
|
||||||
# 或拆分:
|
|
||||||
# JIANG13_DB_HOST=db:5432
|
|
||||||
# JIANG13_DB_USER=forum
|
|
||||||
# JIANG13_DB_PASS=secret
|
|
||||||
# JIANG13_DB_NAME=jiang13
|
|
||||||
# JIANG13_DB_SSLMODE=disable
|
|
||||||
```
|
|
||||||
|
|
||||||
**MySQL / MariaDB:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
JIANG13_DB_TYPE=mysql
|
|
||||||
JIANG13_DB_DSN="forum:secret@tcp(db:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4"
|
|
||||||
```
|
|
||||||
|
|
||||||
连库失败时进程**退出并打印 Env 提示**,不会静默回落 sqlite。
|
|
||||||
|
|
||||||
### Docker Compose 多库示意
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
jiang13:
|
|
||||||
image: hangzhang714128/jiang13-forum:latest
|
|
||||||
environment:
|
|
||||||
JIANG13_DB_TYPE: postgres
|
|
||||||
JIANG13_DB_DSN: postgres://forum:secret@postgres:5432/jiang13?sslmode=disable
|
|
||||||
volumes:
|
|
||||||
- jiang13-data:/data
|
|
||||||
depends_on: [postgres]
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: forum
|
|
||||||
POSTGRES_PASSWORD: secret
|
|
||||||
POSTGRES_DB: jiang13
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 数据目录结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
data/
|
|
||||||
├── install.lock # 安装完成锁(与 DB 引擎无关)
|
|
||||||
├── jiang13.db # 仅 SQLite 时的主库文件(含 sessions / forum_settings)
|
|
||||||
├── jiang13.log # 运行日志
|
|
||||||
├── filter_words.txt # 遗留:启动时可导入 settings;新源以 DB 为准
|
|
||||||
├── .jwt_secret # App HMAC(CSRF 等;勿提交;非登录 JWT)
|
|
||||||
├── .oidc_rsa.pem # 遗留:仅启用 OIDC 且从文件迁移时可能存在;新站优先 DB
|
|
||||||
├── uploads/
|
|
||||||
│ ├── avatars/
|
|
||||||
│ ├── posts/
|
|
||||||
│ └── site/
|
|
||||||
└── jiang13_backup_*.db # SQLite 一键备份(其它引擎请用库方工具)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 部署方式(现网)
|
|
||||||
|
|
||||||
| 方式 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 单二进制 | `build.bat` / `make build` → `dist/jiang13(.exe)` |
|
|
||||||
| Docker | 镜像挂载 `/data`;健康检查 `GET /health` |
|
|
||||||
| Compose | `docker compose up -d --build` |
|
|
||||||
| systemd / Windows Service | `--service install` 后启停 |
|
|
||||||
|
|
||||||
构建约定见 [`.cursor/rules/build-scripts.mdc`](../../.cursor/rules/build-scripts.mdc):Windows 用 `build.bat`,勿直接 `make` / `.\build.ps1`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 存储后端
|
|
||||||
|
|
||||||
| type | 行为 |
|
|
||||||
|------|------|
|
|
||||||
| `local` | 文件落在 `data/uploads`;URL 通常 `/uploads/...` |
|
|
||||||
| `s3` | S3 兼容;endpoint、bucket、密钥、public_base_url、prefix、force_path_style |
|
|
||||||
|
|
||||||
`image_delivery`:`webp`(默认,经 `/media/thumb`)或 `original`。
|
|
||||||
|
|
||||||
媒体索引表 `media` 供后台列表;启动时可后台 SyncMediaIndex。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. SEO / 社交分享字段集合
|
|
||||||
|
|
||||||
新站应用真 SSR,但 **meta 字段应对齐**:
|
|
||||||
|
|
||||||
| 字段 | 来源 |
|
|
||||||
|------|------|
|
|
||||||
| `<title>` | 站点 DocumentTitle 或帖标题 |
|
|
||||||
| meta description | 站点简介优先,否则标语;帖文则摘要 |
|
|
||||||
| meta keywords | 站点 keywords |
|
|
||||||
| canonical | 绝对 URL |
|
|
||||||
| og:* / twitter:* | |
|
|
||||||
| JSON-LD | 结构化数据 |
|
|
||||||
| robots | 个别页可 noindex |
|
|
||||||
|
|
||||||
| 机制 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| robots.txt / sitemap.xml | 动态生成 |
|
|
||||||
|
|
||||||
重构验收:普通浏览器「查看网页源代码」应能看到帖文正文。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 伪静态 Permalink
|
|
||||||
|
|
||||||
设置:`permalink_enabled`、`permalink_ext`(默认 `html`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 安全相关运维注意
|
|
||||||
|
|
||||||
| 项 | 说明 |
|
|
||||||
|----|------|
|
|
||||||
| HMAC / OIDC | 勿提交 `.jwt_secret`;OIDC PEM 优先在 DB;遗留 `.oidc_rsa.pem` 亦勿提交 |
|
|
||||||
| Cookie | `jiang13_session` HttpOnly + SameSite=Lax;生产 HTTPS 下 Secure |
|
|
||||||
| 上传 | 类型/大小限制 |
|
|
||||||
| 敏感词 | `forum_settings.filter_words`,后台可改热更 |
|
|
||||||
| 备份 | SQLite 文件备份含哈希与私信;PG/MySQL 用官方工具 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 健康检查
|
|
||||||
|
|
||||||
`GET /health` → JSON `status`。DB 不可用时非 200。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 从旧站迁数据建议
|
|
||||||
|
|
||||||
1. SQLite:复制 `jiang13.db`;或 dump 到 PG/MySQL 并映射表
|
|
||||||
2. 复制 `uploads/`、`.jwt_secret`(HMAC 兼容);敏感词若仍在文件可启动导入
|
|
||||||
3. 迁移 `forum_settings` 或后台重配(含 `filter_words`)
|
|
||||||
4. OIDC:`oauth_clients` + settings 中 PEM(或遗留 `.oidc_rsa.pem` 一次迁移)
|
|
||||||
5. 旧站 JWT Cookie 无效;用户需重新登录(opaque session)
|
|
||||||
|
|
||||||
表语义以 [03-data-model.md](03-data-model.md) 为准。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 文档包索引
|
|
||||||
|
|
||||||
返回 [README.md](README.md) 阅读顺序;功能验收用 [02-features.md](02-features.md);规则用 [05-business-rules.md](05-business-rules.md)。
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 08 · Gitea 式 SSR 架构(开发约定)
|
|
||||||
|
|
||||||
> **读者**:在本仓库 `rebuild/gitea-ssr` 分支上开发的 AI / 开发者
|
|
||||||
> **前置**:[README.md](README.md)
|
|
||||||
> **对照上游**:[go-gitea/gitea](https://github.com/go-gitea/gitea)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目标栈(已确认)
|
|
||||||
|
|
||||||
| 层 | 选择 |
|
|
||||||
|----|------|
|
|
||||||
| 公开页渲染 | Go `html/template` **真 SSR** |
|
|
||||||
| 浏览器写操作 | `routers/web` HTML 表单 POST + CSRF + PRG |
|
|
||||||
| JSON `/api` | **仅机器客户端**(OIDC、health、`/api/captcha`、`/api/register` 等);不服务已迁页面 UI |
|
|
||||||
| 渐进增强 | `web_src/` → `public/assets/` → `/ssr-assets/` |
|
|
||||||
| 发布 | 单二进制 + `go:embed` |
|
|
||||||
| 业务语义 | `01`–`07`;冲突时改代码并回写规格 |
|
|
||||||
| 不做 | React SPA、爬虫/用户双轨 HTML、为旧 SPA 保留死代码 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 分支
|
|
||||||
|
|
||||||
| 分支 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `main` | React SPA 对照(git checkout / worktree) |
|
|
||||||
| `rebuild/gitea-ssr` | 唯一重建分支 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
```text
|
|
||||||
routers/
|
|
||||||
setup.go
|
|
||||||
install/ # INSTALL_LOCK 未置位时的安装向导
|
|
||||||
web/ # HTML + 表单
|
|
||||||
api/ # 精简机器接口(health / OIDC / robots / sitemap / media)
|
|
||||||
modules/
|
|
||||||
webctx/ # Doer / CSRF / Flash / HTML / Redirect
|
|
||||||
auth/
|
|
||||||
webrender/
|
|
||||||
seo/
|
|
||||||
templates/
|
|
||||||
install.tmpl
|
|
||||||
post-install.tmpl
|
|
||||||
base/ home/ post/ shared/ status/ auth/ admin/ profile/ user/ favorites/ messages/
|
|
||||||
services/
|
|
||||||
web_src/ → public/assets/
|
|
||||||
```
|
|
||||||
|
|
||||||
**已删除:** `frontend/`、`embed_static/`、`ServePublicSPA`、爬虫双轨 HTML、首注册变管理员 bootstrap、`app.ini`、浏览器 JWT Cookie 登录。
|
|
||||||
|
|
||||||
**配置:** 引导 = CLI/Env(含 `DB_*`);运行时 = `forum_settings` 热更;`.jwt_secret` = App HMAC;OIDC PEM 启用时进 settings。详见 [07-config-ops.md](07-config-ops.md)。
|
|
||||||
|
|
||||||
**会话:** Cookie `jiang13_session` → 表 `sessions`;可吊销。
|
|
||||||
|
|
||||||
**数据库:** GORM 方言 `sqlite`(默认)| `postgres` | `mysql`;连库失败不回落。
|
|
||||||
|
|
||||||
**后置:** Gitea 仓库同步(不启后台任务)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 安装
|
|
||||||
|
|
||||||
- 锁文件:`data/install.lock`
|
|
||||||
- 未安装:除 `/install`、`/ssr-assets/*`、`/health` 外一律重定向到安装向导
|
|
||||||
- 管理员仅由安装向导创建;已有用户数据启动时会自动补写锁
|
|
||||||
- 向导不选库:库由启动 Env 决定
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 渲染与交互原则
|
|
||||||
|
|
||||||
1. 已迁路径「查看源代码」须含内容 DOM。
|
|
||||||
2. UI 读写不依赖 `/api` 灌首屏或写操作(`/compose/upload` 为同站表单辅助 JSON,带 CSRF)。
|
|
||||||
3. 模板默认转义;`safeHTML` 仅用于消毒 + 门控后正文/评论 HTML。
|
|
||||||
4. 未迁路径用 `status/pending.tmpl` 或 404,不维护 SPA 占位语义。
|
|
||||||
5. 会话 Cookie:`jiang13_session`;`SameSite=Lax`;HTTPS 下 `Secure`。
|
|
||||||
|
|
||||||
### 已迁路径(摘要)
|
|
||||||
|
|
||||||
公开写:`/install`、`/login`、`/logout`、`/register`、`/forgot-password`(发码/重置)、`/compose`(含门控插入)、`/post/:id/edit`、帖详情评论(reply_to/赞/私密)/赞/藏、评论赞、积分解锁 `POST /post/:id/unlock`。
|
|
||||||
|
|
||||||
个人闭环:`/profile`(昵称/签名/密码/头像、积分钱包/签到/抽奖)、`/user/:id`、`/favorites`。
|
|
||||||
`POST /profile/checkin`、`POST /profile/lottery`(CSRF + PRG)。
|
|
||||||
|
|
||||||
私信:`/messages`、`/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。
|
|
||||||
|
|
||||||
公开浏览:`/boards` 板块索引(链到 `/board/:id`)。
|
|
||||||
|
|
||||||
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。
|
|
||||||
|
|
||||||
右栏:首页与帖详情三栏;登录用户签到/抽奖条(PRG 可回跳当前 Feed/帖页);固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链);Admin `/admin/settings` 可开关排序,友链页 aside 勾选与其共用同一配置。
|
|
||||||
|
|
||||||
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、nav/footer/aside 入口开关)。
|
|
||||||
|
|
||||||
站点单页:公开 `/page/:slug`;nav/footer 按 `show_in_nav` / `show_in_footer` 注入;Admin `/admin/pages`(CRUD、发布、排序、展示开关)。
|
|
||||||
|
|
||||||
举报:帖/评 SSR 表单;Admin `/admin/reports`(dismiss / resolve / reject_post / reject_comment)。
|
|
||||||
|
|
||||||
运营标记:帖详情 Admin 条(全局/版内置顶、精华、禁编、锁评、软删);回收站 `/admin/trash`(恢复/彻底删除)。
|
|
||||||
|
|
||||||
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/侧栏组件/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`、`/admin/trash`。
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# 09 · SSR 重构进度与路线图
|
|
||||||
|
|
||||||
> **读者**:产品方 / 实现 AI
|
|
||||||
> **分支**:`rebuild/gitea-ssr`(`main` = SPA 对照)
|
|
||||||
> **验收清单**: [02-features.md](02-features.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 当前指针
|
|
||||||
|
|
||||||
| 项 | 值 |
|
|
||||||
|----|-----|
|
|
||||||
| **上一刀** | 首页对齐 main SPA 视觉与交互(草绿壳 + Feed v2) |
|
|
||||||
| **下一刀** | 需点名(移动抽屉 / 帖详情抛光 / OIDC Admin…) |
|
|
||||||
| **工作区** | 应干净 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一句话状态
|
|
||||||
|
|
||||||
核心论坛、Admin、Limits/伪静态、嵌套评论、Markdown 编辑器、主题、清青产品壳与资源版本号、首页门面密度已齐;本分支 `routers/api` 仅保留机器入口。余量多为后置与体验打磨。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 已完成(摘)
|
|
||||||
|
|
||||||
| 域 | 状态 |
|
|
||||||
|----|------|
|
|
||||||
| 五种帖 + Admin P1 + §H | 已迁 |
|
|
||||||
| Limits / 伪静态 / 品牌图 | 已迁 |
|
|
||||||
| 评论嵌套树 UI | 已迁 |
|
|
||||||
| Markdown 工具栏 + preview | 已迁 |
|
|
||||||
| 浅色 / 暗色 / 跟随系统 | 已迁(CSS 媒体查询,无 head 防闪脚本) |
|
|
||||||
| 未挂载论坛 JSON API | 已删(对照 `main`) |
|
|
||||||
| 公开页设计系统 | 已迁(首页对齐 main SPA 草绿壳;[10-design-system.md](10-design-system.md)) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 未完成
|
|
||||||
|
|
||||||
- P3:游客评论、修订 diff、响应式侧栏抽屉
|
|
||||||
- 编辑器后置:TipTap、表格、图片组、表情贴纸
|
|
||||||
- 后置:OIDC Admin UI、S3 热切换、Gitea `/projects`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 如何查阅
|
|
||||||
|
|
||||||
| 问题 | 看哪里 |
|
|
||||||
|------|--------|
|
|
||||||
| 某功能做没做? | [02-features.md](02-features.md) |
|
|
||||||
| 现在该干什么? | 本文「当前指针」 |
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# 10 · 公开页设计系统
|
|
||||||
|
|
||||||
> **读者**:实现公开页视觉 / CSS 的 AI
|
|
||||||
> **前置**:[01-product.md](01-product.md)、[06-pages-ux.md](06-pages-ux.md)
|
|
||||||
> **实现**:[`web_src/css/site.css`](../../web_src/css/site.css)、[`templates/base/`](../../templates/base/)
|
|
||||||
> **品牌**:默认「姜十三论坛」;首页视觉对齐 `main` SPA 草绿产品壳
|
|
||||||
|
|
||||||
视觉可重设;**信息架构与关键操作流以 06 为准**。本文件只约定公开页壳层与内容表面的视觉语言。
|
|
||||||
|
|
||||||
静态资源须带构建版本号:`/ssr-assets/site.css?v={{.AssetVersion}}`(长缓存 + query 穿透,对齐 Gitea)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 气质
|
|
||||||
|
|
||||||
| 项 | 约定 |
|
|
||||||
|----|------|
|
|
||||||
| 关键词 | 对齐 `main` SPA:草绿 `#18a058`、清新产品壳 |
|
|
||||||
| 密度 | SPA 同款顶栏工具条 + Feed `post-row--v2` |
|
|
||||||
| 反模式 | 紫渐变、暖奶油底、大圆角卡片墙、全幅 hero、Inter/Roboto |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Token
|
|
||||||
|
|
||||||
### 2.1 色(CSS 变量 `--j13-*`)
|
|
||||||
|
|
||||||
| Token | 浅色 | 暗色 | 用途 |
|
|
||||||
|-------|------|------|------|
|
|
||||||
| `--j13-bg` / page | `#f5f7fa` | `#141416` | 页底 |
|
|
||||||
| `--j13-surface` | `#ffffff` | `#1f1f23` | 顶栏、主栏 |
|
|
||||||
| `--j13-accent` / green | `#18a058` | `#23c36b` | 主色(对齐 main SPA) |
|
|
||||||
| `--j13-accent-ink` | `#138f4c` | `#6ee7a0` | 强强调 |
|
|
||||||
| `--j13-soft` / green-bg | `#edfbf3` / `rgba(24,160,88,.08)` | 绿 soft | active / hover |
|
|
||||||
| `--j13-border` | `#e8edf2` | `#2e2e32` | 分隔 |
|
|
||||||
|
|
||||||
语义色(ok / warn / err / pin / feat)随主题成对定义;feat 用青 soft,不用草绿。
|
|
||||||
|
|
||||||
### 2.2 字号与字重
|
|
||||||
|
|
||||||
| 阶 | 尺寸 | 用途 |
|
|
||||||
|----|------|------|
|
|
||||||
| `xs` | 0.72–0.75rem | 口号、辅助 |
|
|
||||||
| `sm` | 0.8125rem | 元信息、侧栏、顶栏次级 |
|
|
||||||
| `md` | 0.9375rem | 正文默认 |
|
|
||||||
| `lg` | 1.15rem | Feed 标题 |
|
|
||||||
| `xl` | 1.35rem | 帖标题 |
|
|
||||||
| `brand` | 1.3rem / 700 | 顶栏站点名 |
|
|
||||||
|
|
||||||
字体栈:**中文优先** PingFang SC / Hiragino / Microsoft YaHei / Noto Sans SC(与 SPA 一致,确保本机可见);代码 Cascadia / Consolas。不引入 Inter/Roboto,不依赖本机 IBM Plex。
|
|
||||||
|
|
||||||
### 2.3 间距与圆角
|
|
||||||
|
|
||||||
- 栅格:4px 基准;总宽 `--j13-frame: 1320px`
|
|
||||||
- 圆角:`--j13-radius: 8px`;搜索 pill / 排序分段可用满圆角
|
|
||||||
- 阴影:sticky 顶栏微阴影
|
|
||||||
|
|
||||||
### 2.4 动效
|
|
||||||
|
|
||||||
| 处 | 行为 |
|
|
||||||
|----|------|
|
|
||||||
| 主题 | `color` / `background` / `border-color` `150ms ease` |
|
|
||||||
| Feed 行 | hover 背景 |
|
|
||||||
| 顶栏 | sticky 阴影;搜索 focus soft ring |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 布局
|
|
||||||
|
|
||||||
| 断点 | 行为 |
|
|
||||||
|------|------|
|
|
||||||
| ≥901px | 三栏:左 210px / 中 1fr / 右 280px;总宽 ~1400px(对齐 SPA) |
|
|
||||||
| ≤900px | 隐藏右栏;顶栏搜索折行(抽屉化 **下一刀**) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 组件约定
|
|
||||||
|
|
||||||
| 组件 | 规则 |
|
|
||||||
|------|------|
|
|
||||||
| 顶栏 | SPA:品牌 \| 搜索胶囊(筛选钮 + Ctrl+K)\| 发帖绿钮 \| 主题/私信/头像菜单 |
|
|
||||||
| 左栏 | 浏览 / 板块;active 绿底;板块色槽 + 帖数 |
|
|
||||||
| Feed 行 | `post-row--v2`:头像 · 徽章+标题 · 摘要(依 feed_list_style)· 元信息 · 回复数 |
|
|
||||||
| 高级搜索 | 顶栏筛选打开;有条件时 details 展开 |
|
|
||||||
| 排序 | SPA `feed-sort-tab` 绿 active |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 范围边界
|
|
||||||
|
|
||||||
- **覆盖**:公开页壳层(含首页门面密度)、资源 `?v=`、浅/暗色
|
|
||||||
- **不覆盖**:Admin 深重绘、移动抽屉、虚拟滚动、TipTap、改站名
|
|
||||||
|
|
||||||
交叉:[06-pages-ux.md](06-pages-ux.md) · [09-ssr-progress.md](09-ssr-progress.md)
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# 姜十三论坛 · 重构规格文档包
|
|
||||||
|
|
||||||
> **读者**:准备用新栈(建议真 SSR)重写站点的 AI / 开发者
|
|
||||||
> **事实来源**:本仓库现有代码;规格描述「产品必须保留什么」,不是「必须继续用 Go + React SPA」
|
|
||||||
> **交叉引用**:[01-product](01-product.md) · [02-features](02-features.md) · [03-data-model](03-data-model.md) · [04-api](04-api.md) · [05-business-rules](05-business-rules.md) · [06-pages-ux](06-pages-ux.md) · [07-config-ops](07-config-ops.md) · [08-gitea-ssr-architecture](08-gitea-ssr-architecture.md) · [09-ssr-progress](09-ssr-progress.md) · [10-design-system](10-design-system.md)
|
|
||||||
|
|
||||||
> **实现栈(重构分支)**:Gitea 式 Go 模板 SSR;开发分支 `rebuild/gitea-ssr`;`main` 保留 React SPA 对照。详见 [08](08-gitea-ssr-architecture.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阅读顺序(请按序投喂)
|
|
||||||
|
|
||||||
| 顺序 | 文件 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| 1 | 本文 `README.md` | 架构痛点、重构约束、术语 |
|
|
||||||
| 2 | [01-product.md](01-product.md) | 产品定位、角色、模块地图 |
|
|
||||||
| 3 | [02-features.md](02-features.md) | 验收级功能清单(可打勾) |
|
|
||||||
| 4 | [03-data-model.md](03-data-model.md) | 表结构、枚举、设置键、等级徽章 |
|
|
||||||
| 5 | [04-api.md](04-api.md) | HTTP 合约(路径 / 鉴权 / 请求响应) |
|
|
||||||
| 6 | [05-business-rules.md](05-business-rules.md) | 状态机与数值规则 |
|
|
||||||
| 7 | [06-pages-ux.md](06-pages-ux.md) | 路由、布局、编辑器、后台流程 |
|
|
||||||
| 8 | [07-config-ops.md](07-config-ops.md) | 配置、数据目录、部署、SEO 字段 |
|
|
||||||
| 9 | [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) | Gitea 式 SSR 分支与目录约定 |
|
|
||||||
| 10 | [09-ssr-progress.md](09-ssr-progress.md) | **进度与刀序**(「现在做到哪 / 下一刀」) |
|
|
||||||
| 11 | [10-design-system.md](10-design-system.md) | 公开页视觉 token / 组件(壳层重绘) |
|
|
||||||
|
|
||||||
单次 context 不够时:先投喂 `README` + `01` + `02`;跟进度看 `09`;实现某模块时再追加对应 `03`–`06` 章节。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 当前产品是什么
|
|
||||||
|
|
||||||
**姜十三论坛(Jiang13 Forum)** 面向小圈子 / 团队 / 同好社群的轻量论坛。
|
|
||||||
|
|
||||||
当前实现技术栈(**可抛弃,仅作对照**):
|
|
||||||
|
|
||||||
| 层 | 技术 |
|
|
||||||
|----|------|
|
|
||||||
| 后端 | Go · Gin · GORM · SQLite |
|
|
||||||
| 前端 | React 18 SPA · TipTap · Tailwind · TanStack Virtual |
|
|
||||||
| 发布 | Vite 构建 → `go:embed` 打进单二进制 |
|
|
||||||
| 认证 | bcrypt + DB opaque session Cookie(`jiang13_session`) |
|
|
||||||
|
|
||||||
演示站:https://bbs.iioio.com/
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 为何要重构:架构痛点(必须打破)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
browser[Browser]
|
|
||||||
spa[ReactSPA]
|
|
||||||
gin[GinAPI]
|
|
||||||
sqlite[SQLite]
|
|
||||||
bot[BotHTML]
|
|
||||||
browser -->|"用户"| spa
|
|
||||||
spa -->|"JSON /api"| gin
|
|
||||||
gin --> sqlite
|
|
||||||
browser -->|"爬虫 UA"| bot
|
|
||||||
bot --> gin
|
|
||||||
```
|
|
||||||
|
|
||||||
| 痛点 | 现状 | 对用户的影响 |
|
|
||||||
|------|------|----------------|
|
|
||||||
| 非真 SSR | 生产入口(`main` 的 `embed_static`)只注入 title / branding / Open Graph,**不渲染帖文 DOM** | 刷新先出壳再灌数据,体验不如 SSR |
|
|
||||||
| 爬虫双轨 | [`routers/api/seo_bot.go`](../../routers/api/seo_bot.go) 对爬虫返回独立 HTML | 用户与爬虫看到的不是同一套渲染路径 |
|
|
||||||
| 无正式 migration | Schema 靠 GORM `AutoMigrate`([`models/db.go`](../../models/db.go)) | 升级靠「加字段」,难做破坏性迁移与审计 |
|
|
||||||
| Cookie JWT(旧) | 浏览器登录曾用 JWT Cookie | **本分支已改为** DB `sessions` + opaque Cookie `jiang13_session`;`.jwt_secret` 仅 CSRF/HMAC |
|
|
||||||
|
|
||||||
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(SSR)HTML;SEO meta 与正文同源。技术选型自定(Next.js / Nuxt / Remix / 其它均可)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 重构时必须保留 vs 可以改
|
|
||||||
|
|
||||||
### 必须保留(产品语义)
|
|
||||||
|
|
||||||
- [02-features.md](02-features.md) 中列出的功能能力
|
|
||||||
- [03-data-model.md](03-data-model.md) 中的实体关系与枚举含义(表名可改,语义对齐)
|
|
||||||
- [05-business-rules.md](05-business-rules.md) 中的数值与状态机(积分、审核、门控、悬赏分成等)
|
|
||||||
- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;**管理员仅由 `/install` 创建**(不再首注册变管理员)
|
|
||||||
|
|
||||||
### 建议兼容(降低迁移成本)
|
|
||||||
|
|
||||||
- [04-api.md](04-api.md) 的 JSON 字段命名与路径形状(可做版本前缀,但旧字段名便于对照)
|
|
||||||
- Cookie 名 `jiang13_session`(opaque session id;重建分支不做 `jiang13_token` 双读)
|
|
||||||
- 数据目录语义:`jiang13.db`、`uploads/`;敏感词在 `forum_settings.filter_words`(旧 `filter_words.txt` 可导入)
|
|
||||||
|
|
||||||
### 可以彻底改
|
|
||||||
|
|
||||||
- 语言与框架(不必再 Go + React SPA)
|
|
||||||
- 单二进制 / `go:embed`(可改为前后端分离部署)
|
|
||||||
- SQLite(可换 PostgreSQL 等;规格不强制)
|
|
||||||
- UI 视觉(布局信息密度见 [06-pages-ux.md](06-pages-ux.md),视觉可重设)
|
|
||||||
- 爬虫专用 HTML 双轨(用真 SSR 取代)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 术语表(首次出现)
|
|
||||||
|
|
||||||
| 术语 | 中文 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| SSR | 服务端渲染 | 首屏 HTML 含正文,非纯客户端壳 |
|
|
||||||
| SPA | 单页应用 | 当前前台实现形态 |
|
|
||||||
| OIDC | 开放身份连接 | 本站可作 Provider,供 Gitea 等 SSO |
|
|
||||||
| JWT | JSON Web Token | OIDC 对外 `id_token`/`access_token` 仍用;**浏览器登录不用 JWT** |
|
|
||||||
| Opaque session | 不透明会话 | Cookie 只存随机 id,服务端 `sessions` 表可吊销 |
|
|
||||||
| Feed | 信息流 | 首页 / 板块帖列表 |
|
|
||||||
| 门控 | Content gate | 登录可见 / 回复可见 / 积分可见区块 |
|
|
||||||
| 伪静态 | Permalink | 如 `/post/123.html` 的可选后缀 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 源码速查(核对规格时)
|
|
||||||
|
|
||||||
| 主题 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| 路由总装 | [`routers/setup.go`](../../routers/setup.go) |
|
|
||||||
| GORM 模型 | [`models/models.go`](../../models/models.go) |
|
|
||||||
| AutoMigrate | [`models/db.go`](../../models/db.go) |
|
|
||||||
| 论坛设置键 | [`services/settings.go`](../../services/settings.go) |
|
|
||||||
| SSR 页面路由 | [`routers/web/`](../../routers/web/) |
|
|
||||||
| JSON API | [`routers/api/`](../../routers/api/) |
|
|
||||||
| 前端 API / 页面(对照) | 仅 `main`:`frontend/src/api/`、`frontend/src/App.tsx` |
|
|
||||||
| 产品介绍 | [`docs/introduction.md`](../introduction.md)、[`README.md`](../../README.md) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文档包完成标准
|
|
||||||
|
|
||||||
另一 AI 仅阅读本目录、**不打开业务源码**,应能:
|
|
||||||
|
|
||||||
1. 列出全部用户可见功能与后台能力
|
|
||||||
2. 画出核心表 ER 并理解枚举
|
|
||||||
3. 实现或 mock 与现网兼容的 API 形状
|
|
||||||
4. 复现审核 / 积分 / 门控 / 特殊帖规则
|
|
||||||
5. 搭出等价的页面信息架构与关键交互
|
|
||||||
|
|
||||||
若规格与代码冲突:**以代码为准**,并应回写修正本目录文档。
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Admin 徽章管理(SSR)— 实现计划
|
|
||||||
|
|
||||||
> 状态:已完成(SSR `/admin/badges`)
|
|
||||||
> 分支:`rebuild/gitea-ssr`
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
| 能力 | 服务 | 路由 |
|
|
||||||
|------|------|------|
|
|
||||||
| 列表(含停用) | `BadgeService.ListDefs(true)` | `GET /admin/badges` |
|
|
||||||
| 创建/更新定义 | `UpsertDef` | `POST /admin/badges`、`POST /admin/badges/:id` |
|
|
||||||
| 删除定义 | `DeleteDef`(清 `user_badges`) | `POST /admin/badges/:id/delete` |
|
|
||||||
| 颁发限定 | `AwardLimited` | `POST /admin/badges/award` |
|
|
||||||
| 收回 | `Revoke` | `POST /admin/badges/revoke` |
|
|
||||||
|
|
||||||
## 不做
|
|
||||||
|
|
||||||
自动徽章规则引擎调度 UI(P2,服务层 `EvaluateAuto` 仍可被触发)、等级设定。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
`build.bat`;冒烟:创建限定徽章 → 颁发给用户 → 收回 → 删除。
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Admin 媒体库(SSR)— 实现计划
|
|
||||||
|
|
||||||
> 状态:已完成(SSR `/admin/media`)
|
|
||||||
> 分支:`rebuild/gitea-ssr`
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
| 能力 | 服务 | 路由 |
|
|
||||||
|------|------|------|
|
|
||||||
| 分类/搜索/分页列表 | `UploadStore.ListMedia` | `GET /admin/media` |
|
|
||||||
| 单删 | `DeleteMediaByIDs` | `POST /admin/media/:id/delete` |
|
|
||||||
| 批量删 | 同上 | `POST /admin/media/delete` |
|
|
||||||
| 同步索引 | `SyncMediaIndex` | `POST /admin/media/sync` |
|
|
||||||
|
|
||||||
## 不做
|
|
||||||
|
|
||||||
S3 热切换 Admin、全站未引用扫描清理、WebP 产品化默认策略。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
`build.bat`;冒烟:写入 uploads → 同步索引 → 列表可见 → 删除。
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Admin 用户管理(SSR)— 实现计划
|
|
||||||
|
|
||||||
> 状态:已完成(SSR `/admin/users`)
|
|
||||||
> 分支:`rebuild/gitea-ssr`
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
在 Admin 增加用户列表与运营操作,复用已有服务层,不恢复浏览器管理 JSON `/api`。
|
|
||||||
|
|
||||||
| 能力 | 服务 | 路由 |
|
|
||||||
|------|------|------|
|
|
||||||
| 列表/搜索/分页 | `UserService.ListUsers` | `GET /admin/users` |
|
|
||||||
| 禁言/解禁 | `UserService.BanUser` | `POST /admin/users/:id/ban` |
|
|
||||||
| 认证开关 | `SetVerified` | `POST /admin/users/:id/verify` |
|
|
||||||
| 调积分 | `PointsService.AdminAdjust` | `POST /admin/users/:id/points` |
|
|
||||||
|
|
||||||
## 实现要点
|
|
||||||
|
|
||||||
1. [`routers/web/home.go`](../routers/web/home.go) admin 组注册上述路由
|
|
||||||
2. 新建 [`routers/web/admin_users.go`](../routers/web/admin_users.go):渲染 + CSRF POST + PRG
|
|
||||||
3. [`templates/admin/users.tmpl`](../templates/admin/users.tmpl) + [`templates/admin/nav.tmpl`](../templates/admin/nav.tmpl) 入口
|
|
||||||
4. 回写 `02` §H(调积分/认证相关)、`06` `/admin/users` 已迁;更新 `09` 当前指针 → badges
|
|
||||||
|
|
||||||
## 不做
|
|
||||||
|
|
||||||
等级设定 UI、徽章授予、批量导入、恢复 `/api/admin/users` 作 UI。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
`build.bat`;冒烟:搜索用户 → 禁言 → 调积分 → 切换 verified。
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# P2 等级设定与徽章展示 — 实现计划
|
|
||||||
|
|
||||||
> 状态:已完成
|
|
||||||
> 分支:`rebuild/gitea-ssr`
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
| 能力 | 实现 |
|
|
||||||
|------|------|
|
|
||||||
| Admin 设等级 | `POST /admin/users/:id/level` → `SetUserLevel`(Exp 调至门槛) |
|
|
||||||
| 公开等级 | 既有 `/user/:id`、`/profile` Lv/Exp |
|
|
||||||
| 徽章展示 | 用户页 / 资料页列表;访问时 `EvaluateAuto` |
|
|
||||||
| 防刷分成 | 确认 `suspiciousUnlockPair` 已落地并勾选 `02` |
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
`build.bat`;冒烟:设用户 Lv5 → Exp=200;用户页可见等级。
|
|
||||||
@@ -18,6 +18,16 @@
|
|||||||
|
|
||||||
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
|
||||||
|
|
||||||
|
## 自定义单页(推荐)
|
||||||
|
|
||||||
|
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
|
||||||
|
|
||||||
|
| 文件 | 标题 | slug | 操作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
|
||||||
|
|
||||||
|
访问路径:`/page/changelog`。
|
||||||
|
|
||||||
## 可选短文
|
## 可选短文
|
||||||
|
|
||||||
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
| 改什么 | 去哪里 |
|
| 改什么 | 去哪里 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 端口、数据目录、DB、JWT | CLI / Env + `data/.jwt_secret`(改引导项需重启) |
|
| 端口、数据目录、JWT | 服务器上的 `app.ini`(改后重启) |
|
||||||
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
|
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
|
||||||
|
|
||||||
逛完板块,发第一帖或回一楼——聊起来就对了。
|
逛完板块,发第一帖或回一楼——聊起来就对了。
|
||||||
@@ -98,12 +98,12 @@
|
|||||||
### 浏览与发帖
|
### 浏览与发帖
|
||||||
|
|
||||||
- 三栏布局,桌面 / 移动自适应
|
- 三栏布局,桌面 / 移动自适应
|
||||||
- Feed 排序
|
- Feed 排序与虚拟滚动
|
||||||
- 浅色 / 暗色主题(可跟随系统);板块图标与主题色可配
|
- 浅色 / 暗色主题;板块图标与主题色可配
|
||||||
- Markdown 编辑器、标签、正文图片、内容门控
|
- TipTap 富文本、标签、正文图片
|
||||||
- 楼层评论:回复、引用、嵌套树
|
- 楼层评论:回复、引用、@
|
||||||
- 点赞、收藏;置顶 / 精华
|
- 点赞、收藏;置顶 / 精华
|
||||||
- 编辑修订历史
|
- 编辑修订历史(可对比差异)
|
||||||
- 可配置普通用户的编辑时限
|
- 可配置普通用户的编辑时限
|
||||||
|
|
||||||
### 社交与个人
|
### 社交与个人
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
### 管理后台
|
### 管理后台
|
||||||
|
|
||||||
SSR 后台统一在 `/admin`:
|
与前台同一套 React 体验,统一在 `/admin`:
|
||||||
|
|
||||||
| 模块 | 能力 |
|
| 模块 | 能力 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -151,7 +151,7 @@ SSR 后台统一在 `/admin`:
|
|||||||
|
|
||||||
1. 编译得到一个二进制
|
1. 编译得到一个二进制
|
||||||
2. 放到目录里运行
|
2. 放到目录里运行
|
||||||
3. 自动生成 `data/.jwt_secret`
|
3. 自动生成 `app.ini`
|
||||||
4. 浏览器注册,第一个账号即管理员
|
4. 浏览器注册,第一个账号即管理员
|
||||||
|
|
||||||
### 它补哪块空缺
|
### 它补哪块空缺
|
||||||
@@ -165,14 +165,14 @@ SSR 后台统一在 `/admin`:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 单二进制 | 前端已内嵌,不必单独部署前端目录 |
|
| 单二进制 | 前端已内嵌,不必单独部署前端目录 |
|
||||||
| 内置 SQLite | 零外部数据库,数据在本地目录 |
|
| 内置 SQLite | 零外部数据库,数据在本地目录 |
|
||||||
| Env / CLI 引导 | 端口、数据目录、数据库 |
|
| 精简 `app.ini` | 主要管端口、数据目录、JWT |
|
||||||
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
|
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
|
||||||
| 系统服务 | 内置 Linux systemd / Windows Service |
|
| 系统服务 | 内置 Linux systemd / Windows Service |
|
||||||
| 跨平台 | Windows / Linux / macOS 均可编译运行 |
|
| 跨平台 | Windows / Linux / macOS 均可编译运行 |
|
||||||
|
|
||||||
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
|
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
|
||||||
|
|
||||||
进程级项改 Env/CLI 后重启;业务项在管理后台改。引导面保持精简,热更新走数据库。
|
进程级项改 `app.ini` 后重启;业务项在管理后台改。既保留「改文件控进程」的可控性,又避免把所有开关塞进配置文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
160
docs/site-posts/changelog.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# 更新日志
|
||||||
|
|
||||||
|
> 建议标题:更新日志
|
||||||
|
> 建议 slug:`changelog`
|
||||||
|
> 建议路径:`/page/changelog`
|
||||||
|
> 建议操作:后台「单页管理」新建并发布;勾选「页脚展示」(可选「侧栏导航」)
|
||||||
|
> 复制正文时:从下方第一个 `---` **之后**开始粘贴(Markdown 模式)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Docker 镜像、Windows exe、Linux 单文件 **共用同一版本号**。有新版本时,以本页为准。
|
||||||
|
|
||||||
|
**当前版本:1.1.7**(2026-09-02)
|
||||||
|
|
||||||
|
## 去哪看新版本
|
||||||
|
|
||||||
|
| 渠道 | 看什么 | 地址 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 本页 | 版本号、变更说明、升级注意 | 本站 `/page/changelog` |
|
||||||
|
| Docker | 镜像标签(`latest` 与 `1.1.x`) | [Docker Hub · Tags](https://hub.docker.com/r/hangzhang714128/jiang13-forum/tags) |
|
||||||
|
| Windows / Linux | 预编译单文件(与 Docker 同版本) | [Gitea Releases](https://git.iioio.com/freefire/jiang13-forum/releases) |
|
||||||
|
| 源码 | 提交记录与自行编译 | [Gitea 仓库](https://git.iioio.com/freefire/jiang13-forum) |
|
||||||
|
|
||||||
|
**1.1.7 直接下载:**
|
||||||
|
|
||||||
|
- Windows x64:[jiang13-1.1.7-windows-amd64.exe](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-windows-amd64.exe)
|
||||||
|
- Linux x64:[jiang13-1.1.7-linux-amd64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/jiang13-1.1.7-linux-amd64)
|
||||||
|
- 校验文件:[SHA256SUMS.txt](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.7/SHA256SUMS.txt)
|
||||||
|
|
||||||
|
拉取指定版本镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull hangzhang714128/jiang13-forum:1.1.7
|
||||||
|
# 或始终跟随最新
|
||||||
|
docker pull hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:停掉正在跑的进程或服务,用新下载的 exe 覆盖原文件(可改名为 `jiang13.exe`),**不要动**旁边的 `data/` 与 `app.ini`,再启动即可。
|
||||||
|
|
||||||
|
Linux:赋予执行权限后放到原目录覆盖,同样保留 `data/` 与 `app.ini`。
|
||||||
|
|
||||||
|
## 升级注意
|
||||||
|
|
||||||
|
- 一般只需换镜像或换 exe,数据目录向后兼容。
|
||||||
|
- Docker 请继续挂载原来的 `/data` 卷,切勿新建空目录当「升级」。
|
||||||
|
- 升级后若页面样式异常,强制刷新浏览器(Ctrl+F5)。程序也会在前端资源失效时自动硬刷新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.1.7 — 2026-09-02
|
||||||
|
|
||||||
|
私信与开源展柜体验升级,待审通知的审核态更及时。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 私信改为飞书风布局:缩略图表情/图片、按会话草稿、用户搜索发起会话
|
||||||
|
- 主页「发私信」直达消息页对应会话
|
||||||
|
- 开源展柜改为目录式 UI,空列表不再粘滞
|
||||||
|
- 消息页体验重构;待审通知打开后实时回填审核态
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 帖子详情右栏默认头像改为深绿底白字,与列表默认头像配色一致
|
||||||
|
|
||||||
|
## 1.1.6 — 2026-09-01
|
||||||
|
|
||||||
|
编辑器插入体验与首页默认 Feed 排序更合理。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 链接对话框支持网址、链接文字与站内帖子/单页搜索;站内搜索收进「高级选项」,点选同步链接文字
|
||||||
|
- 文章与评论共用统一图片插入选择器(上传 / 链接 / 已上传)
|
||||||
|
- 链接是否新标签打开交由全站设置,对话框不再单独开关
|
||||||
|
- 首页默认「最新」按发帖与最后评论的较晚时间混排,零回复新帖不再沉底
|
||||||
|
- 单页详情右侧栏与帖子共用目录布局;三栏主滚动区统一细绿色滚动条
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 帖子目录从正文同步派生,避免预取缓存竞态导致空树
|
||||||
|
|
||||||
|
## 1.1.5 — 2026-09-01
|
||||||
|
|
||||||
|
首页首屏与交互更稳,Feed 排序语义更清楚。
|
||||||
|
本版起同时提供 **Docker 镜像** 与 **Windows / Linux 预编译单文件**(Gitea Release `v1.1.5`)。
|
||||||
|
|
||||||
|
**新增 / 调整**
|
||||||
|
|
||||||
|
- 首页改为 Go SSR 与 React hydrate(注水)同构,打开首页不再先闪一层空壳再跳内容
|
||||||
|
- Feed 排序改为:**新评论 / 新帖子 / 推荐帖**;推荐帖只出精华(featured)
|
||||||
|
- 点击排序会强制刷新列表;软刷新时同步站点限额与品牌文案
|
||||||
|
- 返回列表用缓存恢复滚动位置,不再重复请求
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 发版后软刷新会检测入口壳;chunk(代码分片)404 时自动硬刷新,避免卡在旧页面
|
||||||
|
- 软刷新齐套前保留旧画面,避免一点击就把内容卸光
|
||||||
|
- 补齐 `/favicon.ico` 与页面 head 图标;拆除爬虫专用 HTML
|
||||||
|
- 桌面端隐藏多余的导航汉堡按钮
|
||||||
|
- 帖行评论数、列表「共 N 条」右对齐;排序栏数字紧跟标签
|
||||||
|
- 手机端 SSR 顶栏 / 页脚与 React 一致,减少闪动
|
||||||
|
|
||||||
|
## 1.1.4 — 2026-08-31
|
||||||
|
|
||||||
|
网站监控的地理信息与口径更准确。
|
||||||
|
|
||||||
|
- 补全 IP2Location(IP 地理位置库)中国城市中文名,同名城市按省消歧
|
||||||
|
- 对齐监控概览「双通道」口径文案与诊断抽样说明
|
||||||
|
|
||||||
|
## 1.1.3 — 2026-08-31
|
||||||
|
|
||||||
|
管理后台可看站点访问情况。
|
||||||
|
|
||||||
|
- 新增管理端 **网站监控**:浏览量写入独立 `monitor.db`,与主库分开
|
||||||
|
|
||||||
|
## 1.1.2 — 2026-08-31
|
||||||
|
|
||||||
|
可选接入官方社区展柜。
|
||||||
|
|
||||||
|
- 可选社区上报,以及官方精选展柜(自建站可出现在官方社区列表中)
|
||||||
|
|
||||||
|
## 1.1.1 — 2026-08-31
|
||||||
|
|
||||||
|
- 评论管理操作收进「更多」菜单,管理员界面更干净
|
||||||
|
|
||||||
|
## 1.1.0 — 2026-08-30
|
||||||
|
|
||||||
|
一批社区功能与界面整理,版本号从 1.0 跨到 1.1。
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
|
||||||
|
- 友链申请、独立友链页;页脚左右区域可分别开关
|
||||||
|
- 自定义单页(后台「单页管理」,如本页)
|
||||||
|
- 投票帖、悬赏帖、抽奖帖
|
||||||
|
- 侧栏签到;右侧栏「最新注册」
|
||||||
|
- 搜索重设计:筛选面板、结果页 chips(筛选标签)
|
||||||
|
- 帖子管理收进右上角菜单;手机端评论输入默认折叠
|
||||||
|
|
||||||
|
**优化**
|
||||||
|
|
||||||
|
- 首页帖子列表密度与标题样式
|
||||||
|
- 开源码桶展示加强,去掉 Feed 顶栏统计
|
||||||
|
- 单页编辑、列表留白与友链申请体验
|
||||||
|
- 开发与发行版共用 `dist/data` 数据目录,避免两套数据打架
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 手机端搜索入口与底部 Sheet(抽屉面板)
|
||||||
|
- 占位头像对比度;无头像用户不再误用游客灰底
|
||||||
|
- 页面双指缩放锁死,正文原图与灯箱仍可 pinch(捏合)放大
|
||||||
|
- 单页正文排版与帖子详情对齐
|
||||||
|
|
||||||
|
## 1.0.0 — 2026-08-23
|
||||||
|
|
||||||
|
首次发布 Docker 镜像 `hangzhang714128/jiang13-forum:1.0.0`。
|
||||||
|
|
||||||
|
当时已包含论坛核心能力:板块与发帖、楼层评论、点赞收藏、私信、TipTap 富文本、贴纸、修订历史、管理后台、OIDC(开放身份连接)/ SSO(单点登录)、邮件验证码、Gitea 仓库同步、SQLite 单二进制部署,以及 Docker 一键运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
之后发版时:Docker 推 `x.y.z` + `latest`,Gitea 打 tag `vx.y.z` 并挂上对应 exe / linux 文件,再把新版本写到本页最上方。
|
||||||
118
embed_static/embed.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package embed_static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
||||||
|
spaBrandTitleFn func() string
|
||||||
|
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
||||||
|
spaBrandFaviconFn func() string // 站点 Favicon URL,注入 <link rel="icon">
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
||||||
|
func SetSPADocumentTitle(fn func() string) {
|
||||||
|
spaBrandTitleFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
|
||||||
|
func SetSPABrandingJSON(fn func() []byte) {
|
||||||
|
spaBrandJSONFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 <link rel="icon">
|
||||||
|
func SetSPAFaviconURL(fn func() string) {
|
||||||
|
spaBrandFaviconFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
||||||
|
func SetupEmbed(r *gin.Engine) error {
|
||||||
|
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
||||||
|
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
|
||||||
|
r.GET("/assets/*filepath", func(c *gin.Context) {
|
||||||
|
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
|
||||||
|
w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
|
||||||
|
c.Writer = w
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if sub, err := fs.Sub(staticFS, "static/spa/stickers"); err == nil {
|
||||||
|
fileServer := http.StripPrefix("/stickers", http.FileServer(http.FS(sub)))
|
||||||
|
r.GET("/stickers/*filepath", func(c *gin.Context) {
|
||||||
|
// stickers 不含哈希指纹,设置适中的缓存
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control
|
||||||
|
type cacheOnOKWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
cacheControl string
|
||||||
|
wroteHeader bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *cacheOnOKWriter) WriteHeader(code int) {
|
||||||
|
if !w.wroteHeader {
|
||||||
|
w.wroteHeader = true
|
||||||
|
if code == http.StatusOK {
|
||||||
|
w.Header().Set("Cache-Control", w.cacheControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *cacheOnOKWriter) Write(b []byte) (int, error) {
|
||||||
|
if !w.wroteHeader {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
|
||||||
|
func ServeSPA(c *gin.Context) {
|
||||||
|
ServeSPAWithMeta(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSPANoIndex 返回带 noindex 的 SPA(登录/后台等私密页)
|
||||||
|
func ServeSPANoIndex(c *gin.Context) {
|
||||||
|
title := ""
|
||||||
|
if spaBrandTitleFn != nil {
|
||||||
|
title = strings.TrimSpace(spaBrandTitleFn())
|
||||||
|
}
|
||||||
|
ServeSPAWithMeta(c, &SPAPageMeta{
|
||||||
|
Title: title,
|
||||||
|
Robots: "noindex,nofollow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSPARoute 判断是否应由 SPA 处理
|
||||||
|
func IsSPARoute(path string) bool {
|
||||||
|
if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "/api") ||
|
||||||
|
strings.HasPrefix(path, "/admin") ||
|
||||||
|
strings.HasPrefix(path, "/uploads") ||
|
||||||
|
strings.HasPrefix(path, "/media") ||
|
||||||
|
strings.HasPrefix(path, "/assets") ||
|
||||||
|
strings.HasPrefix(path, "/stickers") ||
|
||||||
|
strings.HasPrefix(path, "/oauth") ||
|
||||||
|
strings.HasPrefix(path, "/.well-known") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
202
embed_static/spa_meta.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package embed_static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var spaRootEmptyRe = regexp.MustCompile(`(?s)<div id="root">\s*</div>`)
|
||||||
|
|
||||||
|
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。
|
||||||
|
// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。
|
||||||
|
type SPAPageMeta struct {
|
||||||
|
Title string // 完整 <title>
|
||||||
|
Description string
|
||||||
|
Keywords string // meta keywords
|
||||||
|
Canonical string
|
||||||
|
OGType string // 默认 website
|
||||||
|
OGImage string
|
||||||
|
SiteName string // og:site_name
|
||||||
|
Locale string // og:locale,默认 zh_CN
|
||||||
|
Robots string // 如 noindex,nofollow
|
||||||
|
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
|
||||||
|
Status int // HTTP 状态码,0 视为 200
|
||||||
|
RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR)
|
||||||
|
BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
|
||||||
|
func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
||||||
|
status := http.StatusOK
|
||||||
|
if meta != nil && meta.Status != 0 {
|
||||||
|
status = meta.Status
|
||||||
|
}
|
||||||
|
data, err := staticFS.ReadFile("static/spa/index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = applySPAPageMeta(data, meta)
|
||||||
|
// 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存)
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.Header("Pragma", "no-cache")
|
||||||
|
c.Data(status, "text/html; charset=utf-8", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
|
||||||
|
if meta == nil {
|
||||||
|
meta = &SPAPageMeta{}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(meta.Title)
|
||||||
|
if title == "" && spaBrandTitleFn != nil {
|
||||||
|
title = strings.TrimSpace(spaBrandTitleFn())
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
escaped := html.EscapeString(title)
|
||||||
|
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 </title>,不经 JS ——
|
||||||
|
var seo strings.Builder
|
||||||
|
writeMeta(&seo, "description", meta.Description)
|
||||||
|
writeMeta(&seo, "keywords", meta.Keywords)
|
||||||
|
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
||||||
|
seo.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
||||||
|
}
|
||||||
|
if favicon := spaFaviconHref(); favicon != "" {
|
||||||
|
seo.WriteString(`<link rel="icon" href="` + html.EscapeString(favicon) + `"/>`)
|
||||||
|
seo.WriteString(`<link rel="shortcut icon" href="` + html.EscapeString(favicon) + `"/>`)
|
||||||
|
}
|
||||||
|
robots := strings.TrimSpace(meta.Robots)
|
||||||
|
if robots != "" {
|
||||||
|
writeMeta(&seo, "robots", robots)
|
||||||
|
}
|
||||||
|
|
||||||
|
ogType := strings.TrimSpace(meta.OGType)
|
||||||
|
if ogType == "" {
|
||||||
|
ogType = "website"
|
||||||
|
}
|
||||||
|
locale := strings.TrimSpace(meta.Locale)
|
||||||
|
if locale == "" {
|
||||||
|
locale = "zh_CN"
|
||||||
|
}
|
||||||
|
writeProp(&seo, "og:type", ogType)
|
||||||
|
writeProp(&seo, "og:site_name", meta.SiteName)
|
||||||
|
writeProp(&seo, "og:locale", locale)
|
||||||
|
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
|
||||||
|
writeProp(&seo, "og:description", meta.Description)
|
||||||
|
writeProp(&seo, "og:url", meta.Canonical)
|
||||||
|
writeProp(&seo, "og:image", meta.OGImage)
|
||||||
|
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
|
||||||
|
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
|
||||||
|
writeMetaName(&seo, "twitter:description", meta.Description)
|
||||||
|
writeMetaName(&seo, "twitter:image", meta.OGImage)
|
||||||
|
|
||||||
|
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
||||||
|
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
|
||||||
|
seo.WriteString(`<script type="application/ld+json">`)
|
||||||
|
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
|
||||||
|
seo.WriteString(`</script>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seo.Len() > 0 {
|
||||||
|
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 可执行 boot 脚本仍放在 </head> 前 ——
|
||||||
|
var boot strings.Builder
|
||||||
|
if s := spaBrandingBootScript(); s != "" {
|
||||||
|
boot.WriteString(s)
|
||||||
|
}
|
||||||
|
if s := spaHomeBootScript(meta.BootJSON); s != "" {
|
||||||
|
boot.WriteString(s)
|
||||||
|
}
|
||||||
|
if boot.Len() > 0 {
|
||||||
|
data = bytes.Replace(data, []byte("</head>"), []byte(boot.String()+"</head>"), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if root := strings.TrimSpace(meta.RootHTML); root != "" {
|
||||||
|
data = injectSPARootHTML(data, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO)
|
||||||
|
func spaHomeBootScript(raw []byte) string {
|
||||||
|
raw = bytes.TrimSpace(raw)
|
||||||
|
if len(raw) == 0 || !json.Valid(raw) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||||
|
return "<script>window.__J13_HOME_BOOT__=" + string(safe) + ";</script>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白)
|
||||||
|
func injectSPARootHTML(data []byte, rootHTML string) []byte {
|
||||||
|
return spaRootEmptyRe.ReplaceAll(data, []byte(`<div id="root">`+rootHTML+`</div>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
||||||
|
func spaBrandingBootScript() string {
|
||||||
|
if spaBrandJSONFn == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw := bytes.TrimSpace(spaBrandJSONFn())
|
||||||
|
if len(raw) == 0 || !json.Valid(raw) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||||
|
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMeta(b *strings.Builder, name, content string) {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(`<meta name="` + html.EscapeString(name) + `" content="` + html.EscapeString(content) + `"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMetaName(b *strings.Builder, name, content string) {
|
||||||
|
writeMeta(b, name, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeProp(b *strings.Builder, prop, content string) {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(`<meta property="` + html.EscapeString(prop) + `" content="` + html.EscapeString(content) + `"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func twitterCard(ogImage string) string {
|
||||||
|
if strings.TrimSpace(ogImage) != "" {
|
||||||
|
return "summary_large_image"
|
||||||
|
}
|
||||||
|
return "summary"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if s := strings.TrimSpace(v); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL)
|
||||||
|
func spaFaviconHref() string {
|
||||||
|
if spaBrandFaviconFn == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(spaBrandFaviconFn())
|
||||||
|
}
|
||||||
20
frontend/components.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/styles/global.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
frontend/index.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="description" content="拾三一隅,自在交流" />
|
||||||
|
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||||
|
<!-- 样式由 Vite 构建注入外链 CSS,不在此内联 style -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var theme = localStorage.getItem('j13-theme') || 'light';
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
document.documentElement.style.colorScheme = theme;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
/* 锁死手机/平板双指与手势缩放;头像裁剪 / 图片灯箱放行 pinch */
|
||||||
|
(function () {
|
||||||
|
function inZoomAllowArea(target) {
|
||||||
|
return !!(
|
||||||
|
target &&
|
||||||
|
target.closest &&
|
||||||
|
target.closest('.avatar-crop-stage, .image-lightbox')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function blockGesture(e) {
|
||||||
|
if (inZoomAllowArea(e.target)) return;
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
}
|
||||||
|
function blockMultiTouch(e) {
|
||||||
|
if (e.touches && e.touches.length > 1) {
|
||||||
|
if (inZoomAllowArea(e.target)) return;
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('gesturestart', blockGesture, { passive: false });
|
||||||
|
document.addEventListener('gesturechange', blockGesture, { passive: false });
|
||||||
|
document.addEventListener('gestureend', blockGesture, { passive: false });
|
||||||
|
document.addEventListener('touchstart', blockMultiTouch, { passive: false });
|
||||||
|
document.addEventListener('touchmove', blockMultiTouch, { passive: false });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4514
frontend/package-lock.json
generated
Normal file
67
frontend/package.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "jiang13-forum-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.16",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
||||||
|
"@radix-ui/react-label": "^2.1.9",
|
||||||
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
|
"@radix-ui/react-switch": "^1.3.0",
|
||||||
|
"@svg-maps/china": "^2.0.0",
|
||||||
|
"@svg-maps/world": "^2.0.0",
|
||||||
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
|
"@tiptap/core": "^3.26.1",
|
||||||
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
|
"@tiptap/extension-image": "^3.26.1",
|
||||||
|
"@tiptap/extension-link": "^3.26.1",
|
||||||
|
"@tiptap/extension-placeholder": "^3.26.1",
|
||||||
|
"@tiptap/extension-table": "^3.26.1",
|
||||||
|
"@tiptap/extension-underline": "^3.26.1",
|
||||||
|
"@tiptap/pm": "^3.26.1",
|
||||||
|
"@tiptap/react": "^3.26.1",
|
||||||
|
"@tiptap/starter-kit": "^3.26.1",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"diff": "^9.0.0",
|
||||||
|
"dompurify": "^3.4.10",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"lucide-react": "^1.18.0",
|
||||||
|
"marked": "^18.0.5",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-easy-crop": "^6.0.2",
|
||||||
|
"react-hook-form": "^7.79.0",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"turndown": "^7.2.4",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^4.5.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
frontend/public/stickers/bilibili/bl_01.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_02.avif
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/stickers/bilibili/bl_03.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_04.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_05.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_06.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_07.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_08.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_09.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_10.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_11.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_12.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_13.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_14.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_15.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_16.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_17.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_18.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_19.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_20.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_21.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_22.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_23.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/bilibili/bl_24.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_25.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_26.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_27.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_28.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_29.avif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/stickers/bilibili/bl_30.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/bilibili/bl_31.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_32.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/bilibili/bl_33.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/bilibili/bl_34.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/bilibili/bl_35.avif
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/stickers/douyin/dy_01.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_02.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_03.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_04.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_05.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/stickers/douyin/dy_06.avif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/stickers/douyin/dy_07.avif
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/stickers/douyin/dy_08.avif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |