45 Commits

Author SHA1 Message Date
2ba010f3ea feat: 首页对齐 main SPA 草绿壳与 Feed v2
顶栏搜索胶囊/发帖绿钮/Ctrl+K,侧栏板块色槽,列表 post-row--v2 与 SPA 同密度。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 06:06:04 +08:00
6e7b46a2e9 feat: 首页门面 Feed 密度与侧栏分组
对齐 SPA 信息密度:头像/摘要/互动、高级搜索收起、浏览/板块分区。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 05:55:45 +08:00
e0683ef262 feat: 青主题产品壳与静态资源版本号
对齐 SPA 顶栏工具条与清青强调色,CSS/JS 带构建 ?v= 长缓存穿透。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 05:43:25 +08:00
3bd0b990a1 feat: 公开页设计系统与壳层重绘
落墨青冷纸 token 与设计规格,重绘顶栏/三栏/Feed/帖详情壳层并同步暗色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 05:22:15 +08:00
58b94df50c chore: 主题改媒体查询并清理未挂载 API
去掉 head 防闪脚本;删除论坛 JSON CRUD 与 crawler,仅保留机器入口;用户向文档对齐 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 05:10:23 +08:00
4a21d362d0 feat: 浅色/暗色主题并支持跟随系统
顶栏循环切换,localStorage 记忆;head 内联脚本避免首屏闪烁。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 04:54:15 +08:00
1f777eebb1 feat: Markdown 工具栏编辑器与服务端预览
共用 md_editor 于发帖/改帖/评论,扩展 ComposeBodyToHTML,并提供 /compose/preview。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 04:50:07 +08:00
7f048bd9b5 feat: 评论按 ThreadParentID 嵌套树展示
修复 html/template 中 {{template}} 重置 $ 导致渲染失败,页面字段随 commentItemData 下传。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 04:28:44 +08:00
f0358e0f71 feat: 完整内容 Limits 与伪静态 Admin
设置页可配字数/编辑窗/分页与伪静态后缀;公开帖/板/用户链接走规范路径并 301。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 04:12:43 +08:00
118f967540 feat: 发评限流与品牌 Logo/Favicon/OG 上传
发评挂 RateLimiter comment;设置页支持品牌图上传/清除,并写入 favicon 与 og:image。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 04:06:48 +08:00
4ffcaecba2 feat: Admin 设用户等级并展示徽章于用户页
补齐 SetUserLevel 表单;用户主页/资料页展示徽章并触发 EvaluateAuto;勾选 §H Exp 与防刷。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 04:02:24 +08:00
9e403a7178 feat: Admin 媒体库列表、删除与同步索引
SSR /admin/media 复用 ListMedia/DeleteMedia,并支持按 ID 批量删与扫盘回填。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 03:47:20 +08:00
0c5fe8a1b4 feat: Admin 徽章定义 CRUD 与限定颁发收回
SSR /admin/badges 复用 UpsertDef/AwardLimited/Revoke,并新增 DeleteDef;指针指向 media。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 03:42:41 +08:00
02a5ad0c30 feat: Admin 用户列表禁言、认证与调积分
SSR /admin/users 复用 ListUsers/BanUser/SetVerified/AdminAdjust,并回写 02/06/09 指针至徽章。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 03:38:38 +08:00
5fb3b2a165 docs: 进度文档链接 Admin 用户实现提纲
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 03:32:33 +08:00
ae448e1ccc docs: 增加 SSR 进度路线图与 Admin 用户下一刀提纲
便于查阅当前完成度与默认继续刀序;lottery/§O 已在前序提交。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 03:32:25 +08:00
e06c54870c feat: SSR 抽奖帖发帖与开奖
compose 支持 lottery;详情抽奖卡可开奖并展示中奖名单。同步勾选已落地的 health/robots/sitemap/uploads/限流清单项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 03:30:57 +08:00
b5aee606fb feat: SSR 悬赏帖托管、采纳与退款
compose 支持 bounty;详情悬赏条可采纳评论或按规则退回。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 03:16:33 +08:00
303755b47a feat: SSR 问答帖发帖与解决状态切换
compose 支持 question;详情徽章并由作者/管理员切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 02:04:56 +08:00
92b68b9eef feat: Admin 友链申请回链复检按钮
调用 RecheckReciprocal,开启检测且有回链页时可复检。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 01:53:14 +08:00
c8533b6184 feat: Admin 设置页 SQLite 一键备份与下载
复用 BackupService,表单导出后安全下载备份文件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 01:30:16 +08:00
4f93397ef3 feat: SSR 投票帖发帖与详情投票卡
compose 支持 poll 类型与选项,详情可投票/查看结果/结束。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 01:18:58 +08:00
7f21ffb14e feat: 帖/评待审与被拒状态对作者可见
详情页横幅与楼层标签,避免作者误以为内容未保存。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 22:43:20 +08:00
f10ba99012 feat: SSR 帖子修订历史列表与快照
作者与管理员可查看编辑前版本,帖详情增加修订入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 22:28:04 +08:00
c32c81d1ca feat: SSR 评论回复与 @ 提及通知
发评/审通接入 NotifyService,系统通知会话支持 kind 筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 21:51:52 +08:00
145c7a3e1f feat: SSR 评论编辑与作者删除
帖详情提供时限内编辑与软删入口,作者删除与管理员共用回收站子树语义。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 21:36:12 +08:00
54f5de07a4 feat: 注册流程强制图形验证码
SSR 注册页与 /api/register 共用 CaptchaService,防刷同时保留换一张无 JS 刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 21:13:24 +08:00
5e623f67fb feat: Admin settings 侧栏 aside_widgets 开关与排序
让运营可在 /admin/settings 配置右栏组件,并与友链页 aside 开关共用同一份 JSON。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 20:16:07 +08:00
2d09729976 feat: SSR 帖运营标记与回收站
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 18:20:47 +08:00
83994e2707 feat: SSR 帖/评举报与 Admin 处理
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 17:47:09 +08:00
802f86605c feat: SSR 站点单页 /page/:slug 与 Admin CRUD
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 16:49:39 +08:00
c4faba81b3 feat: SSR 右栏签到/抽奖条与安全回跳
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 15:55:32 +08:00
959a2c3a2e feat: SSR 三栏右栏热门帖与 aside widgets
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 15:19:32 +08:00
f76d3e3bf8 feat: SSR Feed 搜索面板(关键词/标签/作者/仅标题)
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 14:54:32 +08:00
2a46c01c85 feat: SSR 公开板块索引 /boards
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 06:53:20 +08:00
d4b29f5b7c feat: SSR 评论回复、点赞与私密开关
帖详情支持 reply_to 展示与提交、评论点赞 PRG,以及私密评论选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 06:46:41 +08:00
4dc196b055 feat: SSR 忘记密码与 Admin SMTP 设置
邮箱验证码重置密码(吊销 session);后台可配 SMTP 并发送测试信。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 06:40:19 +08:00
7bc50bfb80 feat: SSR 友链页与最小 Admin 审核
公开 /links 列表与申请/取消,导航页脚可配入口;后台品牌增删与通过/拒绝。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 06:30:01 +08:00
204e7fdb32 feat: SSR 内容门控与积分钱包/签到抽奖
闭合积分解锁经济闭环:锁定壳与 unlock,以及 profile 钱包流水与每日签到/抽奖 PRG。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 06:20:12 +08:00
40ec20b3df feat: SSR 私信与通知(/messages)
会话列表、读写、未读角标与全部已读;系统会话只读;用户主页可发起私信。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 05:59:54 +08:00
126e3bae98 feat: SSR 个人闭环(profile / user / favorites)
登录用户可改资料与头像、浏览收藏;公开主页不含邮箱;改密吊销并重建本端 session。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 05:53:56 +08:00
fde5f628ec feat: opaque session、安装/发帖 SSR 与最小 Admin 后台
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings;
落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 05:44:16 +08:00
3f50316ad0 fix: 完成 Gitea 目录改组收尾(import、构建与 LICENSE)
同步包路径与路由,去掉 SPA 构建步骤,对齐 Gitea 式 LICENSE,并更新规格/规则与占位 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 04:01:20 +08:00
9fe299a45f refactor: Gitea 式目录改组,移除本分支 SPA 与杂项产物
将 model/service/handler/middleware 迁至 models/services/routers/api/modules/auth,并删除 frontend、embed_static、scripts 及误入库缓存/二进制。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:54:56 +08:00
1414c71dec docs+feat: 重构规格与 Gitea 式 SSR 骨架(首页)
在 rebuild/gitea-ssr 落地产品规格、Cursor 规则,以及 Go 模板 SSR 首页/板块列表;未迁移路径仍回落 SPA,便于对照 main。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:11:25 +08:00
626 changed files with 21072 additions and 73337 deletions

View File

@@ -5,7 +5,8 @@ alwaysApply: true
# 编译与构建脚本 # 编译与构建脚本
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。 本分支(`rebuild/gitea-ssr`Go 单二进制 + `go:embed` 的 **templates** 与 **public/assets**(由 `web_src` 构建);产物在 `dist/`。
对照 SPA 仅在 `main` 分支。
## 运行编译(优先用封装命令,不要猜命令) ## 运行编译(优先用封装命令,不要猜命令)
@@ -18,15 +19,16 @@ Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。 Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
常用 target`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。 常用 target`build`(默认)、`dev`、`run`、`web-src`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
## 修改构建脚本时的约定 ## 修改构建脚本时的约定
1. **双轨同步**`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。 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. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。 4. **构建顺序**:先 `web_src` 内 `npm run build`(产出 `public/assets`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
5. **入口包**`./cmd/jiang13`Windows 产物带 `.exe`。 5. **入口包**`./cmd/jiang13`Windows 产物带 `.exe`。
6. **本分支无 `frontend/`**:勿再添加 SPA 构建步骤;需要对照 UI 时 `git checkout main`。
## 新增 target 检查清单 ## 新增 target 检查清单

View File

@@ -0,0 +1,37 @@
---
description: Gitea 式 SSR 重构总则rebuild/gitea-ssr 分支)
alwaysApply: true
---
# Gitea 式 SSR 重构
## 分支
- 功能开发在 **`rebuild/gitea-ssr`****不要**把破坏性 SSR 替换推到 `main`。
- `main` 保留 React SPA用作对照checkout / worktree
## 渲染
- 公开页(首页、板块、帖详情、用户页等)必须用 **Go `html/template` 服务端渲染完整 HTML**。
- 禁止为已迁移公开页恢复 React SPA 空壳;禁止「用户 SPA + 爬虫专用 HTML」双轨作为长期方案。
- JSON `/api` 仅用于交互增强与管理后台,**不得**作为公开页首屏唯一数据来源。
## 架构参照
对齐 [Gitea](https://github.com/go-gitea/gitea) 职责划分:
- `routers/web` — HTML 页面路由
- `routers/api` — JSON API原 `handler/`
- `routers/setup.go` — 路由总装
- `templates/` — 模板
- `web_src/` → `public/assets/` — 渐进增强 CSS/JS
- `models/`、`services/` — 数据与业务
- `modules/auth`、`modules/webrender`、`modules/seo` — 横切
本分支**已删除** `frontend/` 与 `embed_static/`;勿再恢复 SPA 生产回落。
细节见 [`docs/rebuild-spec/08-gitea-ssr-architecture.md`](docs/rebuild-spec/08-gitea-ssr-architecture.md)。
## 产品规格
实现功能前按 [`docs/rebuild-spec/README.md`](docs/rebuild-spec/README.md) 阅读顺序核对;业务规则以 `05-business-rules.md` 为准。

View File

@@ -0,0 +1,20 @@
---
description: 产品规格为唯一业务事实来源
alwaysApply: true
---
# 规格文档对照
重构实现时以 [`docs/rebuild-spec/`](docs/rebuild-spec/) 为准,不另发明业务语义。
| 需求类型 | 查阅 |
|----------|------|
| 要不要做某功能 | `02-features.md` |
| 表字段 / 枚举 / settings 键 | `03-data-model.md` |
| HTTP 路径与 JSON 形状 | `04-api.md` |
| 审核 / 积分 / 门控 / 悬赏等 | `05-business-rules.md` |
| 路由与交互信息架构 | `06-pages-ux.md` |
| 配置与部署 | `07-config-ops.md` |
| SSR 目录与分支 | `08-gitea-ssr-architecture.md` |
规格与代码冲突时:以**当前分支代码**行为为准,并应回写修正规格。

View File

@@ -0,0 +1,14 @@
---
description: Go HTML 模板约定SSR
globs: templates/**/*
alwaysApply: false
---
# 模板约定
- 目录对齐 Gitea`templates/base/`、`home/`、`post/`、`shared/`、`status/`、`auth/`,根级 `install.tmpl` / `post-install.tmpl`。
- 页面入口用固定 `{{define "home"}}` / `{{define "post"}}` 等显式 `{{template "base/head"}}`…;**禁止** `{{template .Name}}`(标准库不支持动态名)。
- **默认转义**;用户 HTML 须先消毒 + 门控后再 `{{safeHTML ...}}`。
- 静态资源 `/ssr-assets/...`。
- 浏览器写操作走 `routers/web` 表单 POST + CSRF`webctx`),不依赖 JSON `/api`。
- 中文文案可写在模板;站点名等从 PageChrome / settings 传入。

View File

@@ -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/
frontend/node_modules/ web_src/node_modules/
frontend/dist/ public/assets/
embed_static/static/spa/
node_modules/ node_modules/
.vite/ .vite/

20
.gitignore vendored
View File

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

View File

@@ -1,151 +0,0 @@
# 表情效果调整计划
## 问题分析
当前实现存在 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 4pxpadding 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 列密度更高
- 颜文字标签内字体清晰可读

View File

@@ -1,597 +0,0 @@
# 评论表情包改造 + 富文本评论编辑器计划
## 一、摘要
将评论系统从「纯文本 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`
不直接复用 ArticleEditor900 行,含全屏/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 ECSS 样式
#### 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移除 EmojiPickercontent 改为 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 个 chunkhot/j13/text`React.lazy()` 动态导入
- 首次打开选择器只加载"热门"chunk8 个贴纸),切换分类时才加载其他 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 内容
- 保存后评论更新正确

View File

@@ -1,138 +0,0 @@
# 修复主页右侧栏评论点击定位问题
## 问题描述
用户在主页右侧栏点击评论时,如果该评论不是帖子的第一个评论(`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`),验证定位效果

View File

@@ -1,117 +0,0 @@
# 修复:再次进入帖子时滚动位置异常保留
## 问题
浏览帖子滑到下方 → 返回主页 → 再次进入同一帖子,滚动条停在之前阅读的位置,而非顶部。
## 根因
项目未设置 `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 returnpageRef.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 编译通过。

View File

@@ -1,55 +0,0 @@
# 修复生产环境表情图片无法显示 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 typehttp.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 静态文件服务方案

View File

@@ -1,8 +0,0 @@
{
"hash": "de7e4cff",
"configHash": "9a7296da",
"lockfileHash": "e3b0c442",
"browserHash": "8c168d3c",
"optimized": {},
"chunks": {}
}

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -4,12 +4,14 @@
## 开发环境 ## 开发环境
**要求:** Go 1.26+、Node.js 18+ **要求:** Go 1.26+、Node.js 18+(仅构建 `web_src` 静态资源)
本分支(`rebuild/gitea-ssr`)为 **Go 模板 SSR**;对照 React SPA 请 `git checkout main`
```bat ```bat
REM Windows一键启动后端 + 前端热更新(请用 build.bat REM Windows请用 build.bat(内部 Bypass ExecutionPolicy
build.bat -Target dev build.bat -Target dev
REM 浏览器访问 http://localhost:5173 REM 浏览器访问 http://localhost:3000
``` ```
```bash ```bash
@@ -20,15 +22,14 @@ 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 build.bat REM Windows:先 web_src再 go build → dist/
``` ```
```bash ```bash
@@ -38,15 +39,6 @@ make build # Linux / macOS
## 报告问题 ## 报告问题
在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md) 在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)
- 期望行为与实际行为
1. 复现步骤 - 复现步骤、浏览器 / OS
2. 期望行为 vs 实际行为 - 相关模板或 `routers/web` 路径(若已知)
3. 环境信息系统、浏览器、Go/Node 版本)
4. 截图或日志(如有)
演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/)
已知问题与计划功能见 [ROADMAP.md](ROADMAP.md)。
## 行为准则
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。

View File

@@ -1,4 +1,4 @@
# 姜十三论坛 — 多阶段构建:Node 前端 → Go 单二进制 → Alpine 运行镜像 # 姜十三论坛 — 多阶段构建:web_src → 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,14 +8,12 @@
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/ ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
ARG VERSION=dev ARG VERSION=dev
# ── Stage 1: 前端构建Vite → embed_static/static/spa──────────────────── # ── Stage 1: SSR 渐进资源web_src → public/assets────────────────────────
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS frontend FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS websrc
WORKDIR /src/frontend WORKDIR /src/web_src
COPY frontend/package.json frontend/package-lock.json ./ COPY web_src/package.json ./
RUN npm config set registry https://registry.npmmirror.com \ COPY web_src/ ./
&& npm ci RUN node build.mjs
COPY frontend/ ./
RUN npm run build
# ── Stage 2: Go 编译(纯 Go SQLiteCGO_ENABLED=0──────────────────────── # ── Stage 2: Go 编译(纯 Go SQLiteCGO_ENABLED=0────────────────────────
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
@@ -25,7 +23,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=frontend /src/embed_static/static/spa ./embed_static/static/spa COPY --from=websrc /src/public/assets ./public/assets
RUN CGO_ENABLED=0 go build -trimpath \ 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
View File

@@ -1,6 +1,4 @@
MIT License Copyright (c) 2026 The Jiang13 Authors
Copyright (c) 2026 freefire
Permission is hereby granted, free of charge, to any person obtaining a copy 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
@@ -9,13 +7,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is 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 all The above copyright notice and this permission notice shall be included in
copies or substantial portions of the Software. all 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 THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
SOFTWARE. THE SOFTWARE.

View File

@@ -1,50 +1,50 @@
# 姜十三论坛 Jiang13 Forum - Makefile # 姜十三论坛 Jiang13 Forum - Makefile
# Go 1.26 单二进制编译,与 Gitea 打包方式一致 # Go 1.26 单二进制templates SSR + web_src 渐进资源(本分支无 React SPA
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 := 1.1.6 VERSION := $(shell git rev-parse --short HEAD 2>/dev/null | sed 's/^/1.0.0+/' || echo 1.0.0)
LDFLAGS := -s -w -X main.version=$(VERSION) 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 clean run dev tidy help frontend frontend-build docker compose-up compose-down .PHONY: all build build-windows build-linux build-darwin build-all clean run dev tidy help web-src-build docker compose-up compose-down
all: build all: build
frontend-build: web-src-build:
cd frontend && npm install && npm run build cd web_src && npm run build
## 编译当前平台二进制(纯 Go SQLite无需 CGO ## 编译当前平台二进制(纯 Go SQLite无需 CGO
build: frontend-build build: web-src-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG) CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)" @echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
## Windows amd64(先打包前端再 embed ## Windows amd64
build-windows: frontend-build build-windows: web-src-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe" @echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
## Linux amd64(先打包前端再 embed ## Linux amd64
build-linux: frontend-build build-linux: web-src-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64" @echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
## macOS arm64 (Apple Silicon)(先打包前端再 embed ## macOS arm64 (Apple Silicon)
build-darwin: frontend-build build-darwin: web-src-build
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG) CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64" @echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
## 跨平台全量编译(frontend-build 只跑一次) ## 跨平台全量编译(web_src 只跑一次)
build-all: frontend-build build-all: web-src-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,20 +56,13 @@ build-all: frontend-build
tidy: tidy:
$(GO) mod tidy $(GO) mod tidy
## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致 ## 本地运行 SSR先构建 web_src
run: run: web-src-build
@mkdir -p $(DEV_DATA_DIR) @mkdir -p $(DEV_DATA_DIR)
$(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR) $(GO) run $(MAIN_PKG) --work-path . --data $(DEV_DATA_DIR)
## 前端热更新开发(后端 :3000 + Vite :5173Ctrl+C 同时退出;数据目录与 dist 二进制一致 ## 同 runSPA 对照请 git checkout main
dev: dev: run
@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:
@@ -88,14 +81,15 @@ compose-down:
docker compose down docker compose down
help: help:
@echo "姜十三论坛编译命令:" @echo "姜十三论坛编译命令 (rebuild/gitea-ssr):"
@echo " make build - 编译当前平台" @echo " make web-src-build - 构建 SSR 渐进资源 (web_src)"
@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 - 启动后端:3000" @echo " make run / make dev - 启动 SSR: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"

256
README.md
View File

@@ -5,7 +5,8 @@
**能聊 · 好看 · 好装** **能聊 · 好看 · 好装**
面向小圈子、团队与同好社群的轻量现代化论坛。 面向小圈子、团队与同好社群的轻量现代化论坛。
编译为单个 Go 二进制,前端 SPA单页应用内嵌内置 SQLite拷到服务器即可运行。 本分支(`rebuild/gitea-ssr`Go 模板真 SSR + `web_src` 渐进增强,单二进制 + SQLite。
对照 React SPA 请见 `main` 分支。
<br> <br>
@@ -13,14 +14,13 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/Docker-hangzhang714128%2Fjiang13--forum-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/hangzhang714128/jiang13-forum) [![Docker](https://img.shields.io/badge/Docker-hangzhang714128%2Fjiang13--forum-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/hangzhang714128/jiang13-forum)
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod)
[![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json) [![SSR](https://img.shields.io/badge/SSR-Go_html%2Ftemplate-00ADD8?style=flat-square&logo=go&logoColor=white)](docs/rebuild-spec/08-gitea-ssr-architecture.md)
[![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#) [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#)
[在线演示](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/) > **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)(现网多为 `main` SPA
> 项目积极开发中。管理后台已统一为 React SPA`/admin`欢迎提 Issue / PR 共建。 > 本分支按 [Gitea 式 SSR 规格](docs/rebuild-spec/08-gitea-ssr-architecture.md) 重构;欢迎提 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>TipTap 排版 · 图片 · 代码高亮 · 目录导航</sub> <sub>Markdown 排版 · 图片 · 代码 · 目录导航</sub>
</td> </td>
</tr> </tr>
</table> </table>
@@ -96,26 +96,26 @@
| 特性 | 说明 | | 特性 | 说明 |
|------|------| |------|------|
| **三栏布局** | 左栏板块导航 + 中间虚拟滚动帖列表 + 右栏热门 / 标签 / 最新评论 | | **三栏布局** | 左栏板块导航 + 中间帖列表 + 右栏热门 / 标签 / 最新评论 |
| **虚拟滚动** | `@tanstack/react-virtual` 驱动长列表,浏览依然流畅 | | **真 SSR** | 公开页服务端渲染完整 HTMLGo `html/template` |
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 | | **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
| **主题切换** | 浅色 / 暗色跟随系统偏好并本地记忆 | | **主题切换** | 浅色 / 暗色 / 跟随系统`localStorage` |
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 | | **响应式** | ≤900px 隐藏右栏;搜索、发帖、登录触手可及 |
### 社区功能 ### 社区功能
- 用户注册 / 登录bcrypt + JWT Cookie**首个注册用户自动成为管理员** - 安装向导创建管理员;用户注册 / 登录bcrypt + 会话 Cookie
- 板块、发帖、TipTap 富文本、正文图片上传、标签、置顶 / 精华 - 板块、发帖、Markdown 工具栏、正文图片上传、标签、置顶 / 精华
- 帖子修订历史与 diff差异对比;可配置普通用户编辑时限 - 帖子修订历史;可配置普通用户编辑时限
- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控 - 楼层式评论:回复、嵌套树、内容门控(登录/回复/积分可见)
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页 - 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份 - SSR 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
- 可选邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储 - 可选邮件验证码、OIDC Provider、S3 兼容对象存储Gitea 仓库同步后置)
### 部署体验 ### 部署体验
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源 - **单二进制** — `go:embed` 打包模板与 SSR 资源
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理 - **可切换数据库** — 默认 SQLite;可选 PostgreSQL / MySQLEnv 引导)
- **跨平台** — 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 frontend && npm install && npm run build cd web_src && 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,12 +171,7 @@ docker compose up -d --build
make compose-up make compose-up
``` ```
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员** 浏览器打开 `http://localhost:3000/install` 完成安装向导(站点名 + 管理员
版本变更与升级说明见 **[更新日志](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**
@@ -189,7 +184,7 @@ docker run -d --name jiang13 \
hangzhang714128/jiang13-forum:latest hangzhang714128/jiang13-forum:latest
``` ```
**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000``jiang13` 用户),适配 1Panel 等面板挂载的目录 **数据持久化:** 容器内 `/data` 对应 SQLite(默认)、上传、日志与 JWT/OIDC 密钥。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000``jiang13` 用户)。
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录` **若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
@@ -199,10 +194,11 @@ docker run -d --name jiang13 \
|------|------| |------|------|
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000` | | `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000` |
| `JIANG13_DATA` | 数据目录(默认 `/data` | | `JIANG13_DATA` | 数据目录(默认 `/data` |
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret` | | `JIANG13_DB_TYPE` | `sqlite`(默认)\| `postgres` \| `mysql` |
| `JIANG13_CONFIG` | 配置文件路径 | | `JIANG13_DB_DSN` | 完整 DSN非 sqlite 时推荐) |
| `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 / 负载均衡探活。
@@ -212,15 +208,15 @@ docker run -d --name jiang13 \
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.1.6 docker push hangzhang714128/jiang13-forum:1.0.0
docker push hangzhang714128/jiang13-forum:latest docker push hangzhang714128/jiang13-forum:latest
``` ```
或直接构建: 或直接构建:
```bash ```bash
docker build --build-arg VERSION=1.1.6 -t hangzhang714128/jiang13-forum:1.1.6 -t hangzhang714128/jiang13-forum:latest . docker build --build-arg VERSION=1.0.0 -t hangzhang714128/jiang13-forum:1.0.0 -t hangzhang714128/jiang13-forum:latest .
docker push hangzhang714128/jiang13-forum:1.1.6 docker push hangzhang714128/jiang13-forum:1.0.0
docker push hangzhang714128/jiang13-forum:latest docker push hangzhang714128/jiang13-forum:latest
``` ```
@@ -241,112 +237,72 @@ 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/register` 注册管理员 4. 首次访问 `http://服务器IP:3000/install` 完成安装
### 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.6** [Windows](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.6/jiang13-1.1.6-windows-amd64.exe) · [Linux](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.6/jiang13-1.1.6-linux-amd64)
把文件放到目标目录后直接运行(首次会在同目录生成 `app.ini`
```bash ```bash
# Windows # Windows
.\jiang13-1.1.6-windows-amd64.exe .\dist\jiang13.exe --data .\dist\data
# 或改名为 jiang13.exe 后:
.\jiang13.exe
# Linux
chmod +x jiang13-1.1.6-linux-amd64
./jiang13-1.1.6-linux-amd64
```
本地刚编译的产物在 `dist/`
```bash
# Windows
.\dist\jiang13.exe
# Linux / macOS # Linux / macOS
./dist/jiang13 ./dist/jiang13 --data ./data
``` ```
也可先复制示例配置再改端口 / 数据目录: 默认 SQLite库文件在 `{DATA}/jiang13.db`。无 `app.ini`
```bash
cp app.ini.example /opt/jiang13/app.ini
# 编辑 app.ini 后:
./jiang13
```
### 4. 首次使用 ### 4. 首次使用
1. 浏览器打开 `http://localhost:3000/register` 注册账号 1. 浏览器打开 `http://localhost:3000/install`
2. **第一个注册的用户自动成为管理员** 2. 填写站点名与管理员账号
3. 登录访问 `http://localhost:3000/admin` 进入后台 3. 完成后登录访问管理后台配置品牌等(热更新,无需重启)
### 配置文件(`app.ini` ### 配置分层(无 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` | 可执行文件目录 | 工作目录`app.ini` 与相对 `DATA` 的基准) | | `--work-path` | 可执行文件目录 | 工作目录 |
| `--config` | `{work-path}/app.ini` | 配置文件路径 | | `--port` | `3000` | HTTP 监听端口 |
| `--port` | (读配置 / `3000` | HTTP 监听端口 | | `--http-addr` | (空) | 监听地址 |
| `--data` | (读配置 / `data` | 数据目录 | | `--data` | `data` | 数据目录 |
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` | | `--db-type` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
| `--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_DATA``JIANG13_JWT_SECRET``JIANG13_CONFIG``JIANG13_WORK_PATH``JIANG13_COMMUNITY_HUB`(非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表) **环境变量:** `JIANG13_HTTP_PORT``JIANG13_HTTP_ADDR``JIANG13_DATA``JIANG13_WORK_PATH``JIANG13_DB_TYPE``JIANG13_DB_DSN`、以及 `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE`
PostgreSQL / MySQL 示例见 [`docs/rebuild-spec/07-config-ops.md`](docs/rebuild-spec/07-config-ops.md)。
### 5. 注册为系统服务(可选) ### 5. 注册为系统服务(可选)
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
**Ubuntu / Linuxsystemd需 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 --service install sudo /opt/jiang13/jiang13 --work-path /opt/jiang13 --data /opt/jiang13/data --service install
sudo /opt/jiang13/jiang13 --service start sudo /opt/jiang13/jiang13 --service start
sudo systemctl enable jiang13 sudo systemctl enable jiang13
``` ```
**WindowsWindows Service管理员 PowerShell** **Windows管理员 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 --service install C:\jiang13\jiang13.exe --work-path C:\jiang13 --data C:\jiang13\data --service install
C:\jiang13\jiang13.exe --service start C:\jiang13\jiang13.exe --service start
``` ```
> `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。 > 端口或 `DB_*` 后执行 `--service restart`(必要时重装服务以更新参数)。日志:`data/jiang13.log`。
--- ---
@@ -354,95 +310,73 @@ C:\jiang13\jiang13.exe --service start
| 层级 | 技术 | | 层级 | 技术 |
|------|------| |------|------|
| **后端** | Go 1.26 · Gin · GORM · SQLite | | **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite / PostgreSQL / MySQL · `html/template` |
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual | | **渐进资源** | `web_src/`(构建到 `public/assets/`URL `/ssr-assets/` |
| **构建** | Vite`go:embed` 内嵌 SPA,单二进制发布 | | **构建** | `web_src``go:embed` templates + assets,单二进制发布 |
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider | | **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
| **对照 SPA** | 仅 `main` 分支React 18 · TipTap · Vite |
--- ---
## 前端开发 ## 本地开发SSR
日常改前端不需要重新完整构建Vite 支持秒级热更新HMR热模块替换
```bat ```bat
build.bat -Target dev build.bat -Target run
``` ```
```bash ```bash
make dev make run
``` ```
浏览器访问 `http://localhost:5173`API 自动代理到 `http://localhost:3000` 浏览器访问 `http://localhost:3000`。数据目录默认 `dist/data`
开发后端与 `dist/jiang13` 共用数据目录 `dist/data`SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致 改模板 / Go 后重启进程;改 `web_src` 后需再跑 `build.bat -Target web-src`(或完整 `build`
**何时需要完整构建:** 需要对照旧 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/ jiang13-forum/ # 分支 rebuild/gitea-ssr
├── cmd/jiang13/ # 程序入口(含系统服务注册) ├── cmd/jiang13/ # 程序入口(含系统服务注册)
├── config/ # app.ini 与命令行配置 ├── config/ # CLI / Env 引导配置(无 INI
├── app.ini.example # 配置文件示例 ├── Dockerfile # web_src → Go → Alpine
├── Dockerfile # 多阶段 Docker 构建 ├── docker-compose.yml
├── docker-compose.yml # 单容器 Compose 部署 ├── models/ # GORM 模型
├── docker-entrypoint.sh # 容器启动脚本(修正 /data 卷权限) ├── services/ # 业务逻辑
├── .dockerignore ├── routers/
├── model/ # GORM 模型与数据库迁移 │ ├── setup.go # 路由总装
├── service/ # 业务逻辑 │ ├── web/ # HTML SSR
├── handler/ # HTTP 处理器(前台 + 后台 │ └── api/ # 机器入口health / OIDC / SEO / thumb
├── middleware/ # JWT 鉴权等 ├── modules/
├── router/ # 路由注册 │ ├── auth/ # JWT / 限流
├── embed_static/ # go:embed 内嵌的 SPA │ ├── webrender/ # 模板渲染
├── frontend/ # React 源码Vite 构建) │ └── seo/
├── docs/screenshots/ # README 界面截图 ├── templates/ # Go html/templateembed
├── ROADMAP.md # 路线图与已知问题 ├── web_src/ # 渐进 CSS/JS 源码
── scripts/ # 开发辅助脚本(含截图 ── public/assets/ # web_src 构建产物embed
├── 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 # 后台导出的备份
``` ```
--- ---
@@ -453,10 +387,10 @@ data/
| 类型 | 示例 | | 类型 | 示例 |
|------|------| |------|------|
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 | | ✅ 已可用 | 三栏布局、主题、Feed 排序、楼层评论、嵌套树 |
| ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 | | ✅ 发帖体验 | Markdown 工具栏、图片上传、修订历史、内容门控 |
| ✅ 管理后台 | React SPA仪表盘、置顶 / 精华、禁言、系统设置 | | ✅ 管理后台 | SSR `/admin/*`(对照 SPA 见 `main` |
| 📋 计划中 | 通知动态优化、邮件提醒 | | 📋 计划中 | 见 [ROADMAP.md](ROADMAP.md) / [09-ssr-progress.md](docs/rebuild-spec/09-ssr-progress.md) |
--- ---
@@ -470,4 +404,4 @@ data/
## 许可证 ## 许可证
[MIT](LICENSE) — 自由使用、修改与分发。 [MIT](LICENSE)(与 [Gitea](https://github.com/go-gitea/gitea) 相同的 Expat 文本格式)— 自由使用、修改与分发。

View File

@@ -1,7 +1,8 @@
# 路线图 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 对照。
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷 **图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
@@ -11,10 +12,14 @@
| 模块 | 状态 | 说明 | | 模块 | 状态 | 说明 |
|------|------|------| |------|------|------|
| 前台 SPAReact | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 | | 公开页 SSR | ✅ | Go `html/template`;首页 / 板块 / 帖详情 / 用户 / 消息等 |
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 | | 管理后台 SSR | ✅ | `/admin/*` 表单;对照 SPA 见 `main` |
| 评论系统 | ✅ | 换行显示已修复 | | 评论系统 | ✅ | 楼层 + 嵌套树(`ThreadParentID` |
| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO管理后台配置 ROOT_URL 与 OAuth 应用 | | Markdown 编辑 | ✅ | 工具栏 + `/compose/preview`(非 TipTap |
| 主题 | ✅ | 浅色 / 暗色 / 跟随系统 |
| OIDC Provider | ✅ | Discovery / Authorize / Token / UserInfoAdmin 配置面可继续打磨 |
细节进度见 [`docs/rebuild-spec/09-ssr-progress.md`](docs/rebuild-spec/09-ssr-progress.md)。
--- ---
@@ -28,48 +33,20 @@ _当前无已记录缺陷。发现新问题请在本仓库提交 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 SSODiscovery / 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_

View File

@@ -1,25 +0,0 @@
; 姜十三论坛 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

View File

@@ -1,9 +1,10 @@
# 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', 'frontend', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')] [ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'web-src', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
[string]$Target = 'build' [string]$Target = 'build'
) )
@@ -12,7 +13,13 @@ $AppName = 'jiang13'
$MainPkg = './cmd/jiang13' $MainPkg = './cmd/jiang13'
$BuildDir = 'dist' $BuildDir = 'dist'
$DevDataDir = 'dist/data' $DevDataDir = 'dist/data'
$Version = '1.1.6' $Version = '1.0.0'
try {
$gitSha = (git rev-parse --short HEAD 2>$null)
if ($LASTEXITCODE -eq 0 -and $gitSha) {
$Version = "1.0.0+$gitSha"
}
} catch {}
$RegistryImage = 'hangzhang714128/jiang13-forum' $RegistryImage = 'hangzhang714128/jiang13-forum'
$Ldlags = "-s -w -X main.version=$Version" $Ldlags = "-s -w -X main.version=$Version"
@@ -22,15 +29,12 @@ function Ensure-Dir($path) {
} }
} }
function Build-Frontend { function Build-WebSrc {
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan Write-Host '[web_src] npm run build...' -ForegroundColor Cyan
Push-Location frontend Push-Location web_src
try { try {
if (-not (Test-Path node_modules)) {
npm install
}
npm run build npm run build
if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' } if ($LASTEXITCODE -ne 0) { throw 'web_src build failed' }
} finally { } finally {
Pop-Location Pop-Location
} }
@@ -69,22 +73,23 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
switch ($Target) { switch ($Target) {
'help' { 'help' {
Write-Host '.\build.ps1 build current platform' Write-Host '.\build.ps1 build current platform (web_src + go)'
Write-Host '.\build.ps1 -Target frontend frontend only' Write-Host '.\build.ps1 -Target web-src SSR progressive assets 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 backend only (port 3000)' Write-Host '.\build.ps1 -Target run SSR on :3000'
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)' Write-Host '.\build.ps1 -Target dev same as run (SSR; SPA is on main)'
Write-Host '.\build.ps1 -Target tidy' Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean' Write-Host '.\build.ps1 -Target clean'
Write-Host '.\build.ps1 -Target docker build Docker image' Write-Host '.\build.ps1 -Target docker'
Write-Host '.\build.ps1 -Target compose-up docker compose up -d --build' Write-Host '.\build.ps1 -Target compose-up'
Write-Host '.\build.ps1 -Target compose-down docker compose down' Write-Host '.\build.ps1 -Target 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).'
} }
'frontend' { Build-Frontend } 'web-src' { Build-WebSrc }
'tidy' { go mod tidy } 'tidy' { go mod tidy }
'clean' { 'clean' {
if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir } if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
@@ -92,49 +97,33 @@ switch ($Target) {
} }
'run' { 'run' {
Ensure-Dir $DevDataDir Ensure-Dir $DevDataDir
go run $MainPkg --data $DevDataDir Build-WebSrc
go run $MainPkg --work-path . --data $DevDataDir
} }
'dev' { 'dev' {
$root = (Get-Location).Path
Ensure-Dir $DevDataDir Ensure-Dir $DevDataDir
Write-Host '' Build-WebSrc
Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green Write-Host '[dev] SSR: http://localhost:3000 (SPA 对照请 checkout main)' -ForegroundColor Green
Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green go run $MainPkg --work-path . --data $DevDataDir
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-Frontend Build-WebSrc
Build-Go -OutFile $AppName Build-Go -OutFile $AppName
} }
'build-windows' { 'build-windows' {
Build-Frontend Build-WebSrc
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64' Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
} }
'build-linux' { 'build-linux' {
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow Build-WebSrc
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-Frontend Build-WebSrc
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-Frontend Build-WebSrc
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'
@@ -148,12 +137,10 @@ switch ($Target) {
} }
'compose-up' { 'compose-up' {
docker compose up -d --build docker compose up -d --build
if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' } if ($LASTEXITCODE -ne 0) { throw '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 'docker compose down failed' } if ($LASTEXITCODE -ne 0) { throw 'compose down failed' }
Write-Host '[ok] compose stopped' -ForegroundColor Green
} }
} }

View File

@@ -1,21 +1,19 @@
package main package main
import ( import (
"fmt" "fmt"
"log" "log"
"os" "os"
"github.com/kardianos/service" kardsvc "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)
@@ -27,7 +25,7 @@ func main() {
} }
prg := &program{cfg: cfg} prg := &program{cfg: cfg}
svc, err := service.New(prg, svcCfg) svc, err := kardsvc.New(prg, svcCfg)
if err != nil { if err != nil {
log.Fatalf("创建系统服务失败: %v", err) log.Fatalf("创建系统服务失败: %v", err)
} }

View File

@@ -1,4 +1,4 @@
package main package main
import ( import (
"context" "context"
@@ -9,11 +9,11 @@ import (
"os" "os"
"time" "time"
"github.com/kardianos/service" kardsvc "github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/router" "git.iioio.com/freefire/jiang13-forum/routers"
) )
const ( const (
@@ -28,7 +28,7 @@ type program struct {
server *http.Server server *http.Server
} }
func (p *program) Start(s service.Service) error { func (p *program) Start(s kardsvc.Service) error {
if err := p.setup(); err != nil { if err := p.setup(); err != nil {
return err return err
} }
@@ -40,7 +40,7 @@ func (p *program) Start(s service.Service) error {
return nil return nil
} }
func (p *program) Stop(s service.Service) error { func (p *program) Stop(s kardsvc.Service) error {
log.Println("收到关机信号,正在优雅关闭...") log.Println("收到关机信号,正在优雅关闭...")
if p.server == nil { if p.server == nil {
return nil return nil
@@ -57,13 +57,14 @@ func (p *program) Stop(s service.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 service.Interactive() { if kardsvc.Interactive() {
log.SetOutput(io.MultiWriter(os.Stdout, logFile)) log.SetOutput(io.MultiWriter(os.Stdout, logFile))
} else { } else {
log.SetOutput(logFile) log.SetOutput(logFile)
@@ -75,44 +76,56 @@ func (p *program) setup() error {
log.Printf(" 版本: %s", version) log.Printf(" 版本: %s", version)
log.Println("========================================") log.Println("========================================")
if err := model.InitDB(cfg.DBPath()); err != nil { if err := models.InitDB(models.DatabaseConfig{
Type: cfg.DB.Type,
DSN: cfg.DB.DSN,
SQLitePath: cfg.DB.SQLitePath,
MaxOpenConns: cfg.DB.MaxOpenConns,
MaxIdleConns: cfg.DB.MaxIdleConns,
ConnMaxLifetimeSec: cfg.DB.ConnMaxLifetimeSec,
}); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err) return fmt.Errorf("数据库初始化失败: %w", err)
} }
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
return fmt.Errorf("监控库初始化失败: %w", err)
}
engine, err := router.Setup(cfg) engine, err := routers.Setup(cfg)
if err != nil { if err != nil {
return fmt.Errorf("路由初始化失败: %w", err) return fmt.Errorf("路由初始化失败: %w", err)
} }
addr := fmt.Sprintf(":%d", cfg.Port) addr := cfg.ListenAddr()
p.server = &http.Server{ p.server = &http.Server{
Addr: addr, Addr: addr,
Handler: engine, Handler: engine,
} }
log.Printf("姜十三论坛已启动: http://localhost%s", addr) log.Printf("姜十三论坛已启动: http://localhost:%d", cfg.Port)
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr) log.Printf("后台管理地址: http://localhost:%d/admin/dashboard", cfg.Port)
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) (*service.Config, error) { func buildServiceConfig(cfg *config.Config) (*kardsvc.Config, error) {
// 服务绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务 // 服务绑定工作目录与数据目录;改端口 / DB_* 需重启进程(可用 Env 或重装服务参数)
return &service.Config{ args := []string{
"--work-path", cfg.WorkPath,
"--data", cfg.DataDir,
"--port", fmt.Sprintf("%d", cfg.Port),
"--db-type", cfg.DB.Type,
}
if cfg.DB.Type == config.DBTypeSQLite {
args = append(args, "--db-dsn", cfg.DB.SQLitePath)
} else if cfg.DB.DSN != "" {
args = append(args, "--db-dsn", cfg.DB.DSN)
}
return &kardsvc.Config{
Name: svcName, Name: svcName,
DisplayName: svcDisplayName, DisplayName: svcDisplayName,
Description: svcDescription, Description: svcDescription,
WorkingDirectory: cfg.WorkPath, WorkingDirectory: cfg.WorkPath,
Arguments: []string{ Arguments: args,
"--work-path", cfg.WorkPath, Option: kardsvc.KeyValue{
"--config", cfg.ConfigFile,
},
Option: service.KeyValue{
// systemd异常退出后自动拉起 // systemd异常退出后自动拉起
"Restart": "always", "Restart": "always",
// Windows崩溃后重启 // Windows崩溃后重启
@@ -121,16 +134,16 @@ func buildServiceConfig(cfg *config.Config) (*service.Config, error) {
}, nil }, nil
} }
func runServiceControl(s service.Service, action string) error { func runServiceControl(s kardsvc.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 service.StatusRunning: case kardsvc.StatusRunning:
fmt.Println("服务状态: 运行中 (running)") fmt.Println("服务状态: 运行中 (running)")
case service.StatusStopped: case kardsvc.StatusStopped:
fmt.Println("服务状态: 已停止 (stopped)") fmt.Println("服务状态: 已停止 (stopped)")
default: default:
fmt.Println("服务状态: 未知 (unknown)") fmt.Println("服务状态: 未知 (unknown)")
@@ -138,7 +151,7 @@ func runServiceControl(s service.Service, action string) error {
return nil return nil
} }
if err := service.Control(s, action); err != nil { if err := kardsvc.Control(s, action); err != nil {
return err return err
} }

View File

@@ -1,410 +0,0 @@
// 只读诊断:检查 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 日省级 Topcountry / 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(&regions).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)
}

View File

@@ -1,53 +1,79 @@
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"
) )
// Config 应用全局配置:默认读工作目录下 app.ini命令行可覆盖 // DatabaseConfig 数据库引导配置(需重启)
type Config struct { type DatabaseConfig struct {
// 工作目录(默认可执行文件所在目录) Type string // sqlite | postgres | mysql
WorkPath string DSN string // 非空则优先
// 配置文件绝对路径 Host string
ConfigFile string User string
// 监听端口 Password string
Port int Name string
// 数据目录SQLite、上传、日志绝对路径 SSLMode string // postgres
DataDir string // SQLite 文件路径Type=sqlite 时由 DataDir 推导或 DSN
// JWT 签名密钥 SQLitePath string
JWTSecret string
// 日志文件路径 MaxOpenConns int
LogFile string MaxIdleConns int
// 系统服务控制动作install|uninstall|start|stop|restart|status空表示正常运行 ConnMaxLifetimeSec int
ServiceAction string
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
DevMode bool
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
CommunityHub bool
} }
// Parse 解析命令行、环境变量与 app.ini并初始化数据目录 // Config 进程引导配置:仅 CLI / 环境变量(无 INI
// type Config struct {
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值 WorkPath string
HTTPAddr string // 空表示 0.0.0.0
Port int
DataDir string
JWTSecret string
LogFile string
ServiceAction string
DevMode bool
Version string // 构建版本ldflags用于 /ssr-assets ?v= 缓存穿透
DB DatabaseConfig
}
// Parse 解析命令行与环境变量并准备数据目录
// 优先级:命令行显式参数 > 环境变量 > 内置默认
func Parse() (*Config, error) { 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 表示不覆盖") portFlag := flag.Int("port", 0, "HTTP 监听端口(0 表示用环境变量或默认 3000")
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件") addrFlag := flag.String("http-addr", "", "HTTP 监听地址(默认空=全接口")
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)") dataFlag := flag.String("data", "", "数据存储目录")
dbTypeFlag := flag.String("db-type", "", "数据库类型sqlite|postgres|mysql")
dbDSNFlag := flag.String("db-dsn", "", "数据库 DSN优先于拆分参数")
dbHostFlag := flag.String("db-host", "", "数据库主机")
dbUserFlag := flag.String("db-user", "", "数据库用户")
dbPassFlag := flag.String("db-pass", "", "数据库密码")
dbNameFlag := flag.String("db-name", "", "数据库名")
dbSSLFlag := flag.String("db-sslmode", "", "PostgreSQL sslmode")
serviceFlag := flag.String("service", "", "系统服务控制install|uninstall|start|stop|restart|status") serviceFlag := flag.String("service", "", "系统服务控制install|uninstall|start|stop|restart|status")
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173") devFlag := flag.Bool("dev", false, "开发模式")
_ = 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))
@@ -55,35 +81,13 @@ 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 := strings.TrimSpace(*workFlag) workPathInput := firstNonEmpty(*workFlag, envOrDefault(envWorkPath))
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
} }
configInput := strings.TrimSpace(*configFlag) port := defaultPort
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
} }
@@ -91,71 +95,35 @@ func Parse() (*Config, error) {
port = *portFlag port = *portFlag
} }
dataInput := fileCfg.DataRel httpAddr := firstNonEmpty(*addrFlag, envOrDefault(envHTTPAddr))
if v := envOrDefault(envData); v != "" {
dataInput = v dataInput := firstNonEmpty(*dataFlag, envOrDefault(envData), defaultDataRel)
}
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)
} }
jwtSecret := fileCfg.JWTSecret dbCfg, err := buildDatabaseConfig(absData, dbFlags{
if v := envOrDefault(envJWTSecret); v != "" { Type: *dbTypeFlag, DSN: *dbDSNFlag, Host: *dbHostFlag,
jwtSecret = v User: *dbUserFlag, Pass: *dbPassFlag, Name: *dbNameFlag, SSL: *dbSSLFlag,
} })
if strings.TrimSpace(*jwtFlag) != "" { if err != nil {
jwtSecret = strings.TrimSpace(*jwtFlag) return nil, err
}
communityHub := fileCfg.CommunityHub
if v := envBoolOrNil(envCommunityHub); v != nil {
communityHub = *v
} }
cfg := &Config{ cfg := &Config{
WorkPath: workPath, WorkPath: workPath,
ConfigFile: configFile, HTTPAddr: httpAddr,
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,
CommunityHub: communityHub, DB: dbCfg,
} }
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
} }
@@ -167,6 +135,96 @@ 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)
@@ -178,13 +236,6 @@ 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)
@@ -193,7 +244,6 @@ 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)
@@ -204,21 +254,25 @@ 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 c.JWTSecret != "" { if data, err := os.ReadFile(secretFile); err == nil && len(bytesTrimSpace(data)) > 0 {
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600) c.JWTSecret = string(bytesTrimSpace(data))
return nil return nil
} }
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 { sec, err := generateRandomSecret(32)
c.JWTSecret = string(data) if err != nil {
return nil return err
} }
c.JWTSecret = generateRandomSecret(32) c.JWTSecret = sec
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":
@@ -228,41 +282,60 @@ func validServiceAction(action string) bool {
} }
} }
// DBPath 返回主库 SQLite 路径 // ListenAddr 返回 host:port
func (c *Config) ListenAddr() string {
if c.HTTPAddr == "" {
return fmt.Sprintf(":%d", c.Port)
}
return fmt.Sprintf("%s:%d", c.HTTPAddr, c.Port)
}
// SQLitePath 兼容旧调用:仅 sqlite 有意义
func (c *Config) DBPath() string { func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db") if c.DB.Type == DBTypeSQLite {
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 { func generateRandomSecret(n int) (string, error) {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n) b := make([]byte, n)
for i := range b { if _, err := rand.Read(b); err != nil {
b[i] = chars[i%len(chars)] return "", fmt.Errorf("生成密钥失败: %w", err)
} }
return string(b) return base64.RawURLEncoding.EncodeToString(b), nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func envIntDefault(key string, def int) int {
v := envOrDefault(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return def
}
return n
} }

View File

@@ -6,14 +6,22 @@ import (
"strings" "strings"
) )
// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认 // 引导环境变量(无 INI
const ( const (
envWorkPath = "JIANG13_WORK_PATH" envWorkPath = "JIANG13_WORK_PATH"
envConfig = "JIANG13_CONFIG" envHTTPPort = "JIANG13_HTTP_PORT"
envHTTPPort = "JIANG13_HTTP_PORT" envHTTPAddr = "JIANG13_HTTP_ADDR"
envData = "JIANG13_DATA" envData = "JIANG13_DATA"
envJWTSecret = "JIANG13_JWT_SECRET" envDBType = "JIANG13_DB_TYPE"
envCommunityHub = "JIANG13_COMMUNITY_HUB" envDBDSN = "JIANG13_DB_DSN"
envDBHost = "JIANG13_DB_HOST"
envDBUser = "JIANG13_DB_USER"
envDBPass = "JIANG13_DB_PASS"
envDBName = "JIANG13_DB_NAME"
envDBSSLMode = "JIANG13_DB_SSLMODE"
envDBMaxOpen = "JIANG13_DB_MAX_OPEN"
envDBMaxIdle = "JIANG13_DB_MAX_IDLE"
envDBConnLife = "JIANG13_DB_CONN_MAX_LIFETIME_SEC"
) )
func envOrDefault(key string) string { func envOrDefault(key string) string {
@@ -31,21 +39,3 @@ 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
}
}

View File

@@ -1,114 +0,0 @@
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)
}

View File

@@ -1,6 +1,12 @@
# 姜十三论坛 — 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:
@@ -14,12 +20,10 @@ 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
# 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret # JIANG13_DB_TYPE: sqlite
# JIANG13_JWT_SECRET: your-secret-here # JIANG13_HTTP_PORT: "3000"
restart: unless-stopped restart: unless-stopped
stop_grace_period: 15s stop_grace_period: 15s
healthcheck: healthcheck:

View File

@@ -1,105 +0,0 @@
# 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 里假装有两个独立数据根。

View File

@@ -11,7 +11,7 @@
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。 它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:前端 SPA单页应用通过 `go:embed` 内嵌,数据库用内置 **SQLite**,拷贝到服务器就能跑——没有复杂的中间件矩阵,也没有「先装一堆依赖再祈祷能起来」的仪式感 技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:公开页用 **Go `html/template` 真 SSR**`web_src` 做渐进增强 CSS/JS`go:embed`,数据库默认 **SQLite**,拷贝到服务器就能跑。
--- ---
@@ -60,24 +60,24 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。 整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
### 发帖:富文本,够用就好 ### 发帖:Markdown,够用就好
发帖使用 **TipTap** 富文本编辑器:标题、排版、标签、板块选择、正文图片本地上传,日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**,可做 diff差异对比。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。 发帖使用 **Markdown 工具栏**(标题、粗斜体、列表、代码块、图片上传、内容门控插入)与服务端预览;标签、板块选择日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
### 讨论:楼层回复,聊得清楚 ### 讨论:楼层回复,聊得清楚
评论是楼层式的:可回复指定楼层、引用原文、@ 高亮。点赞、收藏、热门帖,把活跃内容自然推到前面。 评论是楼层式的:可回复指定楼层、引用原文;支持嵌套展示。点赞、收藏、热门帖,把活跃内容自然推到前面。
### 管理:后台也是 SPA ### 管理:SSR 后台
管理后台统一在 `/admin`,与前台同一套 React 体验 管理后台统一在 `/admin`,与公开页同一套模板布局
- 仪表盘、板块与帖子管理 - 仪表盘、板块与帖子管理
- 用户禁言、删帖删评 - 用户禁言、删帖删评
- 论坛参数、限流、敏感词 - 论坛参数、限流、敏感词
- SQLite **一键备份** - SQLite **一键备份**
权限模型很简单:**普通用户 / 管理员**站点 **第一个注册用户自动成为管理员**,省去安装向导里的一堆步骤 权限模型很简单:**普通用户 / 管理员**首次通过 **安装向导** 创建管理员账号
--- ---
@@ -87,19 +87,19 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
```text ```text
编译 → 得到一个 jiang13或 jiang13.exe 编译 → 得到一个 jiang13或 jiang13.exe
放到目录里运行 → 自动生成 app.ini 放到目录里运行 → Env / CLI 引导(默认 SQLite
打开浏览器注册 → 第一个账号就是管理员 打开浏览器完成安装向导 → 创建管理员
``` ```
要点: 要点:
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件; - **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
- **零外部数据库**SQLite 落在数据目录,备份就是拷贝文件; - **零外部数据库**SQLite 落在数据目录,备份就是拷贝文件;
- **app.ini 配置**:风格类似 Gitea端口数据目录一眼能改;业务项在管理后台配置 - **Env / CLI 引导**端口数据目录、数据库类型;业务项在管理后台热更新
- **系统服务**:内置 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 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
| 层级 | 技术 | | 层级 | 技术 |
|------|------| |------|------|
| 后端 | Go · Gin · GORM · SQLite | | 后端 / SSR | Go · Gin · GORM · SQLite · `html/template` |
| 前端 | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual | | 渐进资源 | `web_src``public/assets``/ssr-assets/` |
| 构建 | Vite 构建 SPA再由 `go:embed` 打进二进制 | | 构建 | `web_src` + `go:embed` templates/assets二进制 |
| 认证 | bcrypt + JWT Cookie可选 OIDC Provider对接 Gitea 等 SSO | | 认证 | bcrypt + 会话 Cookie可选 OIDC Provider对接 Gitea 等 SSO |
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:前后端分离开发,`dev` 模式下 Vite HMR热模块替换可秒级预览前端改动 对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:改模板/Go 后重启进程;改 `web_src` 后跑 `build.bat -Target web-src`
--- ---
## 现在走到哪一步 ## 现在走到哪一步
项目仍在积极开发中,核心体验已经可用: 项目仍在积极开发中,核心体验已经可用(分支 `rebuild/gitea-ssr`
- ✅ 三栏布局、主题切换、虚拟滚动、Feed 排序 - ✅ 三栏布局、主题切换、Feed 排序、真 SSR 公开页
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史 - ✅ 发帖 / 评论 / 点赞收藏 / 修订历史Markdown 编辑器)
-React 管理后台与论坛参数配置 -SSR 管理后台与论坛参数配置
- ✅ OIDC Provider可作 Gitea 等站点的登录源) - ✅ OIDC Provider可作 Gitea 等站点的登录源)
- ✅ 单二进制部署与系统服务 - ✅ 单二进制部署与系统服务
计划中的方向包括通知动态优化、搜索增强、邮件提醒等——完整列表见仓库 [ROADMAP.md](../ROADMAP.md)。 计划中的方向见仓库 [ROADMAP.md](../ROADMAP.md) 与 [09-ssr-progress.md](rebuild-spec/09-ssr-progress.md)
--- ---

View File

@@ -1,12 +1,12 @@
# Issue 预填模板 # Issue 预填模板
以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板 以下可直接复制到本仓库 Issues 创建。本分支为 **SSR**`rebuild/gitea-ssr`);对照 SPA 请注明 `main`
--- ---
## Issue #1 · 评论回复换行不显示 ## Issue · Bug 模板
**标题:** `[Bug] 评论回复换行不显示` **标题:** `[Bug] 简短描述`
**标签:** `bug` `ui/ux` **标签:** `bug` `ui/ux`
@@ -14,79 +14,52 @@
### 问题描述 ### 问题描述
在帖子详情页的评论框中输入多行文字(按 Enter 换行),提交后评论展示区域不保留换行,所有文字合并为一行。 (一句话)
### 复现步骤 ### 复现步骤
1. 打开任意帖子详情页(如 `/post/2` 1.
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` 或其他处理误删换行(待排查)
### 环境 ### 环境
- 前台React SPA`:3000` 嵌入版或 `:5173` 开发版 - 分支:`rebuild/gitea-ssr`(或 `main` SPA
- 浏览器Chrome / Edge 最新版 - 浏览器 / OS
--- ---
## Issue #2 · 示例:管理能力扩展(模板文案) ## Issue · 功能增强模板
**标题:** `[Feature] 管理后台增加某某能力` **标题:** `[Feature] 简短描述`
**标签:** `enhancement` `ui/ux` `good first issue` **标签:** `enhancement`
**正文:** **正文:**
### 要解决的问题 ### 要解决的问题
描述管理员在 React SPA 管理后台 / 前台中缺少的操作入口或能力。
### 现状 ### 现状
| 能力 | 状态 | | 能力 | 状态 |
| --- | --- | | --- | --- |
| 数据模型与业务逻辑 | ✅ / ❌ | | 规格(`docs/rebuild-spec` | ✅ / ❌ |
| JSON API如 `POST /api/admin/...` | ✅ / ❌ | | SSR 页面 / 表单 | ✅ / ❌ |
| React 管理后台入口 | ✅ / ❌ | | 业务 `services/` | ✅ / ❌ |
| React 前台操作入口(如适用) | ✅ / ❌ |
### 期望方案 ### 期望方案
1. 在对应页面为管理员增加操作入口(仅 `role === 'admin'` 可见) 1.
2. 调用已有或新增的 `/api/admin/*` JSON API 2.
3. 成功后刷新列表/详情,无需离开当前页面
### 相关代码 ### 相关代码
- 后端:`service/`、`handler/api.go`、`router/router.go` - `routers/web/``templates/``services/`
- 前端:`frontend/src/pages/admin/`、`frontend/src/api/client.ts` - 规格:`docs/rebuild-spec/02-features.md` / `06-pages-ux.md`
### 备注
适合作为 `good first issue` 时,优先选择 API 已就绪、只需补 UI 的小改动。

View File

@@ -1,79 +0,0 @@
# 网站监控设计说明
管理端「网站监控」:轻量自建访问统计,对标 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

View File

@@ -0,0 +1,185 @@
# 01 · 产品定位与模块地图
> **读者**:重构架构师 / 产品对齐
> **前置**[README.md](README.md)
> **后续**[02-features.md](02-features.md)
> **源码**[docs/introduction.md](../introduction.md)、[README.md](../../README.md)
---
## 1. 定位
姜十三论坛不做大而全公网社区,只服务「几人到几百人」的内部交流:
| 场景 | 说明 |
|------|------|
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 项目配套社区 | 可与 Gitea 通过 OIDC开放身份连接做 SSO单点登录 |
| 个人站长 | 希望数据自管、部署简单 |
**产品口号气质**:能聊 · 好看 · 好装(部署简单)。
**规模预期**:非百万用户级;信息密度接近 V2EX / NGA 一类,而非大留白营销站。
---
## 2. 产品 vs 运维(拆开看待)
| 维度 | 当前实现 | 重构时 |
|------|----------|--------|
| **产品** | 论坛功能全集(见模块地图) | **必须对齐**功能与规则 |
| **运维** | 单二进制 + 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 帖子
- 富文本正文HTMLTipTap 产出)+ 可选 Markdown 编辑面
- 标签、修订历史与 diff
- 五种类型:`normal` | `question` | `poll` | `bounty` | `lottery`
- 运营标记:全局置顶、版内置顶、精华、禁止编辑、禁止评论
- 审核状态:`pending` | `published` | `rejected`;软删回收站
### 4.4 内容门控
正文内嵌自定义标签(非独立表行,存在 `posts.content` HTML 中):
| 标签 | 含义 |
|------|------|
| `<members-only>` | 登录可见 |
| `<reply-only>` | 本帖已回复可见 |
| `<points-only data-cost="N">` | 积分解锁;按块计费 |
### 4.5 评论
- 楼层号;回复指定楼;嵌套展示;引用;@ 提及
- 游客评论字段;私密评论(仅相关人可见)
- 点赞、编辑时限、审核、软删
### 4.6 积分经济与成长
- **Points**:可消费积分(签到、抽奖、解锁、悬赏托管等)
- **Exp**:不可消费经验 → 等级 Lv110
- **CreatorIncomeTotal**:创作分成累计(徽章指标)
- 徽章:自动(门槛)+ 限定(管理员发放)
### 4.7 私信与通知
统一走 `private_messages` 表,用 `kind` 区分用户私信与系统事件。
### 4.8 友链与站点页
- 管理员维护品牌友链 JSON用户申请审核可选回链检测
- 自定义单页关于、版规等slug、发布、导航/页脚展示
### 4.9 Gitea 开源码桶
后台配置后定时同步公开仓库到 `gitea_repos`,前台 `/projects` 展示。
### 4.10 OIDC Provider
本站作为 IdPDiscovery / Authorize / Token / UserInfo / Logout / JWKS多 OAuth 客户端。
### 4.11 管理后台
仪表盘、板块、单页、友链、帖/评、举报、用户、徽章、媒体、系统设置、SQLite 备份。
### 4.12 SEO / 发现
`robots.txt``sitemap.xml`、Open Graph / Twitter / JSON-LD当前另有爬虫 HTML。新站应用 SSR 统一,但 **meta 字段集合应保留**(见 [07-config-ops.md](07-config-ops.md))。
---
## 5. 权限一句话
- **读公开内容**:人人可(含游客)
- **写内容**:登录(部分评论允许游客)
- **审与运营**:管理员
- **免审写**:管理员或 `verified`
细节状态机见 [05-business-rules.md](05-business-rules.md)。

View File

@@ -0,0 +1,215 @@
# 02 · 功能清单(验收级)
> **读者**:实现与验收
> **前置**[01-product.md](01-product.md)
> **交叉**[05-business-rules.md](05-business-rules.md)、[06-pages-ux.md](06-pages-ux.md)
> **源码对照**[`frontend/src/App.tsx`]((仅 mainfrontend/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`)— SSRSameSite=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` 投票210 选项;单选/多选;可选截止时间;投票;作者可结束 — 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 → 等级 Lv110 — 门槛推导 + 发帖/评论/获赞加 ExpAdmin 设等级 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 大幅优化
- 帖子搜索增强(组合筛选更强)
若新站一并实现,可作为加分项,不阻塞「功能对等」验收。

View File

@@ -0,0 +1,473 @@
# 03 · 数据模型
> **读者**:实现数据库与领域层的 AI
> **前置**[01-product.md](01-product.md)
> **后续**[04-api.md](04-api.md)、[05-business-rules.md](05-business-rules.md)
> **源码**[`model/models.go`](../../models/models.go)、[`model/oauth.go`](../../models/oauth.go)、[`model/gitea.go`](../../models/gitea.go)、[`model/level.go`](../../models/level.go)、[`model/db.go`](../../models/db.go)、[`model/user_view.go`](../../models/user_view.go)、[`service/settings.go`](../../services/settings.go)
当前无独立 SQL migration表由 GORM `AutoMigrate` 创建。新站可用正式 migration但**字段语义应对齐**。
---
## 1. ER 概览
```mermaid
erDiagram
User ||--o{ Post : authors
User ||--o{ Comment : authors
Board ||--o{ Post : contains
Post ||--o{ Comment : has
Post ||--o{ PostLike : likes
Post ||--o{ PostFavorite : favorites
Post ||--o{ PostRevision : revisions
Comment ||--o{ CommentLike : likes
Comment ||--o{ CommentRevision : revisions
Post ||--o| Poll : poll
Poll ||--o{ PollOption : options
PollOption ||--o{ PollVote : votes
Post ||--o{ PostLotteryWinner : winners
Post ||--o{ PostContentUnlock : unlocks
User ||--o{ PointLedger : ledger
User ||--o{ CheckIn : checkins
User ||--o{ LotteryDraw : draws
User ||--o{ UserBadge : earns
BadgeDef ||--o{ UserBadge : defines
User ||--o{ PrivateMessage : sends
User ||--o{ PostReport : reports
User ||--o{ FriendLinkApply : applies
User ||--o{ Media : uploads
```
另有:`ForumSetting`(键值)、`Session`(浏览器 opaque 会话)、`OAuthClient` / `OAuthAuthCode``GiteaRepo``SitePage`
---
## 2. 表与字段
说明:`json:"-"` 表示默认 API 序列化隐藏;软删列 `deleted_at` 表示 GORM soft delete。
### 2.0 sessions
| 列 | 类型 | 说明 |
|----|------|------|
| id | string(64) PK | 密码学随机 opaque idCookie `jiang13_session` 的值) |
| user_id | uint index | 用户 |
| expires_at | time index | 过期;默认 TTL 7 天,滑动续期 |
| created_at / last_seen_at | time | |
| ip / user_agent | string | 可选审计 |
登出删单行;禁言 / 改密删该用户全部 session。每次请求以 DB 中 `users.role` / 禁言为准。
### 2.1 users
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | uint PK | | |
| username | string(128) | unique, not null | 登录名 |
| email | string(128) | index, default '' | 公开主页不返回 |
| password | string(128) | not null | bcrypt 哈希,永不返回 |
| nickname | string(64) | | 展示名 |
| signature | string(512) | default '' | 个人签名 |
| avatar | string(512) | | 相对或绝对 URL |
| role | string(16) | default `user` | `user` \| `admin` |
| verified | bool | index | 站长认证,免审 |
| exp | int | default 0 | 经验(不可消费) |
| points | int | default 0 | 可用积分 |
| creator_income_total | int | default 0 | 创作分成累计 |
| banned | bool | | 禁言 |
| banned_at | *time | | |
| last_login_at | *time | json 隐藏 | |
| last_login_ip | string(45) | json 隐藏 | |
| last_access_at | *time | json 隐藏 | 带鉴权访问 |
| created_at / updated_at | time | | |
| deleted_at | soft | | |
**非落库展示字段**`level`(由 Exp 推导)、`badges`(附加)。
视图结构:`UserPublic` / `UserSelf` / `UserAdmin`(见 [`model/user_view.go`](../../models/user_view.go))。
### 2.2 boards
| 字段 | 说明 |
|------|------|
| id, name(64), description(512) | |
| icon(64), color_index(default -1) | -1=按 id 自动取色 |
| sort_order | 升序 |
| created_at, updated_at, deleted_at | |
### 2.3 posts
| 字段 | 说明 |
|------|------|
| id, board_id, user_id | FK 索引 |
| title(256), content(text) | HTML 正文 |
| content_plain(text) | 纯文本搜索索引,`json:"-"` |
| tags(256) | 逗号或空格分隔标签串 |
| post_type | `normal`\|`question`\|`poll`\|`bounty`\|`lottery` |
| question_resolved | 仅问答 |
| bounty_points, bounty_status, bounty_comment_id | 悬赏 |
| lottery_winner_count, lottery_status | 抽奖帖 |
| pinned | 全局置顶 |
| board_pinned | 版内置顶 |
| featured | 精华 |
| edit_locked | 禁止编辑 |
| comments_locked | 禁止新评论 |
| status | `pending`\|`published`\|`rejected` |
| like_count, view_count | |
| timestamps + soft delete | |
关联Board, User, Comments。
### 2.4 post_revisions
每次修改前保存旧版:`post_id`, `editor_id`, `title`, `content`, `tags`, `created_at`
### 2.5 comments
| 字段 | 说明 |
|------|------|
| post_id, user_id | user_id=0 表示游客 |
| floor | 楼层号 |
| content | HTML/富文本 |
| reply_to | *uint 回复目标评论 |
| guest_nick / guest_email / guest_url | 游客信息 |
| is_private | 私密评论 |
| status | pending\|published\|rejected |
| like_count | |
| soft delete | |
**非落库**`reply_target`, `thread_parent_id`, `content_hidden`, `liked`
### 2.6 comment_revisions
`comment_id`, `editor_id`, `content`, `created_at`(管理员可查)。
### 2.7 post_likes / comment_likes / post_favorites
唯一索引:(post_id|comment_id, user_id)。收藏带 Post 关联。
### 2.8 private_messages
| 字段 | 说明 |
|------|------|
| from_user_id | 0=系统 |
| to_user_id | |
| subject(256), content(text) | |
| kind | 见枚举 |
| related_post_id, related_report_id | 可选 |
| is_read | |
| created_at | |
### 2.9 post_reports
帖或评举报:`post_id` 必填;`comment_id` 有值则为评论举报。
`reason`, `detail`, `status`, `handler_id`, `handle_note`, `handled_at`
### 2.10 friend_link_applies
申请字段name, url, description, logo, reciprocal_page_url, link_on_homepage,
reciprocal_verified / check_note / checked_at, status, review_note, reviewed_at + soft delete。
### 2.11 media
上传索引:`category`=`avatars`\|`posts`\|`site``name`, `url`(unique), `size`, `content_type`, `storage_type`=`local`\|`s3`, `user_id`
### 2.12 point_ledgers
`user_id`, `delta`, `balance`(变动后), `reason`, `ref_type`, `ref_id`, `note`, `created_at`
### 2.13 check_ins
唯一 `(user_id, day)`day=`YYYY-MM-DD``points`, `streak`
### 2.14 lottery_draws
每日抽奖唯一 `(user_id, day)``points` 可为 0。
### 2.15 post_content_unlocks
唯一 `(user_id, post_id, block_key)``cost`
### 2.16 site_pages
`title`, `slug`(unique), `content`, `published`, `sort_order`, `show_in_footer`, `show_in_nav` + soft delete。
### 2.17 polls / poll_options / poll_votes
- Poll`post_id` unique`multi`, `max_choices`, `closed`, `ends_at`
- Option`post_id`, `text`(64), `sort_order`, `vote_count`
- Vote唯一 `(post_id, option_id, user_id)`(多选时多行)
### 2.18 post_lottery_winners
`post_id`, `user_id`, `comment_id`, `created_at`
### 2.19 badge_defs / user_badges
BadgeDef`code` unique, `name`, `description`, `icon`, `kind`=`auto`\|`limited`, `metric`, `threshold`, `sort_order`, `enabled`
UserBadge唯一 `(user_id, badge_id)``awarded_at`, `awarded_by`(0=系统)。
### 2.20 forum_settings
| 字段 | 说明 |
|------|------|
| key | PK string(64) |
| value | string(2048) |
### 2.21 oauth_clients / oauth_auth_codes
Client`client_id` unique, `client_secret_hash`, `name`, `redirect_uris`(可多行), `enabled`
AuthCode一次性码 + PKCE 字段 + `expires_at` + `used`
### 2.22 gitea_repos
同步缓存:`gitea_id` unique, owner/name/full_name, description, html_url, language, stars/forks, private, updated_at_remote, forum_user_id, synced_at。
---
## 3. 枚举全集
### 3.1 角色 Role
`user` | `admin`
### 3.2 内容状态 ContentStatus
`pending` | `published` | `rejected`
### 3.3 帖类型 PostType
`normal` | `question` | `poll` | `bounty` | `lottery`
### 3.4 悬赏 BountyStatus
`open` | `awarded` | `refunded`(空串视为非悬赏)
### 3.5 帖内抽奖 PostLotteryStatus
`open` | `drawn`
### 3.6 私信 kind
| 值 | 含义 |
|----|------|
| user | 用户互发 |
| system | 系统通知 |
| reject | 帖/评被拒 |
| report_result | 举报处理结果 |
| reply | 被回复 |
| mention | 被 @ |
| moderation | 待审提醒管理员 |
### 3.7 举报
Status`pending` | `resolved` | `dismissed`
Reason`spam` | `abuse` | `illegal` | `irrelevant` | `other`
### 3.8 友链申请
`pending` | `approved` | `rejected`
### 3.9 积分 reason
| 值 | 含义 |
|----|------|
| check_in | 签到 |
| lottery | 每日抽奖 |
| unlock_spend | 解锁消费 |
| creator_income | 创作分成 |
| admin_adjust | 管理员调账 |
| bounty_escrow | 悬赏托管 |
| bounty_award | 悬赏发放 |
| bounty_refund | 悬赏退回 |
### 3.10 徽章
Kind`auto` | `limited`
Metric`tenure_days` | `likes_received` | `creator_income`
---
## 4. 等级Exp → Level
源:[`model/level.go`](../../models/level.go)
| Level | 最低 Exp |
|-------|----------|
| 1 | 0 |
| 2 | 20 |
| 3 | 50 |
| 4 | 100 |
| 5 | 200 |
| 6 | 400 |
| 7 | 800 |
| 8 | 1500 |
| 9 | 3000 |
| 10 | 5000 |
管理员设等级时,应把 Exp 调到该等级门槛(见后台 API
---
## 5. 内置自动徽章seed
源:[`model/db.go`](../../models/db.go) `seedDefaultBadges`
| code | 名称 | metric | threshold |
|------|------|--------|-----------|
| tenure_30 | 初来乍到 | tenure_days | 30 |
| tenure_365 | 资深居民 | tenure_days | 365 |
| likes_10 | 小有人气 | likes_received | 10 |
| likes_100 | 人气作者 | likes_received | 100 |
| likes_1000 | 人气巨星 | likes_received | 1000 |
| income_100 | 小有进账 | creator_income | 100 |
| income_1000 | 创作达人 | creator_income | 1000 |
已存在同 `code` 则跳过插入。
---
## 6. forum_settings 键与默认值
源:[`service/settings.go`](../../services/settings.go)、[`service/permalink.go`](../../services/permalink.go)
### 6.1 论坛限制
| Key | 默认 | 说明 |
|-----|------|------|
| post_edit_window_hours | 24 | 0 可表示特殊策略,以实现为准 |
| comment_edit_window_minutes | 3 | |
| rate_limit_post | 10 | 窗口内次数 |
| rate_limit_comment | 10 | |
| rate_limit_register | 10 | |
| rate_limit_login | 10 | |
| rate_limit_window_sec | 60 | |
| post_title_max | 128 | |
| post_tags_max | 256 | |
| post_content_max | 50000 | |
| comment_max | 5000 | |
| search_keyword_min | 1 | |
| search_keyword_max | 50 | |
| page_size_default | 30 | API 硬上限 100 |
| password_min_len | 6 | |
| avatar_max_mb | 2 | |
| signature_max | 200 | |
| open_posts_in_new_tab | 1 | |
| open_content_links_in_new_tab | 1 | |
### 6.2 Feed / 侧栏 / 友链展示
| Key | 默认 |
|-----|------|
| feed_list_style | `title`(另有 `excerpt` / `thumbnail` |
| aside_show_tag_cloud | 0 |
| aside_show_recent_comments | 0 |
| aside_show_friend_links | 1 |
| aside_widgets | JSON 数组,见下 |
| nav_show_friend_links | 1 |
| footer_show_friend_links | 1 |
| friend_link_reciprocal_check | 0 |
| permalink_enabled | 0 |
| permalink_ext | `html` |
默认 `aside_widgets`
```json
[
{"id":"tag_cloud","enabled":false},
{"id":"recent_comments","enabled":false},
{"id":"friend_links","enabled":true}
]
```
合法 widget id`tag_cloud` | `recent_comments` | `recent_users` | `friend_links`
### 6.3 SMTP
| Key | 默认 |
|-----|------|
| smtp_enabled | 0 |
| smtp_host | |
| smtp_port | 465 |
| smtp_username / smtp_password | |
| smtp_from | |
| smtp_from_name | 姜十三论坛 |
| smtp_encryption | `ssl`(另有 `none` / `starttls` |
### 6.4 OIDC
| Key | 默认 |
|-----|------|
| oidc_enabled | 0 |
| oidc_root_url | |
| oidc_group_claim | groups |
| oidc_admin_group | gitea-admin |
| oidc_user_group | gitea-users |
| oidc_rsa_private_pem | (空)启用 OIDC 时懒生成并写入;未启用不落盘 `.oidc_rsa.pem` |
### 6.4b 敏感词
| Key | 默认 |
|-----|------|
| filter_words | 默认词表文本;启动时从旧 `filter_words.txt` 导入(若键为空) |
### 6.5 Gitea 同步(**后置**,键保留兼容)
| Key | 默认 |
|-----|------|
| gitea_sync_enabled | 0 |
| gitea_base_url | |
| gitea_token | |
| gitea_sync_interval_min | 60 |
本迭代不启同步任务;见 [02-features.md](02-features.md) §K。
### 6.6 存储
| Key | 默认 |
|-----|------|
| storage_type | local |
| storage_endpoint / region / bucket | region 默认 us-east-1 |
| storage_access_key / storage_secret_key | |
| storage_public_base_url / storage_prefix | |
| storage_force_path_style | 1 |
| storage_image_delivery | webp或 original |
### 6.7 站点品牌
| Key | 默认 |
|-----|------|
| site_name | 姜十三论坛 |
| site_slogan | 拾三一隅,自在交流 |
| site_description / site_keywords | 空 |
| site_logo_mark | 姜 |
| site_logo / site_favicon / site_og_image | 空 |
| site_icp_beian | 空 |
| site_icp_beian_url | https://beian.miit.gov.cn/ |
| site_friend_links | `[]` JSON最多 20 条 |
---
## 7. 升级兼容补丁(现网 InitDB
[`model/db.go`](../../models/db.go) 在 AutoMigrate 后:
-`status` 的帖/评 → `published`
-`post_type``normal`
- Exp=0 用户按存量内容粗算经验:`posts*10 + comments*2 + like_sum`
新站若从空库开始可忽略;若迁移旧库需保留等价 backfill。
---
## 8. 内容门控在库中的形态
**无独立表**存放门控块;存在 `posts.content` HTML 中,例如:
```html
<members-only>...</members-only>
<reply-only>...</reply-only>
<points-only data-cost="10">...</points-only>
```
积分解锁 `block_key` = `sha256(innerHTML)[:16]`hex见 [`service/unlock.go`](../../services/unlock.go)。

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

@@ -0,0 +1,391 @@
# 04 · HTTP API 合约
> **读者**:机器客户端 / 集成方;浏览器 UI **不**使用本文件作为主路径
> **前置**[03-data-model.md](03-data-model.md)、[08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
> **源码**[`routers/setup.go`](../../routers/setup.go)、[`modules/auth/auth.go`](../../modules/auth/auth.go)
本分支(`rebuild/gitea-ssr`)浏览器走 **`routers/web` 模板 + 表单**。
**当前进程仅注册** health / OIDC / robots / sitemap / media thumb 等机器相关路由;论坛 CRUD 的 `/api/*` **handler 已从本分支删除**(对照实现见 `main`,合约形状见下文历史章节)。
`main` 分支 SPA 仍完整实现下表;对照请 checkout `main`
---
## 1. 本分支已注册的机器入口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 探活 |
| GET | `/robots.txt` | 抓取规则 |
| GET | `/sitemap.xml` | 站点地图 |
| GET/POST | `/oauth/*``/.well-known/openid-configuration` | OIDC Provider |
| GET | `/media/thumb/*``/uploads/*` | 媒体 |
---
## 2. 历史 JSON 合约main / 对照,本分支默认不挂载)
以下章节描述原 SPA 使用的 `/api` 形状,便于迁移业务语义;**实现 UI 时请用 web 表单,勿恢复双轨。**
### 通用约定(历史)
| 项 | 约定 |
|----|------|
| Base | 同源;`credentials: 'same-origin'` |
| 成功 | HTTP 2xx + JSON body |
| 失败 | 非 2xx + `{ "error": "..." }` |
| 鉴权 | Cookie `jiang13_session`opaque机器 OIDC 用 Bearer |
### 分页形态差异
| 场景 | 典型字段 |
|------|----------|
| 前台帖列表 | `posts`, `total`, `page`, `size`, `has_more` |
| 后台多数列表 | `total`, `page`, `total_pages` + 实体数组 |
| 私信会话消息 | `before` 游标式 |
---
## 3. 基础设施 / SEO / 静态(节选,仍有效)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | `/health` | 无 | `{ "status": "ok" }` |
| GET | `/robots.txt` | 无 | 文本 |
| GET | `/sitemap.xml` | 无 | XML |
| GET | `/media/thumb/*filepath` | 无 | 缩略图 / WebP 等 |
| GET | `/uploads/*` | 无 | 静态上传文件 |
---
## 3. OIDC Provider
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | `/.well-known/openid-configuration` | 无 | Discovery |
| GET | `/oauth/jwks` | 无 | JWKS |
| GET | `/oauth/authorize` | OptionalAuth | 授权码流程 |
| POST | `/oauth/token` | 无(客户端凭证) | 换 token |
| GET/POST | `/oauth/userinfo` | Bearer | 用户信息 |
| GET/POST | `/oauth/logout` | 视实现 | 登出 |
细节以 [`service/oidc.go`](../../services/oidc.go) / [`handler/oidc.go`](../../routers/api/oidc.go) 为准。
---
## 4. 公开 API`/api` + OptionalAuth
### 4.1 会话与站点
| 方法 | 路径 | 响应要点 |
|------|------|----------|
| GET | `/api/me` | `{ user: UserSelf \| null }` |
| GET | `/api/stats` | `{ users, posts, boards, comments }` |
| GET | `/api/forum-limits` | `ForumLimitsPublic`(无限流内部字段) |
| GET | `/api/site-branding` | `SiteBranding`(可含 `site_url` |
| GET | `/api/captcha` | `{ id, image }` image 为 data URL 或 base64 |
| GET | `/api/register/config` | 见下 |
**RegisterConfig**
```json
{
"is_first_user": true,
"mail_ready": false,
"require_email_code": false,
"register_open": true,
"email_code_len": 6
}
```
### 4.2 认证(限流)
| 方法 | 路径 | Body | 响应 |
|------|------|------|------|
| POST | `/api/register` | Form/JSON: username, password, nickname, email, captcha_id, captcha, email_code? | 成功后通常种 cookie |
| POST | `/api/login` | Form: username, password | 种 cookie |
| POST | `/api/register/email-code` | JSON `{ email }` | `{ message }` |
| POST | `/api/password-reset/email-code` | JSON `{ email }` | `{ message }` |
| POST | `/api/password-reset` | JSON `{ email, email_code, new_password }` | `{ message }` |
### 4.3 内容只读
| 方法 | 路径 | Query / 说明 |
|------|------|----------------|
| GET | `/api/boards` | `{ boards: Board[] }` |
| GET | `/api/posts` | 见下表 |
| GET | `/api/posts/hot` | 热门列表 |
| GET | `/api/posts/:id` | `skip_view=1` 可选;返回 `PostDetailResponse` |
| GET | `/api/posts/:id/comments` | `my_ids` 可选(逗号分隔,便于标自己的楼) |
| GET | `/api/tags` | `limit` 默认 40 → `{ tags: [{name,count}] }` |
| GET | `/api/comments/recent` | `{ comments: RecentComment[] }` |
| GET | `/api/users/search` | `q`, `limit` |
| GET | `/api/users/recent` | `{ users: RecentUser[] }` |
| GET | `/api/users/:id` | `{ user: UserPublic, stats }` |
| GET | `/api/pages` | 已发布摘要列表 |
| GET | `/api/pages/:slug` | 单页详情 |
| GET | `/api/projects` | `page`, `limit`, `q` |
**GET `/api/posts` Query**
| 参数 | 说明 |
|------|------|
| page | 默认 1 |
| size | 默认 page_size_default上限 100 |
| board_id | 0 或不传=全部 |
| user_id | 某用户的帖 |
| keyword | 搜索词 |
| tag | 标签 |
| author | 用户名优先,否则昵称精确匹配 |
| title_only | `1`/`true` 仅搜标题 |
| sort | `latest` \| `reply` \| `hot` |
**响应示例**
```json
{
"posts": [ /* PostItem */ ],
"total": 100,
"page": 1,
"size": 30,
"has_more": true
}
```
**PostDetailResponse 要点**
```json
{
"post": { /* PostItem + content */ },
"comment_count": 0,
"liked": false,
"favorited": false,
"has_replied": false,
"can_edit": true,
"edit_block_reason": "",
"is_edited": false,
"post_edit_window_hours": 24,
"poll": { /* PollView */ },
"lottery": { /* PostLotteryView */ },
"bounty_can_refund": false,
"bounty_refund_block_reason": "",
"bounty_eligible_reply_count": 0
}
```
### 4.4 游客可写评论
| 方法 | 路径 | 限流 | Body |
|------|------|------|------|
| POST | `/api/posts/:id/comments` | comment | Form: content, reply_to?, is_private?, 以及游客字段(以实现为准) |
登录用户发评也走此路径RequireAuth 组外公开组已注册该路由)。
---
## 5. 需登录 API`/api` + RequireAuth
### 5.1 会话与资料
| 方法 | 路径 | Body | 响应 |
|------|------|------|------|
| POST | `/api/logout` | | 清 cookie |
| GET | `/api/favorites` | | `{ favorites, total }` |
| GET | `/api/profile/stats` | | `{ stats: UserActivityStats }` |
| POST | `/api/profile/nickname` | Form nickname | |
| POST | `/api/profile/signature` | Form signature | `{ message, user }` |
| POST | `/api/profile/password` | Form old_password, new_password | |
| POST | `/api/profile/avatar` | Form avatar=file | `{ avatar }` |
| POST | `/api/uploads/image` | Form image=file | `{ url }` |
### 5.2 帖子写操作
| 方法 | 路径 | Body | 响应 |
|------|------|------|------|
| POST | `/api/posts` | Form: board_id, title, content, tags?, post_type?, poll_options?, bounty_points?, lottery_winner_count? | `{ message, post_id, status }` |
| PUT | `/api/posts/:id` | Form: title, content, tags?, board_id?, post_type? | `{ message }` |
| DELETE | `/api/posts/:id` | | 软删 |
| GET | `/api/posts/:id/revisions` | | `{ revisions }` |
| GET | `/api/posts/:id/revisions/:revId` | | `{ revision }` |
| POST | `/api/posts/:id/like` | | `{ liked, like_count }` |
| POST | `/api/posts/:id/favorite` | | `{ favorited }` |
| POST | `/api/posts/:id/resolve` | Form resolved=`1`\|`0` | `{ question_resolved }` |
| POST | `/api/posts/:id/poll/vote` | JSON `{ option_ids: number[] }` | `{ poll }` |
| POST | `/api/posts/:id/poll/close` | | `{ poll }` |
| POST | `/api/posts/:id/bounty/award` | Form comment_id | |
| POST | `/api/posts/:id/bounty/refund` | | |
| POST | `/api/posts/:id/lottery/draw` | | `{ lottery }` |
| POST | `/api/posts/:id/report` | JSON `{ reason, detail? }` | `{ report }` |
| POST | `/api/posts/:id/unlock` | JSON `{ block_key }` | 见下 |
**poll_options JSON 示例**Form 字段字符串)
```json
{
"multi": false,
"max_choices": 1,
"ends_at": "2026-09-01T12:00:00Z",
"options": [{ "text": "选项A" }, { "text": "选项B" }]
}
```
**unlock 响应**
```json
{
"message": "...",
"unlock": {
"block_key": "abcdef0123456789",
"cost": 10,
"points_balance": 90,
"inner_html": "<p>...</p>"
}
}
```
### 5.3 评论写操作
| 方法 | 路径 | Body |
|------|------|------|
| POST | `/api/comments/:id/like` | → `{ liked, like_count }` |
| POST | `/api/comments/:id/report` | JSON `{ reason, detail? }` |
| PUT | `/api/comments/:id` | Form content |
| DELETE | `/api/comments/:id` | |
### 5.4 私信
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/messages/unread-count` | `{ count, dm_count?, notify_count? }` |
| GET | `/api/messages/notifications` | page, size, kind |
| POST | `/api/messages/notifications/read` | |
| GET | `/api/messages/conversations` | page, size |
| GET | `/api/messages/conversations/:peerId` | size, beforepeerId=0 为系统 |
| POST | `/api/messages/conversations/:peerId/read` | |
| POST | `/api/messages` | JSON `{ to_user_id, subject?, content }` |
| POST | `/api/messages/read-all` | |
### 5.5 经济
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/me/points` | page含 ledger、check_in、lottery |
| GET/POST | `/api/me/check-in` | 状态 / 执行签到 |
| GET/POST | `/api/me/lottery` | 状态 / 抽奖 |
### 5.6 友链申请
| 方法 | 路径 | Body |
|------|------|------|
| POST | `/api/friend-links/apply` | JSON name, url, logo, link_on_homepage, reciprocal_page_url? |
| POST | `/api/friend-links/logo` | Form logo=file → `{ url }` |
| GET | `/api/friend-links/my-applies` | |
| PUT | `/api/friend-links/applies/:id` | 同申请字段 |
| DELETE | `/api/friend-links/applies/:id` | 取消 |
---
## 6. 管理 API`/api/admin` + Auth + Admin
### 6.1 仪表盘与设置
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/dashboard` | AdminDashboard |
| GET | `/settings` | AdminSettings 聚合 |
| PUT | `/settings/forum` | ForumLimits |
| PUT | `/settings/mail` | MailConfig |
| POST | `/settings/mail/test` | `{ to }` |
| PUT | `/settings/oidc` | OIDCConfig |
| PUT | `/settings/gitea` | **后置**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`]((仅 mainfrontend/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)。

View File

@@ -0,0 +1,230 @@
# 05 · 业务规则与状态机
> **读者**:实现领域逻辑的 AI最易「看起来像但算错」
> **前置**[03-data-model.md](03-data-model.md)、[04-api.md](04-api.md)
> **源码**[`service/`](../../services/)、[`model/models.go`](../../models/models.go)
---
## 1. 注册与引导
源:[`routers/web/auth.go`](../../routers/web/auth.go)、[`services/auth.go`](../../services/auth.go)
| 规则 | 细节 |
|------|------|
| 管理员 | **仅** `/install` 向导创建(不再「首注册变管理员」) |
| 开放注册 | 安装完成后开放;不依赖 SMTP |
| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时可无码注册 |
| 会话 | Cookie `jiang13_session` = opaque id`sessions`HttpOnly + SameSite=LaxHTTPS 时 SecureTTL 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
| 规则 | 细节 |
|------|------|
| 选项数 | 210单选项 ≤64 字 |
| 多选 | `multi``max_choices` 钳制在 1..选项数 |
| 截止 | `ends_at` 可选;过期或 `closed` 不可再投 |
| 投票 | 每用户每帖;已投不可改(`ErrPollAlreadyVoted` |
| 结束 | 作者或管理员 `poll/close` |
### 6.3 悬赏 bounty
```mermaid
stateDiagram-v2
[*] --> open: 发帖托管积分
open --> awarded: 采纳他人已发布评论
open --> refunded: 退款
```
| 规则 | 细节 |
|------|------|
| 发帖 | 积分 ≥1立即 `bounty_escrow` 扣作者积分 |
| 采纳 | 不能采纳自己的回复;评论须 published全额给评论作者 `bounty_award` |
| 退款 | 状态 open作者在**无他人已发布回复**时可退;**管理员始终可强制退** |
| 退款后 | status=`refunded``bounty_points=0`,积分退回作者 |
### 6.4 抽奖帖 lottery
| 规则 | 细节 |
|------|------|
| 中奖人数 | 120 |
| 参与者 | 已发布评论且 **非楼主**;按用户去重(保留最早评论) |
| 开奖 | 作者/管理员;人数不足报错;随机抽取;写 `post_lottery_winners`status=`drawn` |
---
## 7. 签到与每日抽奖
源:[`service/points.go`](../../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 等;窗口秒与次数来自 settingsAdmin 可改基础四项+窗口)
---
## 12. 徽章自动授予
定期或触发时检查 `BadgeDef`kind=autotenure_days / likes_received / creator_income 达阈值则写入 `user_badges`。限定徽章仅管理员发放。
---
## 13. 置顶排序语义
| 标记 | 首页全部 Feed | 板块 Feed |
|------|---------------|-----------|
| `pinned` | 抬升 | 抬升 |
| `board_pinned` | **不**抬升 | 抬升 |
| `featured` | 标记展示,不一定改变排序 | 同左 |
具体 SQL/排序实现见 [`service/post.go`](../../services/post.go) ListItems。

View File

@@ -0,0 +1,229 @@
# 06 · 页面、交互与信息架构
> **读者**:实现前台 / 后台 UI 的 AI
> **前置**[02-features.md](02-features.md)
> **源码**[`frontend/src/App.tsx`]((仅 mainfrontend/src/App.tsx)、[`frontend/src/pages/`]((仅 mainfrontend/src/pages/)、[`frontend/src/components/`]((仅 mainfrontend/src/components/)、[`frontend/src/layouts/`]((仅 mainfrontend/src/layouts/)
视觉可重设(见 [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` | 重定向前台登录 | 已迁 |
未迁(原 SPAOIDC·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`)— **已迁** SSRAdmin `/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发帖托管 |
| 抽奖 | 中奖人数 120 — SSR compose |
### 3.2 编辑器能力本分支Markdown 渐进增强)
源对照:[`ArticleEditor.tsx`]((仅 mainfrontend/src/components/ArticleEditor.tsx)TipTap**不迁**
| 能力 | SSR 状态 |
|------|----------|
| 标题 h2h6`#` 映射为 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 textareanav/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 细节
- Toastsonner反馈成功/失败
- 路由级 ErrorBoundary / AppRouteError
- 懒加载页面 + retry`lazyWithRetry`
- 新标签打开帖子 / 正文外链:受 `open_posts_in_new_tab``open_content_links_in_new_tab` 控制
- 文档标题:`站点名 - 标语`详情页应换成帖标题SSR 时首屏即正确)
---
## 10. SSR 重构提示(交互层)
当前 SPA 在客户端挂载后才拉 `/api/posts/:id`。新站应:
1. 服务端渲染列表项与帖文 HTML已按门控红action
2. 水合后接上赞/评/解锁等交互
3. 管理后台仍可为 CSR但前台公开页优先 SSR
勿再维护「爬虫一套 HTML、用户一套空壳」双轨除非过渡期兼容。

View File

@@ -0,0 +1,213 @@
# 07 · 配置、运维与 SEO
> **读者**:部署与运维、以及实现配置层的 AI
> **前置**[README.md](README.md)
> **源码**[`config/`](../../config/)、[`README.md`](../../README.md)、[`routers/api/seo.go`](../../routers/api/seo.go)、[`routers/install/`](../../routers/install/)
运维形态可改;下列描述**本分支**行为。
---
## 0. 首次安装Gitea 式)
| 项 | 说明 |
|----|------|
| 锁文件 | `data/install.lock` |
| 向导 | `GET/POST /install``templates/install.tmpl` |
| 未锁定 | 除 `/install``/ssr-assets/*``/health` 外重定向到向导 |
| 管理员 | 仅安装向导创建;不再「首个注册用户变管理员」 |
| 向导内容 | 站点名 + 管理员账号(数据库已在进程启动时连上) |
| 旧数据 | 启动时若已有用户且无锁,自动补写锁 |
**无 `app.ini`。** 引导仅 CLI / Env。
---
## 1. 配置分层与重启边界
| 层 | 存什么 | 变更方式 | 需重启 |
|----|--------|----------|--------|
| **Bootstrap** | `DATA``HTTP_PORT`/`ADDR``DB_TYPE` + DSN/连接参数、工作目录 | CLI / Env | **是** |
| **密钥文件** | App HMAC`data/.jwt_secret`文件名历史遗留OIDC RSA **仅启用时**写入 settings可选遗留文件迁移 | 自动生成 | HMAC 换钥需重启 |
| **站点运行时** | 品牌、邮件、OIDC 开关、限流、敏感词、存储、伪静态… | DB `forum_settings` | **否**(热更) |
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
### 进程引导
| CLI | 环境变量 | 默认 | 说明 |
|-----|----------|------|------|
| `--port` | `JIANG13_HTTP_PORT` | 3000 | 监听端口 |
| `--http-addr` | `JIANG13_HTTP_ADDR` | (空=全接口) | 监听地址 |
| `--data` | `JIANG13_DATA` | `data` | 数据目录 |
| `--work-path` | `JIANG13_WORK_PATH` | 可执行文件目录 | 工作目录 |
| `--db-type` | `JIANG13_DB_TYPE` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
| `--db-dsn` | `JIANG13_DB_DSN` | sqlite 默认 `{DATA}/jiang13.db` | 完整 DSN优先 |
| `--db-host` 等 | `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE` | | DSN 为空时拼接pg/mysql |
| `--service` | | | install/uninstall/start/stop/restart/status |
`{DATA}/.jwt_secret`**App HMAC 密钥**CSRF 双提交等),启动时自动生成。**不是**浏览器登录 JWT。`--config` / `--jwt-secret` / `JIANG13_JWT_SECRET` 已废弃。
浏览器登录DB `sessions` + Cookie `jiang13_session`。OIDC 对外 token 仍为 JWT私钥在 `forum_settings.oidc_rsa_private_pem`(启用时懒加载;未启用不生成 `.oidc_rsa.pem`)。
业务配置邮件、OIDC、存储、品牌、敏感词等**DB `forum_settings`**,管理后台热更新。
### 数据库 Env 示例
**SQLite默认**
```bash
JIANG13_DATA=/data
# 可不设 DB_*;库文件 = $JIANG13_DATA/jiang13.db
```
**PostgreSQL**
```bash
JIANG13_DB_TYPE=postgres
JIANG13_DB_DSN="postgres://forum:secret@db:5432/jiang13?sslmode=disable"
# 或拆分:
# JIANG13_DB_HOST=db:5432
# JIANG13_DB_USER=forum
# JIANG13_DB_PASS=secret
# JIANG13_DB_NAME=jiang13
# JIANG13_DB_SSLMODE=disable
```
**MySQL / MariaDB**
```bash
JIANG13_DB_TYPE=mysql
JIANG13_DB_DSN="forum:secret@tcp(db:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4"
```
连库失败时进程**退出并打印 Env 提示**,不会静默回落 sqlite。
### Docker Compose 多库示意
```yaml
services:
jiang13:
image: hangzhang714128/jiang13-forum:latest
environment:
JIANG13_DB_TYPE: postgres
JIANG13_DB_DSN: postgres://forum:secret@postgres:5432/jiang13?sslmode=disable
volumes:
- jiang13-data:/data
depends_on: [postgres]
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: forum
POSTGRES_PASSWORD: secret
POSTGRES_DB: jiang13
```
---
## 2. 数据目录结构
```text
data/
├── install.lock # 安装完成锁(与 DB 引擎无关)
├── jiang13.db # 仅 SQLite 时的主库文件(含 sessions / forum_settings
├── jiang13.log # 运行日志
├── filter_words.txt # 遗留:启动时可导入 settings新源以 DB 为准
├── .jwt_secret # App HMACCSRF 等;勿提交;非登录 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)。

View File

@@ -0,0 +1,105 @@
# 08 · Gitea 式 SSR 架构(开发约定)
> **读者**:在本仓库 `rebuild/gitea-ssr` 分支上开发的 AI / 开发者
> **前置**[README.md](README.md)
> **对照上游**[go-gitea/gitea](https://github.com/go-gitea/gitea)
---
## 目标栈(已确认)
| 层 | 选择 |
|----|------|
| 公开页渲染 | Go `html/template` **真 SSR** |
| 浏览器写操作 | `routers/web` HTML 表单 POST + CSRF + PRG |
| JSON `/api` | **仅机器客户端**OIDC、health、`/api/captcha``/api/register` 等);不服务已迁页面 UI |
| 渐进增强 | `web_src/``public/assets/``/ssr-assets/` |
| 发布 | 单二进制 + `go:embed` |
| 业务语义 | `01``07`;冲突时改代码并回写规格 |
| 不做 | React SPA、爬虫/用户双轨 HTML、为旧 SPA 保留死代码 |
---
## 分支
| 分支 | 用途 |
|------|------|
| `main` | React SPA 对照git checkout / worktree |
| `rebuild/gitea-ssr` | 唯一重建分支 |
---
## 目录
```text
routers/
setup.go
install/ # INSTALL_LOCK 未置位时的安装向导
web/ # HTML + 表单
api/ # 精简机器接口health / OIDC / robots / sitemap / media
modules/
webctx/ # Doer / CSRF / Flash / HTML / Redirect
auth/
webrender/
seo/
templates/
install.tmpl
post-install.tmpl
base/ home/ post/ shared/ status/ auth/ admin/ profile/ user/ favorites/ messages/
services/
web_src/ → public/assets/
```
**已删除:** `frontend/``embed_static/``ServePublicSPA`、爬虫双轨 HTML、首注册变管理员 bootstrap、`app.ini`、浏览器 JWT Cookie 登录。
**配置:** 引导 = CLI/Env`DB_*`);运行时 = `forum_settings` 热更;`.jwt_secret` = App HMACOIDC 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`

View File

@@ -0,0 +1,52 @@
# 09 · SSR 重构进度与路线图
> **读者**:产品方 / 实现 AI
> **分支**`rebuild/gitea-ssr``main` = SPA 对照)
> **验收清单** [02-features.md](02-features.md)
---
## 当前指针
| 项 | 值 |
|----|-----|
| **上一刀** | 首页对齐 main SPA 视觉与交互(草绿壳 + Feed v2 |
| **下一刀** | 需点名(移动抽屉 / 帖详情抛光 / OIDC Admin… |
| **工作区** | 应干净 |
---
## 一句话状态
核心论坛、Admin、Limits/伪静态、嵌套评论、Markdown 编辑器、主题、清青产品壳与资源版本号、首页门面密度已齐;本分支 `routers/api` 仅保留机器入口。余量多为后置与体验打磨。
---
## 已完成(摘)
| 域 | 状态 |
|----|------|
| 五种帖 + Admin P1 + §H | 已迁 |
| Limits / 伪静态 / 品牌图 | 已迁 |
| 评论嵌套树 UI | 已迁 |
| Markdown 工具栏 + preview | 已迁 |
| 浅色 / 暗色 / 跟随系统 | 已迁CSS 媒体查询,无 head 防闪脚本) |
| 未挂载论坛 JSON API | 已删(对照 `main` |
| 公开页设计系统 | 已迁(首页对齐 main SPA 草绿壳;[10-design-system.md](10-design-system.md) |
---
## 未完成
- P3游客评论、修订 diff、响应式侧栏抽屉
- 编辑器后置TipTap、表格、图片组、表情贴纸
- 后置OIDC Admin UI、S3 热切换、Gitea `/projects`
---
## 如何查阅
| 问题 | 看哪里 |
|------|--------|
| 某功能做没做? | [02-features.md](02-features.md) |
| 现在该干什么? | 本文「当前指针」 |

View File

@@ -0,0 +1,94 @@
# 10 · 公开页设计系统
> **读者**:实现公开页视觉 / CSS 的 AI
> **前置**[01-product.md](01-product.md)、[06-pages-ux.md](06-pages-ux.md)
> **实现**[`web_src/css/site.css`](../../web_src/css/site.css)、[`templates/base/`](../../templates/base/)
> **品牌**:默认「姜十三论坛」;首页视觉对齐 `main` SPA 草绿产品壳
视觉可重设;**信息架构与关键操作流以 06 为准**。本文件只约定公开页壳层与内容表面的视觉语言。
静态资源须带构建版本号:`/ssr-assets/site.css?v={{.AssetVersion}}`(长缓存 + query 穿透,对齐 Gitea
---
## 1. 气质
| 项 | 约定 |
|----|------|
| 关键词 | 对齐 `main` SPA草绿 `#18a058`、清新产品壳 |
| 密度 | SPA 同款顶栏工具条 + Feed `post-row--v2` |
| 反模式 | 紫渐变、暖奶油底、大圆角卡片墙、全幅 hero、Inter/Roboto |
---
## 2. Token
### 2.1 色CSS 变量 `--j13-*`
| Token | 浅色 | 暗色 | 用途 |
|-------|------|------|------|
| `--j13-bg` / page | `#f5f7fa` | `#141416` | 页底 |
| `--j13-surface` | `#ffffff` | `#1f1f23` | 顶栏、主栏 |
| `--j13-accent` / green | `#18a058` | `#23c36b` | 主色(对齐 main SPA |
| `--j13-accent-ink` | `#138f4c` | `#6ee7a0` | 强强调 |
| `--j13-soft` / green-bg | `#edfbf3` / `rgba(24,160,88,.08)` | 绿 soft | active / hover |
| `--j13-border` | `#e8edf2` | `#2e2e32` | 分隔 |
语义色ok / warn / err / pin / feat随主题成对定义feat 用青 soft不用草绿。
### 2.2 字号与字重
| 阶 | 尺寸 | 用途 |
|----|------|------|
| `xs` | 0.720.75rem | 口号、辅助 |
| `sm` | 0.8125rem | 元信息、侧栏、顶栏次级 |
| `md` | 0.9375rem | 正文默认 |
| `lg` | 1.15rem | Feed 标题 |
| `xl` | 1.35rem | 帖标题 |
| `brand` | 1.3rem / 700 | 顶栏站点名 |
字体栈:**中文优先** PingFang SC / Hiragino / Microsoft YaHei / Noto Sans SC与 SPA 一致,确保本机可见);代码 Cascadia / Consolas。不引入 Inter/Roboto不依赖本机 IBM Plex。
### 2.3 间距与圆角
- 栅格4px 基准;总宽 `--j13-frame: 1320px`
- 圆角:`--j13-radius: 8px`;搜索 pill / 排序分段可用满圆角
- 阴影sticky 顶栏微阴影
### 2.4 动效
| 处 | 行为 |
|----|------|
| 主题 | `color` / `background` / `border-color` `150ms ease` |
| Feed 行 | hover 背景 |
| 顶栏 | sticky 阴影;搜索 focus soft ring |
---
## 3. 布局
| 断点 | 行为 |
|------|------|
| ≥901px | 三栏:左 210px / 中 1fr / 右 280px总宽 ~1400px对齐 SPA |
| ≤900px | 隐藏右栏;顶栏搜索折行(抽屉化 **下一刀** |
---
## 4. 组件约定
| 组件 | 规则 |
|------|------|
| 顶栏 | SPA品牌 \| 搜索胶囊(筛选钮 + Ctrl+K\| 发帖绿钮 \| 主题/私信/头像菜单 |
| 左栏 | 浏览 / 板块active 绿底;板块色槽 + 帖数 |
| Feed 行 | `post-row--v2`:头像 · 徽章+标题 · 摘要(依 feed_list_style· 元信息 · 回复数 |
| 高级搜索 | 顶栏筛选打开;有条件时 details 展开 |
| 排序 | SPA `feed-sort-tab` 绿 active |
---
## 5. 范围边界
- **覆盖**:公开页壳层(含首页门面密度)、资源 `?v=`、浅/暗色
- **不覆盖**Admin 深重绘、移动抽屉、虚拟滚动、TipTap、改站名
交叉:[06-pages-ux.md](06-pages-ux.md) · [09-ssr-progress.md](09-ssr-progress.md)

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

@@ -0,0 +1,140 @@
# 姜十三论坛 · 重构规格文档包
> **读者**:准备用新栈(建议真 SSR重写站点的 AI / 开发者
> **事实来源**:本仓库现有代码;规格描述「产品必须保留什么」,不是「必须继续用 Go + React SPA」
> **交叉引用**[01-product](01-product.md) · [02-features](02-features.md) · [03-data-model](03-data-model.md) · [04-api](04-api.md) · [05-business-rules](05-business-rules.md) · [06-pages-ux](06-pages-ux.md) · [07-config-ops](07-config-ops.md) · [08-gitea-ssr-architecture](08-gitea-ssr-architecture.md) · [09-ssr-progress](09-ssr-progress.md) · [10-design-system](10-design-system.md)
> **实现栈(重构分支)**Gitea 式 Go 模板 SSR开发分支 `rebuild/gitea-ssr``main` 保留 React SPA 对照。详见 [08](08-gitea-ssr-architecture.md)。
---
## 阅读顺序(请按序投喂)
| 顺序 | 文件 | 用途 |
|------|------|------|
| 1 | 本文 `README.md` | 架构痛点、重构约束、术语 |
| 2 | [01-product.md](01-product.md) | 产品定位、角色、模块地图 |
| 3 | [02-features.md](02-features.md) | 验收级功能清单(可打勾) |
| 4 | [03-data-model.md](03-data-model.md) | 表结构、枚举、设置键、等级徽章 |
| 5 | [04-api.md](04-api.md) | HTTP 合约(路径 / 鉴权 / 请求响应) |
| 6 | [05-business-rules.md](05-business-rules.md) | 状态机与数值规则 |
| 7 | [06-pages-ux.md](06-pages-ux.md) | 路由、布局、编辑器、后台流程 |
| 8 | [07-config-ops.md](07-config-ops.md) | 配置、数据目录、部署、SEO 字段 |
| 9 | [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) | Gitea 式 SSR 分支与目录约定 |
| 10 | [09-ssr-progress.md](09-ssr-progress.md) | **进度与刀序**(「现在做到哪 / 下一刀」) |
| 11 | [10-design-system.md](10-design-system.md) | 公开页视觉 token / 组件(壳层重绘) |
单次 context 不够时:先投喂 `README` + `01` + `02`;跟进度看 `09`;实现某模块时再追加对应 `03``06` 章节。
---
## 当前产品是什么
**姜十三论坛Jiang13 Forum** 面向小圈子 / 团队 / 同好社群的轻量论坛。
当前实现技术栈(**可抛弃,仅作对照**
| 层 | 技术 |
|----|------|
| 后端 | Go · Gin · GORM · SQLite |
| 前端 | React 18 SPA · TipTap · Tailwind · TanStack Virtual |
| 发布 | Vite 构建 → `go:embed` 打进单二进制 |
| 认证 | bcrypt + DB opaque session Cookie`jiang13_session` |
演示站https://bbs.iioio.com/
---
## 为何要重构:架构痛点(必须打破)
```mermaid
flowchart LR
browser[Browser]
spa[ReactSPA]
gin[GinAPI]
sqlite[SQLite]
bot[BotHTML]
browser -->|"用户"| spa
spa -->|"JSON /api"| gin
gin --> sqlite
browser -->|"爬虫 UA"| bot
bot --> gin
```
| 痛点 | 现状 | 对用户的影响 |
|------|------|----------------|
| 非真 SSR | 生产入口(`main``embed_static`)只注入 title / branding / Open Graph**不渲染帖文 DOM** | 刷新先出壳再灌数据,体验不如 SSR |
| 爬虫双轨 | [`routers/api/seo_bot.go`](../../routers/api/seo_bot.go) 对爬虫返回独立 HTML | 用户与爬虫看到的不是同一套渲染路径 |
| 无正式 migration | Schema 靠 GORM `AutoMigrate`[`models/db.go`](../../models/db.go) | 升级靠「加字段」,难做破坏性迁移与审计 |
| Cookie JWT | 浏览器登录曾用 JWT Cookie | **本分支已改为** DB `sessions` + opaque Cookie `jiang13_session``.jwt_secret` 仅 CSRF/HMAC |
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染SSRHTMLSEO meta 与正文同源。技术选型自定Next.js / Nuxt / Remix / 其它均可)。
---
## 重构时必须保留 vs 可以改
### 必须保留(产品语义)
- [02-features.md](02-features.md) 中列出的功能能力
- [03-data-model.md](03-data-model.md) 中的实体关系与枚举含义(表名可改,语义对齐)
- [05-business-rules.md](05-business-rules.md) 中的数值与状态机(积分、审核、门控、悬赏分成等)
- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;**管理员仅由 `/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. 搭出等价的页面信息架构与关键交互
若规格与代码冲突:**以代码为准**,并应回写修正本目录文档。

View File

@@ -0,0 +1,22 @@
# Admin 徽章管理SSR— 实现计划
> 状态已完成SSR `/admin/badges`
> 分支:`rebuild/gitea-ssr`
## 范围
| 能力 | 服务 | 路由 |
|------|------|------|
| 列表(含停用) | `BadgeService.ListDefs(true)` | `GET /admin/badges` |
| 创建/更新定义 | `UpsertDef` | `POST /admin/badges``POST /admin/badges/:id` |
| 删除定义 | `DeleteDef`(清 `user_badges` | `POST /admin/badges/:id/delete` |
| 颁发限定 | `AwardLimited` | `POST /admin/badges/award` |
| 收回 | `Revoke` | `POST /admin/badges/revoke` |
## 不做
自动徽章规则引擎调度 UIP2服务层 `EvaluateAuto` 仍可被触发)、等级设定。
## 验证
`build.bat`;冒烟:创建限定徽章 → 颁发给用户 → 收回 → 删除。

View File

@@ -0,0 +1,21 @@
# Admin 媒体库SSR— 实现计划
> 状态已完成SSR `/admin/media`
> 分支:`rebuild/gitea-ssr`
## 范围
| 能力 | 服务 | 路由 |
|------|------|------|
| 分类/搜索/分页列表 | `UploadStore.ListMedia` | `GET /admin/media` |
| 单删 | `DeleteMediaByIDs` | `POST /admin/media/:id/delete` |
| 批量删 | 同上 | `POST /admin/media/delete` |
| 同步索引 | `SyncMediaIndex` | `POST /admin/media/sync` |
## 不做
S3 热切换 Admin、全站未引用扫描清理、WebP 产品化默认策略。
## 验证
`build.bat`;冒烟:写入 uploads → 同步索引 → 列表可见 → 删除。

View File

@@ -0,0 +1,30 @@
# Admin 用户管理SSR— 实现计划
> 状态已完成SSR `/admin/users`
> 分支:`rebuild/gitea-ssr`
## 范围
在 Admin 增加用户列表与运营操作,复用已有服务层,不恢复浏览器管理 JSON `/api`
| 能力 | 服务 | 路由 |
|------|------|------|
| 列表/搜索/分页 | `UserService.ListUsers` | `GET /admin/users` |
| 禁言/解禁 | `UserService.BanUser` | `POST /admin/users/:id/ban` |
| 认证开关 | `SetVerified` | `POST /admin/users/:id/verify` |
| 调积分 | `PointsService.AdminAdjust` | `POST /admin/users/:id/points` |
## 实现要点
1. [`routers/web/home.go`](../routers/web/home.go) admin 组注册上述路由
2. 新建 [`routers/web/admin_users.go`](../routers/web/admin_users.go):渲染 + CSRF POST + PRG
3. [`templates/admin/users.tmpl`](../templates/admin/users.tmpl) + [`templates/admin/nav.tmpl`](../templates/admin/nav.tmpl) 入口
4. 回写 `02` §H调积分/认证相关)、`06` `/admin/users` 已迁;更新 `09` 当前指针 → badges
## 不做
等级设定 UI、徽章授予、批量导入、恢复 `/api/admin/users` 作 UI。
## 验证
`build.bat`;冒烟:搜索用户 → 禁言 → 调积分 → 切换 verified。

View File

@@ -0,0 +1,17 @@
# P2 等级设定与徽章展示 — 实现计划
> 状态:已完成
> 分支:`rebuild/gitea-ssr`
## 范围
| 能力 | 实现 |
|------|------|
| Admin 设等级 | `POST /admin/users/:id/level``SetUserLevel`Exp 调至门槛) |
| 公开等级 | 既有 `/user/:id``/profile` Lv/Exp |
| 徽章展示 | 用户页 / 资料页列表;访问时 `EvaluateAuto` |
| 防刷分成 | 确认 `suspiciousUnlockPair` 已落地并勾选 `02` |
## 验证
`build.bat`;冒烟:设用户 Lv5 → Exp=200用户页可见等级。

View File

@@ -18,16 +18,6 @@
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。 把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
## 自定义单页(推荐)
站务帖之外,建议在后台 **单页管理** 发一篇更新日志,方便 Docker / Windows 用户对照版本:
| 文件 | 标题 | slug | 操作 |
| --- | --- | --- | --- |
| [changelog.md](./changelog.md) | 更新日志 | `changelog` | 发布;页脚展示(可选侧栏导航) |
访问路径:`/page/changelog`
## 可选短文 ## 可选短文
若站务区希望不止一篇,见 [optional.md](./optional.md) 若站务区希望不止一篇,见 [optional.md](./optional.md)

View File

@@ -84,7 +84,7 @@
| 改什么 | 去哪里 | | 改什么 | 去哪里 |
| --- | --- | | --- | --- |
| 端口、数据目录、JWT | 服务器上的 `app.ini`(改后重启) | | 端口、数据目录、DB、JWT | CLI / Env + `data/.jwt_secret`(改引导项需重启) |
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) | | 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
逛完板块,发第一帖或回一楼——聊起来就对了。 逛完板块,发第一帖或回一楼——聊起来就对了。
@@ -98,12 +98,12 @@
### 浏览与发帖 ### 浏览与发帖
- 三栏布局,桌面 / 移动自适应 - 三栏布局,桌面 / 移动自适应
- Feed 排序与虚拟滚动 - Feed 排序
- 浅色 / 暗色主题;板块图标与主题色可配 - 浅色 / 暗色主题(可跟随系统);板块图标与主题色可配
- TipTap 富文本、标签、正文图片 - Markdown 编辑器、标签、正文图片、内容门控
- 楼层评论:回复、引用、@ - 楼层评论:回复、引用、嵌套树
- 点赞、收藏;置顶 / 精华 - 点赞、收藏;置顶 / 精华
- 编辑修订历史(可对比差异) - 编辑修订历史
- 可配置普通用户的编辑时限 - 可配置普通用户的编辑时限
### 社交与个人 ### 社交与个人
@@ -114,7 +114,7 @@
### 管理后台 ### 管理后台
与前台同一套 React 体验,统一在 `/admin` SSR 后台统一在 `/admin`
| 模块 | 能力 | | 模块 | 能力 |
| --- | --- | | --- | --- |
@@ -151,7 +151,7 @@
1. 编译得到一个二进制 1. 编译得到一个二进制
2. 放到目录里运行 2. 放到目录里运行
3. 自动生成 `app.ini` 3. 自动生成 `data/.jwt_secret`
4. 浏览器注册,第一个账号即管理员 4. 浏览器注册,第一个账号即管理员
### 它补哪块空缺 ### 它补哪块空缺
@@ -165,14 +165,14 @@
| --- | --- | | --- | --- |
| 单二进制 | 前端已内嵌,不必单独部署前端目录 | | 单二进制 | 前端已内嵌,不必单独部署前端目录 |
| 内置 SQLite | 零外部数据库,数据在本地目录 | | 内置 SQLite | 零外部数据库,数据在本地目录 |
| 精简 `app.ini` | 主要管端口、数据目录、JWT | | Env / CLI 引导 | 端口、数据目录、数据库 |
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 | | 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
| 系统服务 | 内置 Linux systemd / Windows Service | | 系统服务 | 内置 Linux systemd / Windows Service |
| 跨平台 | Windows / Linux / macOS 均可编译运行 | | 跨平台 | Windows / Linux / macOS 均可编译运行 |
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。 备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
进程级项改 `app.ini` 后重启;业务项在管理后台改。既保留「改文件控进程」的可控性,又避免把所有开关塞进配置文件 进程级项改 Env/CLI 后重启;业务项在管理后台改。引导面保持精简,热更新走数据库
--- ---

View File

@@ -1,145 +0,0 @@
# 更新日志
> 建议标题:更新日志
> 建议 slug`changelog`
> 建议路径:`/page/changelog`
> 建议操作:后台「单页管理」新建并发布;勾选「页脚展示」(可选「侧栏导航」)
> 复制正文时:从下方第一个 `---` **之后**开始粘贴Markdown 模式)
---
Docker 镜像、Windows exe、Linux 单文件 **共用同一版本号**。有新版本时,以本页为准。
**当前版本1.1.6**2026-09-01
## 去哪看新版本
| 渠道 | 看什么 | 地址 |
| --- | --- | --- |
| 本页 | 版本号、变更说明、升级注意 | 本站 `/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.6 直接下载:**
- Windows x64[jiang13-1.1.6-windows-amd64.exe](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.6/jiang13-1.1.6-windows-amd64.exe)
- Linux x64[jiang13-1.1.6-linux-amd64](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.6/jiang13-1.1.6-linux-amd64)
- 校验文件:[SHA256SUMS.txt](https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.6/SHA256SUMS.txt)
拉取指定版本镜像:
```bash
docker pull hangzhang714128/jiang13-forum:1.1.6
# 或始终跟随最新
docker pull hangzhang714128/jiang13-forum:latest
```
Windows停掉正在跑的进程或服务用新下载的 exe 覆盖原文件(可改名为 `jiang13.exe`**不要动**旁边的 `data/``app.ini`,再启动即可。
Linux赋予执行权限后放到原目录覆盖同样保留 `data/``app.ini`
## 升级注意
- 一般只需换镜像或换 exe数据目录向后兼容。
- Docker 请继续挂载原来的 `/data` 卷,切勿新建空目录当「升级」。
- 升级后若页面样式异常强制刷新浏览器Ctrl+F5。程序也会在前端资源失效时自动硬刷新。
---
## 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
网站监控的地理信息与口径更准确。
- 补全 IP2LocationIP 地理位置库)中国城市中文名,同名城市按省消歧
- 对齐监控概览「双通道」口径文案与诊断抽样说明
## 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 文件,再把新版本写到本页最上方。

View File

@@ -1,118 +0,0 @@
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
}

View File

@@ -1,202 +0,0 @@
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 HTMLmeta / 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())
}

View File

@@ -1,20 +0,0 @@
{
"$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"
}
}

View File

@@ -1,48 +0,0 @@
<!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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +0,0 @@
{
"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"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Some files were not shown because too many files have changed in this diff Show More