107 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
4dc3bbb13f fix: 单页正文改用 post-detail-content 对齐帖子排版
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:33:09 +08:00
8cfe31da61 fix: 锁死页面双指缩放,正文原图与灯箱 pinch 放大
禁止手机/平板整页捏合缩放;帖子与单页正文直接显示原图(含外链可点开灯箱),灯箱内支持双指缩放与拖动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:11:00 +08:00
7965768f3d fix: 注册用户无头像时不再误用游客灰底样式
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:05:14 +08:00
012cac23de feat: 去掉 Feed 顶栏统计并强化开源码桶
按帖子列表风格展示码桶,补齐语言/Star/Fork 与论坛作者关联回填,避免未绑定仓库与空列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:58:04 +08:00
d929bf96aa fix: 首页帖子列表标题去掉加粗
保留 15px 字号,字重改回 400。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:11:47 +08:00
cce9fb9ed8 feat: 友链入口迁至站点区并支持左右页脚独立开关
修复左侧栏/页脚开关无法关闭的问题,改为专用设置接口可靠写入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:04:22 +08:00
9eefe24a33 feat: 优化单页编辑、列表留白与友链申请体验
单页全屏编辑并禁用内容门控;对齐 Feed 留白;友链申请区前置并收敛弹框样式;略增大帖子列表标题字号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:24 +08:00
f559fc4c4b fix: 适配手机端搜索入口与底部 Sheet 面板
顶栏改为可伸展搜索 pill,面板改为自底部滑出并优化筛选条与触控布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 23:39:32 +08:00
55b1831da2 feat: 重设计搜索帖子 UI/UX,新增筛选面板与结果页 chips
将高级筛选移入独立面板,统一 URL 状态与清除逻辑,并优化移动端搜索与空结果反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 19:21:55 +08:00
c6df703b96 fix: 占位头像默认白字并保留 hover 描边反馈
修复 user-link inherit 覆盖 post-avatar 白字的问题,头像链接 hover 时用淡绿描边替代变绿字。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 19:01:18 +08:00
7cc574f9a1 feat: 优化首页帖子列表并新增右侧栏最新注册组件
首页列表精简 meta 与统计展示,板块色标前移并淡化;有回复时显示最后回复人。右侧栏新增最新注册(4 列头像网格),友链改为标签块并排换行。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 18:50:09 +08:00
2208af7070 feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 06:23:54 +08:00
df19752a1e chore: 移除 GitHub Actions 工作流,避免 Gitea 检查失败
仓库托管在 Gitea,镜像改由本地手动构建推送到 Docker Hub。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 18:37:01 +08:00
c07d1fd2bd feat: 新增 Docker 容器化部署,支持 Docker Hub 镜像发布
Some checks failed
Docker / docker (push) Has been cancelled
多阶段 Dockerfile、Compose、/health 探活与 JIANG13_* 环境变量;
entrypoint 自动修正 /data 卷权限,适配 1Panel 等平台;更新 README 部署文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 18:30:53 +08:00
4f85a86077 fix: 修复生产环境表情无法显示的问题
1. 后端新增/stickers路由静态文件服务,设置1天缓存
2. 排除/stickers前缀的SPA路由拦截
3. 优化评论区表情按钮样式与可访问性
4. 移除编辑器中未使用的标题工具与引用工具
2026-08-12 02:49:26 +08:00
f0650a9c39 fix(post-detail): 修复评论定位不准确和重试机制缺失问题
将jumpToFloor改为使用pageRef.scrollTo替代scrollIntoView适配自定义滚动容器,同时为floor锚点定位添加完善的重试机制,对齐heading锚点的处理逻辑
2026-08-11 22:52:55 +08:00
4f42a2faff fix(comment): 修复评论框提交后重置和聚焦的问题
1. 为CommentEditor的focus方法添加preventScroll可选参数并透传调用
2. 使用ref记录上次提交计数,避免首次挂载时重复清空输入框
3. 仅在submitCount实际变化时重置表单并聚焦编辑器
2026-08-08 15:09:43 +08:00
536707f812 feat: 新增贴纸系统,重构评论编辑器与内容处理
1. 新增多平台贴纸库:贴吧、微博、知乎、小红书、抖音、B站共500+贴纸,搭配颜文字贴纸
2. 实现贴纸选择器面板,支持分类浏览、键盘导航和懒加载
3. 重构评论内容渲染:替换旧的@提及高亮逻辑,新增HTML净化与双向兼容
4. 重构评论编辑器为Tiptap富文本版本,支持表情插入、格式编辑
5. 优化评论编辑体验,修复滚动位置保留问题,调整评论区UI样式
6. 移除旧版EmojiPicker工具类,新增贴纸数据管理模块
2026-08-08 02:24:04 +08:00
1550ccb693 Revert "feat: 实现基础滚动位置恢复功能"
This reverts commit 4042395e3e.
2026-08-08 02:11:10 +08:00
4042395e3e feat: 实现基础滚动位置恢复功能
1. 新增 useScrollRestoration hook 用于在布局组件挂载时恢复滚动位置
2. 新增 scrollRestore 工具模块实现基于 sessionStorage 的滚动位置保存与恢复
3. 在 MainLayout 和 AdminLayout 中接入滚动恢复hook
4. 在应用入口初始化全局滚动保存监听
2026-08-07 21:46:58 +08:00
383440ed5b feat(frontend): add posting compose component suite
新增了发帖相关的完整组件模块:包括顶部导航栏ComposeHeader、正文编辑区ComposeDocument,以及发布设置栏ComposeContextBar,同时添加了vite依赖缓存配置文件。
2026-08-07 17:45:50 +08:00
94f6a9666b 增加 @提及、搜索筛选与找回密码,并将右栏热门改为正在聊。
补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 07:39:33 +08:00
10185178e2 补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 06:32:06 +08:00
0f83183620 增加板块内置顶,与全局置顶分离:首页仅抬升全局置顶,板块列表优先全局再板块。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 05:14:00 +08:00
bb8d415eb7 支持评论级联软删与后台回收站,并完善 Markdown 代码围栏嵌套。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 17:49:05 +08:00
e6e7ed73e3 优化个人中心信息架构与手机编辑体验,置顶改为文字标识。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:41:34 +08:00
6b0a1d4281 增加用户认证、等级、徽章与积分体系,并优化管理后台体验。
覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 02:42:18 +08:00
b075495540 支持评论点赞与举报,并统一帖子/评论的举报入口交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 01:06:02 +08:00
c05cc472cf 增加回复与待审提醒:站内消息与 SMTP 邮件通知。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:34:16 +08:00
322ac14055 优化评论区展示:弱化用户名样式、调整回复布局与编辑时限。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:33:06 +08:00
e8b28a37e4 项目截图、readme更新 2026-08-05 17:11:05 +08:00
cc51c49272 增加回复可见功能 2026-08-05 12:44:00 +08:00
5b2d9a4ea4 编辑工具增加表格输入菜单,优化表格样式 2026-08-05 12:21:14 +08:00
e50047e1c8 代码块优化,增加aardio、R语言代码高亮等 2026-08-05 11:58:35 +08:00
860dffd547 修复帖内 CSS 污染整页:前后端禁止 style 并消毒 HTML。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 13:04:10 +08:00
2a34699f64 为板块编辑对话框补充 Description,消除无障碍警告。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 00:22:42 +08:00
b24f6c23ea 优化帖子详情与列表展示,并新增问答帖类型与已解决状态。
统一标题锚点定位、编辑底栏固定与操作栏分层,列表徽章前置并区分置顶/问答配色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 00:19:32 +08:00
3aca5c42c5 头像裁剪改为保留源格式上传,避免服务端只存 WebP。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 18:30:27 +08:00
2c06eaccb2 修复发帖长文布局与手机下拉刷新,发版后自动重载失效 chunk,并整理站务文案。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 18:18:35 +08:00
61b47363e9 新增站务公告板块发帖素材与发布说明。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:39:27 +08:00
dd9e185847 修复无板块时空态导航失效,去除发帖本地草稿,并种子默认板块。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:12:17 +08:00
81f5d451bc 精简 app.ini:仅保留端口/数据目录/JWT,业务项改由管理后台配置。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:58:30 +08:00
48db333272 移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:37:40 +08:00
060b7707cb 支持公开用户主页、帖子图缩略图与编辑器图组排版。
新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:29:50 +08:00
822eef96be 新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:58:22 +08:00
9487c8ab02 支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。
引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:09:58 +08:00
962bb15298 chore: 不再跟踪 embed_static/static/spa 构建产物
SPA 由 Vite 输出至 embed_static,发布前通过 build 脚本构建即可。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 02:41:32 +08:00
55c256654b 支持板块图标与主题色自定义,并优化列表加载与未保存离开提示。
新增后端图标校验与色槽规范化,前端展示 BoardBadge/骨架屏,发帖与板块管理页增加未保存确认。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:13:32 +08:00
e038d4f9e9 add cursor rules 2026-06-26 05:27:06 +08:00
f527c91c27 chore: add .gitattributes to fix cross-platform line endings 2026-06-26 05:26:07 +08:00
773639fd7c 修复头部用户菜单关闭后按钮残留高亮,优化 hover 与 focus 样式。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:16:54 +08:00
9767b54192 限制首页 Feed 自动加载上限,超出后支持手动加载更多历史帖子。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:11:33 +08:00
0eae3f8c47 恢复 README 原有排版:居中标题、徽章、截图表格与 HTML 布局。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 06:00:44 +08:00
57172eb053 完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 05:57:15 +08:00
e4d1dd139e 更新 README 界面截图,同步 Feed 排序与 TipTap 发帖等新 UI。
改进截图脚本:登录后抓取发帖页,使用富文本示例帖并优化等待逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 04:15:52 +08:00
b451703642 完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。
同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 04:11:38 +08:00
1d273066b0 升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。
将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 03:05:45 +08:00
d0555de28e 统一 React 管理后台,修复评论换行与帖子置顶
- /admin/* 全部由 React SPA 渲染,替代旧版 HTML 后台页面
- 新增仪表盘、帖子/评论/用户管理、系统设置与 JSON API
- 帖子详情页支持管理员置顶;评论换行显示修复

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 23:06:44 +08:00
9230f7272d 添加路线图与 Issue 模板,呈现待办事项
- ROADMAP.md 记录已知缺陷、计划功能与已完成项
- README 增加开发状态说明
- Gitea Issue 模板与预填议题文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 23:01:18 +08:00
b27289bbea 开源准备:重写 README 展示 UI/UX,更新模块路径
- 新增界面截图与 CONTRIBUTING.md
- README 以三栏布局、暗色主题、移动端等视觉亮点为主
- go module 迁移至 git.iioio.com/freefire/jiang13-forum

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:15:51 +08:00
99ae0ccdaa 合并 Gitea 远程初始提交,保留本地项目文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:09:20 +08:00
e1c1708715 初始提交:姜十三论坛 Jiang13 Forum
轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 21:08:52 +08:00
202 changed files with 35026 additions and 41 deletions

View File

@@ -0,0 +1,38 @@
---
description: 编译与构建脚本约定(Windows build.bat/ps1、Makefile、跨平台同步)
alwaysApply: true
---
# 编译与构建脚本
本分支(`rebuild/gitea-ssr`):Go 单二进制 + `go:embed` 的 **templates** 与 **public/assets**(由 `web_src` 构建);产物在 `dist/`。
对照 SPA 仅在 `main` 分支。
## 运行编译(优先用封装命令,不要猜命令)
| 平台 | 命令 |
|------|------|
| Windows | `build.bat` 或 `build.bat -Target <target>` |
| Linux / macOS | `make` 或 `make <target>` |
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE,不兼容本项目 Makefile)。
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
常用 target:`build`(默认)、`dev`、`run`、`web-src`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
## 修改构建脚本时的约定
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
4. **构建顺序**:先 `web_src` 内 `npm run build`(产出 `public/assets`),再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
6. **本分支无 `frontend/`**:勿再添加 SPA 构建步骤;需要对照 UI 时 `git checkout main`。
## 新增 target 检查清单
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
- [ ] `build.bat -Target help` 说明(help 输出在 ps1 内)
- [ ] README「快速开始」如需对外暴露再更新

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

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
# Git 与编辑器
.git
.gitignore
.idea
.vscode
.cursor
.trae
# 运行时数据与本地配置
data/
app.ini
tmp-cookie.txt
# 编译产物与依赖缓存
dist/
web_src/node_modules/
public/assets/
node_modules/
.vite/
# 文档与截图(不参与镜像构建)
docs/
# 计划文件
*.plan.md

17
.gitattributes vendored Normal file
View File

@@ -0,0 +1,17 @@
# 跨平台行尾:仓库内统一 LF,避免 Windows autocrlf 造成「假 modified」
* text=auto eol=lf
# Windows 批处理在 cmd 下需要 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# 二进制
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.exe binary

View File

@@ -0,0 +1,56 @@
name: 缺陷报告
about: 报告一个可复现的问题
title: "[Bug] "
labels:
- bug
body:
- type: markdown
attributes:
value: |
感谢反馈!请尽量提供复现步骤,方便我们定位问题。
- type: textarea
id: description
attributes:
label: 问题描述
description: 简要说明发生了什么
placeholder: 评论回复换行后,页面上显示为一行…
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤
placeholder: |
1. 打开帖子详情页
2. 在评论框输入多行文字
3. 点击发送
4. 观察到换行丢失
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望行为
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
validations:
required: true
- type: input
id: version
attributes:
label: 版本 / 提交
placeholder: main @ abc1234 或 v1.0.0
- type: input
id: environment
attributes:
label: 环境
placeholder: Windows 11 / Chrome 125 / Go 1.26
- type: textarea
id: screenshots
attributes:
label: 截图或日志
description: 可粘贴图片或相关日志

View File

@@ -0,0 +1,44 @@
name: 功能建议
about: 提出新功能或改进现有体验
title: "[Feature] "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
欢迎提出想法!请先搜索已有 Issue,避免重复。
- type: textarea
id: problem
attributes:
label: 要解决什么问题?
description: 从用户场景出发描述痛点
placeholder: 管理员在帖子详情页缺少某某操作入口…
validations:
required: true
- type: textarea
id: solution
attributes:
label: 期望的方案
description: 你心目中的实现方式(可简略)
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 备选方案
description: 如有其他思路可一并说明
- type: dropdown
id: area
attributes:
label: 涉及模块
options:
- 前台 UI/UX
- 管理后台
- 评论系统
- 帖子 / 板块
- 认证 / 权限
- 部署 / 构建
- 其他
validations:
required: true

51
.gitignore vendored
View File

@@ -1,27 +1,30 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
# 运行时数据(数据库、JWT 密钥、日志、头像)
/data/
# 本地配置(保留 app.ini.example)
/app.ini
# Node / 构建缓存
/node_modules/
/web_src/node_modules/
*.tsbuildinfo
# Go 编译产物(统一进 dist/;勿把二进制扔在仓库根目录)
/dist/
*.exe
*.exe~
*.dll
*.so
*.dylib
/jiang13
/jiang13-*
!/cmd/jiang13/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# 临时文件
tmp-cookie.txt
*-err.txt
*-out.txt
# 编辑器 / AI 草稿 / OS
.idea/
.vscode/
.trae/
*.swp
Thumbs.db
.DS_Store

44
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,44 @@
# 参与贡献
感谢你对姜十三论坛的关注!这是一个开源项目,欢迎提交 Issue 和 Pull Request。
## 开发环境
**要求:** Go 1.26+、Node.js 18+(仅构建 `web_src` 静态资源)
本分支(`rebuild/gitea-ssr`)为 **Go 模板 SSR**;对照 React SPA 请 `git checkout main`。
```bat
REM Windows:请用 build.bat(内部 Bypass ExecutionPolicy)
build.bat -Target dev
REM 浏览器访问 http://localhost:3000
```
```bash
# Linux / macOS
make dev
```
## 提交规范
- 一个 PR 只做一件事,保持 diff 小而清晰
- 前端(`web_src` / 模板)改动请确认浅色 / 暗色主题下都正常
- 涉及 UI 变更时,建议在 PR 中附上截图
- 功能语义以 [`docs/rebuild-spec/`](docs/rebuild-spec/) 为准
## 完整构建
```bat
build.bat REM Windows:先 web_src,再 go build → dist/
```
```bash
make build # Linux / macOS
```
## 报告问题
在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)):
- 期望行为与实际行为
- 复现步骤、浏览器 / OS
- 相关模板或 `routers/web` 路径(若已知)

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
# 姜十三论坛 — 多阶段构建:web_src → Go 单二进制 → Alpine 运行镜像
# 不使用 # syntax=docker/dockerfile:1,避免构建前额外拉取 docker.io/docker/dockerfile
#
# 国内网络:默认经 DaoCloud 拉取基础镜像,npm/go 走国内代理
# 海外或已配置 Docker registry-mirrors 时可传空前缀:
# docker build --build-arg IMAGE_PREFIX= ...
ARG IMAGE_PREFIX=docker.m.daocloud.io/library/
ARG VERSION=dev
# ── Stage 1: SSR 渐进资源(web_src → public/assets)────────────────────────
FROM ${IMAGE_PREFIX}node:22-bookworm-slim AS websrc
WORKDIR /src/web_src
COPY web_src/package.json ./
COPY web_src/ ./
RUN node build.mjs
# ── Stage 2: Go 编译(纯 Go SQLite,CGO_ENABLED=0)────────────────────────
FROM ${IMAGE_PREFIX}golang:1.26-bookworm AS builder
ARG VERSION
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=websrc /src/public/assets ./public/assets
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/jiang13 ./cmd/jiang13
# ── Stage 3: 运行镜像 ───────────────────────────────────────────────────────
FROM ${IMAGE_PREFIX}alpine:3.21
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata wget su-exec \
&& adduser -D -u 1000 -h /app jiang13
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY --from=builder /out/jiang13 /app/jiang13
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chown jiang13:jiang13 /app/jiang13 \
&& chmod +x /app/jiang13 /docker-entrypoint.sh
# 以 root 启动,entrypoint 修正 /data 卷权限后 su-exec 降权为 jiang13
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["--work-path", "/app", "--data", "/data", "--port", "3000"]

31
LICENSE
View File

@@ -1,18 +1,19 @@
MIT License
Copyright (c) 2026 The Jiang13 Authors
Copyright (c) 2026 freefire
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

95
Makefile Normal file
View File

@@ -0,0 +1,95 @@
# 姜十三论坛 Jiang13 Forum - Makefile
# Go 1.26 单二进制:templates SSR + web_src 渐进资源(本分支无 React SPA)
APP_NAME := jiang13
MAIN_PKG := ./cmd/jiang13
BUILD_DIR := dist
DEV_DATA_DIR := dist/data
VERSION := $(shell git rev-parse --short HEAD 2>/dev/null | sed 's/^/1.0.0+/' || echo 1.0.0)
LDFLAGS := -s -w -X main.version=$(VERSION)
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
GO := go
GOFLAGS := -trimpath
.PHONY: all build build-windows build-linux build-darwin build-all clean run dev tidy help web-src-build docker compose-up compose-down
all: build
web-src-build:
cd web_src && npm run build
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
build: web-src-build
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
## Windows amd64
build-windows: web-src-build
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
## Linux amd64
build-linux: web-src-build
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
## macOS arm64 (Apple Silicon)
build-darwin: web-src-build
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
## 跨平台全量编译(web_src 只跑一次)
build-all: web-src-build
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
@echo "✓ 全平台编译完成"
## 整理依赖
tidy:
$(GO) mod tidy
## 本地运行 SSR(先构建 web_src)
run: web-src-build
@mkdir -p $(DEV_DATA_DIR)
$(GO) run $(MAIN_PKG) --work-path . --data $(DEV_DATA_DIR)
## 同 run(SPA 对照请 git checkout main)
dev: run
## 清理编译产物
clean:
rm -rf $(BUILD_DIR)
## 构建 Docker 镜像
docker:
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY_IMAGE):$(VERSION) -t $(REGISTRY_IMAGE):latest .
## Docker Compose 启动
compose-up:
docker compose up -d --build
## Docker Compose 停止
compose-down:
docker compose down
help:
@echo "姜十三论坛编译命令 (rebuild/gitea-ssr):"
@echo " make web-src-build - 构建 SSR 渐进资源 (web_src)"
@echo " make build - web_src + 编译当前平台"
@echo " make build-windows - 编译 Windows"
@echo " make build-linux - 编译 Linux"
@echo " make build-darwin - 编译 macOS"
@echo " make build-all - 编译全部平台"
@echo " make run / make dev - 启动 SSR(:3000)"
@echo " make docker - 构建 Docker 镜像"
@echo " make compose-up - Docker Compose 启动"
@echo " make compose-down - Docker Compose 停止"
@echo " SPA 对照: git checkout main"

408
README.md
View File

@@ -1,3 +1,407 @@
# jiang13-forum
<div align="center">
轻量自用论坛:Go 单二进制 + React SPA 内嵌 + SQLite,面向小圈子内部交流。
# 姜十三论坛 Jiang13 Forum
**能聊 · 好看 · 好装**
面向小圈子、团队与同好社群的轻量现代化论坛。
本分支(`rebuild/gitea-ssr`):Go 模板真 SSR + `web_src` 渐进增强,单二进制 + SQLite。
对照 React SPA 请见 `main` 分支。
<br>
[![在线演示](https://img.shields.io/badge/Demo-bbs.iioio.com-18a058?style=flat-square)](https://bbs.iioio.com/)
[![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)
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod)
[![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)](#)
[在线演示](https://bbs.iioio.com/) ·
[快速开始](#-快速开始) ·
[界面预览](#-界面预览) ·
[功能亮点](#-功能亮点) ·
[路线图](ROADMAP.md) ·
[参与贡献](CONTRIBUTING.md)
<br>
<img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%">
<sub>浅色主题 · 三栏布局 · Feed 排序 · 板块导航 · 标签云</sub>
<br>
> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)(现网多为 `main` SPA)
> 本分支按 [Gitea 式 SSR 规格](docs/rebuild-spec/08-gitea-ssr-architecture.md) 重构;欢迎提 Issue / PR 共建。
</div>
---
## 它是什么
姜十三论坛不做大而全的社区平台,只做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
| 场景 | 说明 |
|------|------|
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 项目配套社区 | 可与 Gitea 等通过 OIDC(开放身份连接)做 SSO(单点登录) |
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
---
## 界面预览
截图来自演示站 [bbs.iioio.com](https://bbs.iioio.com/)。
<table>
<tr>
<td width="50%" align="center">
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
<br><b>浅色主题</b><br>
<sub>左栏板块 · Feed 排序 · 右栏热门 / 标签 / 评论</sub>
</td>
<td width="50%" align="center">
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
<br><b>暗色主题</b><br>
<sub>一键切换 · 护眼阅读 · 全局色彩自适应</sub>
</td>
</tr>
<tr>
<td width="50%" align="center">
<img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%">
<br><b>帖子详情</b><br>
<sub>文章目录 · 标签 · 作者卡片 · 修订信息</sub>
</td>
<td width="50%" align="center">
<img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
<br><b>富文本渲染</b><br>
<sub>Markdown 排版 · 图片 · 代码块 · 目录导航</sub>
</td>
</tr>
</table>
<p align="center">
<img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280">
<br><b>移动端</b> — 板块快捷筛选 · Feed 排序 · 触控友好列表
</p>
---
## 功能亮点
### 界面与交互
| 特性 | 说明 |
|------|------|
| **三栏布局** | 左栏板块导航 + 中间帖列表 + 右栏热门 / 标签 / 最新评论 |
| **真 SSR** | 公开页服务端渲染完整 HTML(Go `html/template`) |
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
| **主题切换** | 浅色 / 暗色 / 跟随系统(`localStorage`) |
| **响应式** | ≤900px 隐藏右栏;搜索、发帖、登录触手可及 |
### 社区功能
- 安装向导创建管理员;用户注册 / 登录(bcrypt + 会话 Cookie)
- 板块、发帖、Markdown 工具栏、正文图片上传、标签、置顶 / 精华
- 帖子修订历史;可配置普通用户编辑时限
- 楼层式评论:回复、嵌套树、内容门控(登录/回复/积分可见)
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
- SSR 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
- 可选:邮件验证码、OIDC Provider、S3 兼容对象存储(Gitea 仓库同步后置)
### 部署体验
- **单二进制** — `go:embed` 打包模板与 SSR 资源
- **可切换数据库** — 默认 SQLite;可选 PostgreSQL / MySQL(Env 引导)
- **跨平台** — Windows / Linux / macOS 一键编译
- **系统服务** — 内置 Linux systemd / Windows Service 注册
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
---
## 快速开始
### 1. 编译
**Windows(推荐):**
```bat
build.bat
```
> 请通过 `build.bat` 调用(内部已处理 ExecutionPolicy)。不要直接 `.\build.ps1`,也不要在 Windows 上使用系统自带的 Embarcadero `make`。
**Linux / macOS:**
```bash
make build
```
**手动分步(全平台):**
```bash
cd web_src && npm run build
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
```
跨平台编译:
```bat
build.bat -Target build-windows
build.bat -Target build-linux
build.bat -Target build-all
```
### 2. Docker 部署(推荐)
**一键启动(本地构建):**
```bash
docker compose up -d --build
```
```bat
.\build.bat -Target compose-up
```
```bash
make compose-up
```
浏览器打开 `http://localhost:3000/install` 完成安装向导(站点名 + 管理员)。
**拉取已构建镜像(Docker Hub):**
```bash
docker pull hangzhang714128/jiang13-forum:latest
docker run -d --name jiang13 \
-p 3000:3000 \
-v jiang13-data:/data \
--restart unless-stopped \
hangzhang714128/jiang13-forum:latest
```
**数据持久化:** 容器内 `/data` 对应 SQLite(默认)、上传、日志与 JWT/OIDC 密钥。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户)。
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
**可选环境变量(容器编排):**
| 变量 | 说明 |
|------|------|
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
| `JIANG13_DB_TYPE` | `sqlite`(默认)\| `postgres` \| `mysql` |
| `JIANG13_DB_DSN` | 完整 DSN(非 sqlite 时推荐) |
| `JIANG13_WORK_PATH` | 工作目录 |
JWT 自动写入 `/data/.jwt_secret`,无需 Env。
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
**发布镜像到 Docker Hub(手动):**
```bash
docker login
.\build.bat -Target docker # Windows
# make docker # Linux/macOS
docker push hangzhang714128/jiang13-forum:1.0.0
docker push hangzhang714128/jiang13-forum:latest
```
或直接构建:
```bash
docker build --build-arg VERSION=1.0.0 -t hangzhang714128/jiang13-forum:1.0.0 -t hangzhang714128/jiang13-forum:latest .
docker push hangzhang714128/jiang13-forum:1.0.0
docker push hangzhang714128/jiang13-forum:latest
```
停止服务:`docker compose down` / `.\build.bat -Target compose-down` / `make compose-down`
**构建失败(无法连接 auth.docker.io)?** Dockerfile 已默认经 DaoCloud 拉取基础镜像。若仍超时,可在 Docker Desktop → Settings → Docker Engine 添加:
```json
{
"registry-mirrors": ["https://docker.m.daocloud.io"]
}
```
保存并重启 Docker 后重试 `.\build.bat -Target docker`。PowerShell 中请用 `.\build.bat`(带 `.\` 前缀)。
**1Panel 部署提示:**
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
2. 端口映射 `3000:3000`
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
4. 首次访问 `http://服务器IP:3000/install` 完成安装
### 3. 直接启动(二进制)
```bash
# Windows
.\dist\jiang13.exe --data .\dist\data
# Linux / macOS
./dist/jiang13 --data ./data
```
默认 SQLite,库文件在 `{DATA}/jiang13.db`。无 `app.ini`。
### 4. 首次使用
1. 浏览器打开 `http://localhost:3000/install`
2. 填写站点名与管理员账号
3. 完成后登录,访问管理后台配置品牌等(热更新,无需重启)
### 配置分层(无 INI)
| 层 | 内容 | 需重启 |
|----|------|--------|
| CLI / Env | 端口、数据目录、数据库类型与 DSN | 是 |
| `data/.jwt_secret`、`.oidc_rsa.pem` | 密钥 | 换密钥需重启 |
| DB `forum_settings` | 品牌、邮件、OIDC 开关、限流、存储… | 否 |
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
### 启动参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--work-path` | 可执行文件目录 | 工作目录 |
| `--port` | `3000` | HTTP 监听端口 |
| `--http-addr` | (空) | 监听地址 |
| `--data` | `data` | 数据目录 |
| `--db-type` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
| `--db-dsn` | (sqlite 默认 `{data}/jiang13.db`) | 完整 DSN |
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
**环境变量:** `JIANG13_HTTP_PORT`、`JIANG13_HTTP_ADDR`、`JIANG13_DATA`、`JIANG13_WORK_PATH`、`JIANG13_DB_TYPE`、`JIANG13_DB_DSN`、以及 `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE`。
PostgreSQL / MySQL 示例见 [`docs/rebuild-spec/07-config-ops.md`](docs/rebuild-spec/07-config-ops.md)。
### 5. 注册为系统服务(可选)
```bash
sudo mkdir -p /opt/jiang13
sudo cp jiang13 /opt/jiang13/
sudo /opt/jiang13/jiang13 --work-path /opt/jiang13 --data /opt/jiang13/data --service install
sudo /opt/jiang13/jiang13 --service start
sudo systemctl enable jiang13
```
**Windows(管理员 PowerShell):**
```powershell
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
Copy-Item .\jiang13.exe C:\jiang13\
C:\jiang13\jiang13.exe --work-path C:\jiang13 --data C:\jiang13\data --service install
C:\jiang13\jiang13.exe --service start
```
> 改端口或 `DB_*` 后执行 `--service restart`(必要时重装服务以更新参数)。日志:`data/jiang13.log`。
---
## 技术栈
| 层级 | 技术 |
|------|------|
| **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite / PostgreSQL / MySQL · `html/template` |
| **渐进资源** | `web_src/`(构建到 `public/assets/`,URL `/ssr-assets/`) |
| **构建** | `web_src` → `go:embed` templates + assets,单二进制发布 |
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
| **对照 SPA** | 仅 `main` 分支(React 18 · TipTap · Vite) |
---
## 本地开发(SSR)
```bat
build.bat -Target run
```
```bash
make run
```
浏览器访问 `http://localhost:3000`。数据目录默认 `dist/data`。
改模板 / Go 后重启进程;改 `web_src` 后需再跑 `build.bat -Target web-src`(或完整 `build`)。
需要对照旧 SPA UI:`git checkout main` 或 `git worktree add ../jiang13-spa main`。
---
## 项目结构
```
jiang13-forum/ # 分支 rebuild/gitea-ssr
├── cmd/jiang13/ # 程序入口(含系统服务注册)
├── config/ # CLI / Env 引导配置(无 INI)
├── Dockerfile # web_src → Go → Alpine
├── docker-compose.yml
├── models/ # GORM 模型
├── services/ # 业务逻辑
├── routers/
│ ├── setup.go # 路由总装
│ ├── web/ # HTML SSR
│ └── api/ # 机器入口(health / OIDC / SEO / thumb)
├── modules/
│ ├── auth/ # JWT / 限流
│ ├── webrender/ # 模板渲染
│ └── seo/
├── templates/ # Go html/template(embed)
├── web_src/ # 渐进 CSS/JS 源码
├── public/assets/ # web_src 构建产物(embed)
├── docs/rebuild-spec/ # 产品规格与 SSR 架构
├── docs/screenshots/
└── ROADMAP.md
```
> SPA 源码树仅存在于 `main`(`frontend/`、`embed_static/`)。
---
## 数据目录
```
data/
├── jiang13.db # SQLite 主数据库
├── jiang13.log # 运行日志
├── filter_words.txt # 敏感词配置
├── .jwt_secret # JWT 密钥(自动生成)
├── uploads/avatars/ # 用户头像
├── uploads/posts/ # 帖子正文图片
└── jiang13_backup_*.db # 后台导出的备份
```
---
## 开发状态
项目**积极开发中**,欢迎参与共建。完整列表见 **[ROADMAP.md](ROADMAP.md)**。
| 类型 | 示例 |
|------|------|
| ✅ 已可用 | 三栏布局、主题、Feed 排序、楼层评论、嵌套树 |
| ✅ 发帖体验 | Markdown 工具栏、图片上传、修订历史、内容门控 |
| ✅ 管理后台 | SSR `/admin/*`(对照 SPA 见 `main`) |
| 📋 计划中 | 见 [ROADMAP.md](ROADMAP.md) / [09-ssr-progress.md](docs/rebuild-spec/09-ssr-progress.md) |
---
## 参与贡献
欢迎提交 Issue 和 Pull Request!详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
在线体验与反馈也可直接在演示站进行:[https://bbs.iioio.com/](https://bbs.iioio.com/)
---
## 许可证
[MIT](LICENSE)(与 [Gitea](https://github.com/go-gitea/gitea) 相同的 Expat 文本格式)— 自由使用、修改与分发。

52
ROADMAP.md Normal file
View File

@@ -0,0 +1,52 @@
# 路线图 ROADMAP
> 姜十三论坛仍在积极开发中。
> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
> **本仓库默认开发分支**:`rebuild/gitea-ssr`(Gitea 式 SSR)。`main` = React SPA 对照。
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
---
## 开发状态概览
| 模块 | 状态 | 说明 |
|------|------|------|
| 公开页 SSR | ✅ | Go `html/template`;首页 / 板块 / 帖详情 / 用户 / 消息等 |
| 管理后台 SSR | ✅ | `/admin/*` 表单;对照 SPA 见 `main` |
| 评论系统 | ✅ | 楼层 + 嵌套树(`ThreadParentID`) |
| Markdown 编辑 | ✅ | 工具栏 + `/compose/preview`(非 TipTap) |
| 主题 | ✅ | 浅色 / 暗色 / 跟随系统 |
| OIDC Provider | ✅ | Discovery / Authorize / Token / UserInfo;Admin 配置面可继续打磨 |
细节进度见 [`docs/rebuild-spec/09-ssr-progress.md`](docs/rebuild-spec/09-ssr-progress.md)。
---
## 🐛 已知缺陷(Bug)
_当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_
---
## 📋 计划中(Planned)
| 优先级 | 功能 | 说明 |
|--------|------|------|
| 中 | OIDC / 存储 Admin 打磨 | SSO 与 S3 热切换运维面 |
| 低 | 编辑器增强 | 表格、表情贴纸(TipTap 不作为本分支默认) |
| 低 | Gitea `/projects` | 仓库列表页(后置) |
---
## ✅ 已完成(摘,本分支)
- [x] Gitea 式目录与 Go 模板 SSR
- [x] 五种帖类型 + 核心闭环(赞/藏/门控/审核…)
- [x] Admin 仪表盘、板块、审核、用户、徽章、媒体、设置等
- [x] Markdown 发帖/评论编辑器与预览
- [x] 评论嵌套树、浅色/暗色主题
- [x] OIDC Provider 机器入口
- [x] 本分支删除未挂载论坛 JSON API(对照 `main`)
更全清单:[`docs/rebuild-spec/02-features.md`](docs/rebuild-spec/02-features.md)。

4
build.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
REM Jiang13 Forum Windows build wrapper (double-click or run from cmd)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
exit /b %ERRORLEVEL%

146
build.ps1 Normal file
View File

@@ -0,0 +1,146 @@
# Jiang13 Forum - Windows build script (replaces GNU Make)
# Usage: .\build.ps1
# .\build.ps1 -Target build-windows
# Branch rebuild/gitea-ssr: Go templates SSR + web_src (no React SPA)
param(
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'web-src', 'tidy', 'run', 'dev', 'clean', 'docker', 'compose-up', 'compose-down', 'help')]
[string]$Target = 'build'
)
$ErrorActionPreference = 'Stop'
$AppName = 'jiang13'
$MainPkg = './cmd/jiang13'
$BuildDir = 'dist'
$DevDataDir = 'dist/data'
$Version = '1.0.0'
try {
$gitSha = (git rev-parse --short HEAD 2>$null)
if ($LASTEXITCODE -eq 0 -and $gitSha) {
$Version = "1.0.0+$gitSha"
}
} catch {}
$RegistryImage = 'hangzhang714128/jiang13-forum'
$Ldlags = "-s -w -X main.version=$Version"
function Ensure-Dir($path) {
if (-not (Test-Path $path)) {
New-Item -ItemType Directory -Path $path | Out-Null
}
}
function Build-WebSrc {
Write-Host '[web_src] npm run build...' -ForegroundColor Cyan
Push-Location web_src
try {
npm run build
if ($LASTEXITCODE -ne 0) { throw 'web_src build failed' }
} finally {
Pop-Location
}
}
function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
Ensure-Dir $BuildDir
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
# 纯 Go SQLite(glebarez),交叉编译无需 C 工具链
$prevCgo = $env:CGO_ENABLED
$env:CGO_ENABLED = '0'
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
$OutFile = "$OutFile.exe"
}
$outPath = Join-Path $BuildDir $OutFile
Write-Host "[go] build -> $outPath (CGO_ENABLED=0)" -ForegroundColor Cyan
try {
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
Write-Host "[ok] $outPath" -ForegroundColor Green
} finally {
if ($null -eq $prevCgo) {
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
} else {
$env:CGO_ENABLED = $prevCgo
}
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
}
}
switch ($Target) {
'help' {
Write-Host '.\build.ps1 build current platform (web_src + go)'
Write-Host '.\build.ps1 -Target web-src SSR progressive assets only'
Write-Host '.\build.ps1 -Target build-windows'
Write-Host '.\build.ps1 -Target build-linux'
Write-Host '.\build.ps1 -Target build-all'
Write-Host '.\build.ps1 -Target run SSR on :3000'
Write-Host '.\build.ps1 -Target dev same as run (SSR; SPA is on main)'
Write-Host '.\build.ps1 -Target tidy'
Write-Host '.\build.ps1 -Target clean'
Write-Host '.\build.ps1 -Target docker'
Write-Host '.\build.ps1 -Target compose-up'
Write-Host '.\build.ps1 -Target compose-down'
Write-Host ''
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
Write-Host 'SPA reference: git checkout main (or origin/main).'
}
'web-src' { Build-WebSrc }
'tidy' { go mod tidy }
'clean' {
if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
Write-Host '[ok] cleaned dist' -ForegroundColor Green
}
'run' {
Ensure-Dir $DevDataDir
Build-WebSrc
go run $MainPkg --work-path . --data $DevDataDir
}
'dev' {
Ensure-Dir $DevDataDir
Build-WebSrc
Write-Host '[dev] SSR: http://localhost:3000 (SPA 对照请 checkout main)' -ForegroundColor Green
go run $MainPkg --work-path . --data $DevDataDir
}
'build' {
Build-WebSrc
Build-Go -OutFile $AppName
}
'build-windows' {
Build-WebSrc
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
}
'build-linux' {
Build-WebSrc
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
}
'build-darwin' {
Build-WebSrc
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
}
'build-all' {
Build-WebSrc
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
Write-Host '[ok] all platforms done' -ForegroundColor Green
}
'docker' {
Write-Host "[docker] build $RegistryImage`:$Version and latest" -ForegroundColor Cyan
docker build --build-arg "VERSION=$Version" -t "${RegistryImage}:$Version" -t "${RegistryImage}:latest" .
if ($LASTEXITCODE -ne 0) { throw 'docker build failed' }
Write-Host '[ok] docker image built' -ForegroundColor Green
}
'compose-up' {
docker compose up -d --build
if ($LASTEXITCODE -ne 0) { throw 'compose up failed' }
}
'compose-down' {
docker compose down
if ($LASTEXITCODE -ne 0) { throw 'compose down failed' }
}
}

46
cmd/jiang13/main.go Normal file
View File

@@ -0,0 +1,46 @@
package main
import (
"fmt"
"log"
"os"
kardsvc "github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config"
)
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
var version = "dev"
func main() {
cfg, err := config.Parse()
if err != nil {
log.Fatalf("配置解析失败: %v", err)
}
svcCfg, err := buildServiceConfig(cfg)
if err != nil {
log.Fatalf("构建服务配置失败: %v", err)
}
prg := &program{cfg: cfg}
svc, err := kardsvc.New(prg, svcCfg)
if err != nil {
log.Fatalf("创建系统服务失败: %v", err)
}
if cfg.ServiceAction != "" {
if err := runServiceControl(svc, cfg.ServiceAction); err != nil {
fmt.Fprintf(os.Stderr, "服务操作失败 (%s): %v\n", cfg.ServiceAction, err)
os.Exit(1)
}
return
}
// 交互终端或由服务管理器拉起时均走 Run:
// Windows Service / systemd 负责生命周期;前台运行时仍响应 Ctrl+C / SIGTERM
if err := svc.Run(); err != nil {
log.Fatalf("运行失败: %v", err)
}
}

175
cmd/jiang13/program.go Normal file
View File

@@ -0,0 +1,175 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
kardsvc "github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/routers"
)
const (
svcName = "jiang13"
svcDisplayName = "姜十三论坛"
svcDescription = "姜十三论坛 Jiang13 Forum — 轻量单二进制论坛服务"
)
// program 实现 kardianos/service.Interface,兼容 Windows Service 与 Linux systemd
type program struct {
cfg *config.Config
server *http.Server
}
func (p *program) Start(s kardsvc.Service) error {
if err := p.setup(); err != nil {
return err
}
go func() {
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP 服务异常: %v", err)
}
}()
return nil
}
func (p *program) Stop(s kardsvc.Service) error {
log.Println("收到关机信号,正在优雅关闭...")
if p.server == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := p.server.Shutdown(ctx); err != nil {
log.Printf("HTTP 服务关闭异常: %v", err)
return err
}
log.Println("姜十三论坛已安全退出")
return nil
}
func (p *program) setup() error {
cfg := p.cfg
cfg.Version = version
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("打开日志文件失败: %w", err)
}
// 服务模式下 stdout 可能不可用,仅写文件;前台运行则双写
if kardsvc.Interactive() {
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
} else {
log.SetOutput(logFile)
}
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Println("========================================")
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
log.Printf(" 版本: %s", version)
log.Println("========================================")
if err := models.InitDB(models.DatabaseConfig{
Type: cfg.DB.Type,
DSN: cfg.DB.DSN,
SQLitePath: cfg.DB.SQLitePath,
MaxOpenConns: cfg.DB.MaxOpenConns,
MaxIdleConns: cfg.DB.MaxIdleConns,
ConnMaxLifetimeSec: cfg.DB.ConnMaxLifetimeSec,
}); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err)
}
engine, err := routers.Setup(cfg)
if err != nil {
return fmt.Errorf("路由初始化失败: %w", err)
}
addr := cfg.ListenAddr()
p.server = &http.Server{
Addr: addr,
Handler: engine,
}
log.Printf("姜十三论坛已启动: http://localhost:%d", cfg.Port)
log.Printf("后台管理地址: http://localhost:%d/admin/dashboard", cfg.Port)
log.Printf("工作目录: %s", cfg.WorkPath)
log.Printf("数据目录: %s", cfg.DataDir)
log.Printf("数据库: %s", cfg.DB.Type)
return nil
}
func buildServiceConfig(cfg *config.Config) (*kardsvc.Config, error) {
// 服务绑定工作目录与数据目录;改端口 / DB_* 需重启进程(可用 Env 或重装服务参数)
args := []string{
"--work-path", cfg.WorkPath,
"--data", cfg.DataDir,
"--port", fmt.Sprintf("%d", cfg.Port),
"--db-type", cfg.DB.Type,
}
if cfg.DB.Type == config.DBTypeSQLite {
args = append(args, "--db-dsn", cfg.DB.SQLitePath)
} else if cfg.DB.DSN != "" {
args = append(args, "--db-dsn", cfg.DB.DSN)
}
return &kardsvc.Config{
Name: svcName,
DisplayName: svcDisplayName,
Description: svcDescription,
WorkingDirectory: cfg.WorkPath,
Arguments: args,
Option: kardsvc.KeyValue{
// systemd:异常退出后自动拉起
"Restart": "always",
// Windows:崩溃后重启
"OnFailure": "restart",
},
}, nil
}
func runServiceControl(s kardsvc.Service, action string) error {
if action == "status" {
st, err := s.Status()
if err != nil {
return err
}
switch st {
case kardsvc.StatusRunning:
fmt.Println("服务状态: 运行中 (running)")
case kardsvc.StatusStopped:
fmt.Println("服务状态: 已停止 (stopped)")
default:
fmt.Println("服务状态: 未知 (unknown)")
}
return nil
}
if err := kardsvc.Control(s, action); err != nil {
return err
}
switch action {
case "install":
fmt.Println("服务已安装。可用以下命令启动:")
fmt.Printf(" %s --service start\n", os.Args[0])
fmt.Println("或使用系统工具:")
fmt.Println(" Linux: sudo systemctl start jiang13 && sudo systemctl enable jiang13")
fmt.Println(" Windows: Start-Service jiang13")
case "uninstall":
fmt.Println("服务已卸载")
case "start":
fmt.Println("服务已启动")
case "stop":
fmt.Println("服务已停止")
case "restart":
fmt.Println("服务已重启")
}
return nil
}

341
config/config.go Normal file
View File

@@ -0,0 +1,341 @@
package config
import (
"crypto/rand"
"encoding/base64"
"flag"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)
const (
defaultPort = 3000
defaultDataRel = "data"
DBTypeSQLite = "sqlite"
DBTypePostgres = "postgres"
DBTypeMySQL = "mysql"
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
StorageTypeLocal = "local"
StorageTypeS3 = "s3"
)
// DatabaseConfig 数据库引导配置(需重启)
type DatabaseConfig struct {
Type string // sqlite | postgres | mysql
DSN string // 非空则优先
Host string
User string
Password string
Name string
SSLMode string // postgres
// SQLite 文件路径(Type=sqlite 时由 DataDir 推导或 DSN)
SQLitePath string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetimeSec int
}
// Config 进程引导配置:仅 CLI / 环境变量(无 INI)
type Config struct {
WorkPath string
HTTPAddr string // 空表示 0.0.0.0
Port int
DataDir string
JWTSecret string
LogFile string
ServiceAction string
DevMode bool
Version string // 构建版本(ldflags);用于 /ssr-assets ?v= 缓存穿透
DB DatabaseConfig
}
// Parse 解析命令行与环境变量并准备数据目录
// 优先级:命令行显式参数 > 环境变量 > 内置默认
func Parse() (*Config, error) {
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
portFlag := flag.Int("port", 0, "HTTP 监听端口(0 表示用环境变量或默认 3000)")
addrFlag := flag.String("http-addr", "", "HTTP 监听地址(默认空=全接口)")
dataFlag := flag.String("data", "", "数据存储目录")
dbTypeFlag := flag.String("db-type", "", "数据库类型:sqlite|postgres|mysql")
dbDSNFlag := flag.String("db-dsn", "", "数据库 DSN(优先于拆分参数)")
dbHostFlag := flag.String("db-host", "", "数据库主机")
dbUserFlag := flag.String("db-user", "", "数据库用户")
dbPassFlag := flag.String("db-pass", "", "数据库密码")
dbNameFlag := flag.String("db-name", "", "数据库名")
dbSSLFlag := flag.String("db-sslmode", "", "PostgreSQL sslmode")
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
devFlag := flag.Bool("dev", false, "开发模式")
_ = flag.String("config", "", "已废弃:不再使用 ini 配置文件")
_ = flag.String("jwt-secret", "", "已废弃:JWT 仅使用 data/.jwt_secret")
flag.Parse()
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
if action != "" && !validServiceAction(action) {
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
}
workPathInput := firstNonEmpty(*workFlag, envOrDefault(envWorkPath))
workPath, err := resolveWorkPath(workPathInput)
if err != nil {
return nil, err
}
port := defaultPort
if p := envIntOrZero(envHTTPPort); p > 0 {
port = p
}
if *portFlag > 0 {
port = *portFlag
}
httpAddr := firstNonEmpty(*addrFlag, envOrDefault(envHTTPAddr))
dataInput := firstNonEmpty(*dataFlag, envOrDefault(envData), defaultDataRel)
absData, err := absPath(workPath, dataInput)
if err != nil {
return nil, fmt.Errorf("解析数据目录失败: %w", err)
}
dbCfg, err := buildDatabaseConfig(absData, dbFlags{
Type: *dbTypeFlag, DSN: *dbDSNFlag, Host: *dbHostFlag,
User: *dbUserFlag, Pass: *dbPassFlag, Name: *dbNameFlag, SSL: *dbSSLFlag,
})
if err != nil {
return nil, err
}
cfg := &Config{
WorkPath: workPath,
HTTPAddr: httpAddr,
Port: port,
DataDir: absData,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
DevMode: *devFlag,
DB: dbCfg,
}
needDirs := action == "" || action == "install"
if needDirs {
if err := ensureDataDirs(absData); err != nil {
return nil, err
}
if err := cfg.resolveJWT(); err != nil {
return nil, err
}
}
return cfg, nil
}
type dbFlags struct {
Type, DSN, Host, User, Pass, Name, SSL string
}
func buildDatabaseConfig(dataDir string, f dbFlags) (DatabaseConfig, error) {
typ := strings.ToLower(firstNonEmpty(f.Type, envOrDefault(envDBType), DBTypeSQLite))
switch typ {
case "sqlite", "sqlite3":
typ = DBTypeSQLite
case "postgres", "postgresql", "pg":
typ = DBTypePostgres
case "mysql", "mariadb":
typ = DBTypeMySQL
default:
return DatabaseConfig{}, fmt.Errorf("不支持的数据库类型 %q,可选:sqlite|postgres|mysql", typ)
}
out := DatabaseConfig{
Type: typ,
DSN: firstNonEmpty(f.DSN, envOrDefault(envDBDSN)),
Host: firstNonEmpty(f.Host, envOrDefault(envDBHost)),
User: firstNonEmpty(f.User, envOrDefault(envDBUser)),
Password: firstNonEmpty(f.Pass, envOrDefault(envDBPass)),
Name: firstNonEmpty(f.Name, envOrDefault(envDBName)),
SSLMode: firstNonEmpty(f.SSL, envOrDefault(envDBSSLMode), "disable"),
MaxOpenConns: envIntDefault(envDBMaxOpen, 0),
MaxIdleConns: envIntDefault(envDBMaxIdle, 0),
ConnMaxLifetimeSec: envIntDefault(envDBConnLife, 0),
}
if typ == DBTypeSQLite {
if out.DSN != "" {
out.SQLitePath = out.DSN
} else {
out.SQLitePath = filepath.Join(dataDir, "jiang13.db")
out.DSN = out.SQLitePath
}
if out.MaxOpenConns == 0 {
out.MaxOpenConns = 1
}
if out.MaxIdleConns == 0 {
out.MaxIdleConns = 1
}
return out, nil
}
if out.DSN == "" {
dsn, err := buildDSN(out)
if err != nil {
return DatabaseConfig{}, err
}
out.DSN = dsn
}
if out.MaxOpenConns == 0 {
out.MaxOpenConns = 25
}
if out.MaxIdleConns == 0 {
out.MaxIdleConns = 5
}
if out.ConnMaxLifetimeSec == 0 {
out.ConnMaxLifetimeSec = 300
}
return out, nil
}
func buildDSN(c DatabaseConfig) (string, error) {
if c.Host == "" || c.User == "" || c.Name == "" {
return "", fmt.Errorf("%s 需要 JIANG13_DB_DSN,或 JIANG13_DB_HOST/USER/NAME(及可选 PASS)", c.Type)
}
switch c.Type {
case DBTypePostgres:
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(c.User, c.Password),
Host: c.Host,
Path: "/" + c.Name,
}
q := url.Values{}
q.Set("sslmode", c.SSLMode)
u.RawQuery = q.Encode()
return u.String(), nil
case DBTypeMySQL:
// 特殊字符密码请直接用 JIANG13_DB_DSN;此处为拆分参数简易拼接
return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true&loc=Local&charset=utf8mb4",
c.User, c.Password, c.Host, c.Name), nil
default:
return "", fmt.Errorf("无法为 %s 拼接 DSN", c.Type)
}
}
func resolveWorkPath(flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
abs, err := filepath.Abs(flagVal)
if err != nil {
return "", fmt.Errorf("解析工作目录失败: %w", err)
}
return filepath.Clean(abs), nil
}
return defaultWorkPath()
}
func ensureDataDirs(dataDir string) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("创建数据目录失败: %w", err)
}
for _, sub := range []string{
filepath.Join(dataDir, "uploads", "avatars"),
filepath.Join(dataDir, "uploads", "posts"),
filepath.Join(dataDir, "uploads", "site"),
} {
if err := os.MkdirAll(sub, 0755); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err)
}
}
return nil
}
func (c *Config) resolveJWT() error {
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
if data, err := os.ReadFile(secretFile); err == nil && len(bytesTrimSpace(data)) > 0 {
c.JWTSecret = string(bytesTrimSpace(data))
return nil
}
sec, err := generateRandomSecret(32)
if err != nil {
return err
}
c.JWTSecret = sec
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
}
return nil
}
func bytesTrimSpace(b []byte) []byte {
return []byte(strings.TrimSpace(string(b)))
}
func validServiceAction(action string) bool {
switch action {
case "install", "uninstall", "start", "stop", "restart", "status":
return true
default:
return false
}
}
// ListenAddr 返回 host:port
func (c *Config) ListenAddr() string {
if c.HTTPAddr == "" {
return fmt.Sprintf(":%d", c.Port)
}
return fmt.Sprintf("%s:%d", c.HTTPAddr, c.Port)
}
// SQLitePath 兼容旧调用:仅 sqlite 有意义
func (c *Config) DBPath() string {
if c.DB.Type == DBTypeSQLite {
return c.DB.SQLitePath
}
return c.DB.DSN
}
func (c *Config) AvatarUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "avatars")
}
func (c *Config) PostImageUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "posts")
}
func (c *Config) SiteUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "site")
}
func (c *Config) FilterWordsPath() string {
return filepath.Join(c.DataDir, "filter_words.txt")
}
func generateRandomSecret(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("生成密钥失败: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func envIntDefault(key string, def int) int {
v := envOrDefault(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return def
}
return n
}

41
config/env.go Normal file
View File

@@ -0,0 +1,41 @@
package config
import (
"os"
"strconv"
"strings"
)
// 引导环境变量(无 INI)
const (
envWorkPath = "JIANG13_WORK_PATH"
envHTTPPort = "JIANG13_HTTP_PORT"
envHTTPAddr = "JIANG13_HTTP_ADDR"
envData = "JIANG13_DATA"
envDBType = "JIANG13_DB_TYPE"
envDBDSN = "JIANG13_DB_DSN"
envDBHost = "JIANG13_DB_HOST"
envDBUser = "JIANG13_DB_USER"
envDBPass = "JIANG13_DB_PASS"
envDBName = "JIANG13_DB_NAME"
envDBSSLMode = "JIANG13_DB_SSLMODE"
envDBMaxOpen = "JIANG13_DB_MAX_OPEN"
envDBMaxIdle = "JIANG13_DB_MAX_IDLE"
envDBConnLife = "JIANG13_DB_CONN_MAX_LIFETIME_SEC"
)
func envOrDefault(key string) string {
return strings.TrimSpace(os.Getenv(key))
}
func envIntOrZero(key string) int {
v := envOrDefault(key)
if v == "" {
return 0
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 || n > 65535 {
return 0
}
return n
}

69
config/paths.go Normal file
View File

@@ -0,0 +1,69 @@
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// resolveAppPath 返回可执行文件的绝对路径(解析符号链接)
func resolveAppPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("获取可执行文件路径失败: %w", err)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
return "", fmt.Errorf("解析可执行文件路径失败: %w", err)
}
return exe, nil
}
// defaultWorkPath 类似 Gitea:默认可执行文件所在目录;
// go run 时二进制在临时目录,回退为当前工作目录。
func defaultWorkPath() (string, error) {
exe, err := resolveAppPath()
if err != nil {
return "", err
}
dir := filepath.Dir(exe)
if isEphemeralExeDir(dir) {
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("获取当前目录失败: %w", err)
}
return filepath.Clean(wd), nil
}
return dir, nil
}
func isEphemeralExeDir(dir string) bool {
lower := strings.ToLower(filepath.Clean(dir))
sep := string(filepath.Separator)
markers := []string{
sep + "go-build",
sep + "go-run",
}
if runtime.GOOS == "windows" {
markers = append(markers, "\\go-build", "\\go-run")
}
for _, m := range markers {
if strings.Contains(lower, strings.ToLower(m)) {
return true
}
}
return false
}
func absPath(base, p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", fmt.Errorf("路径为空")
}
if filepath.IsAbs(p) {
return filepath.Clean(p), nil
}
return filepath.Abs(filepath.Join(base, p))
}

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
# 姜十三论坛 — Docker Compose 单服务部署
# 启动:docker compose up -d --build
# 或:make compose-up / build.bat -Target compose-up
#
# 默认 SQLite(数据在 /data)。换库示例:
# JIANG13_DB_TYPE=postgres
# JIANG13_DB_DSN=postgres://forum:secret@host:5432/jiang13?sslmode=disable
# JIANG13_DB_TYPE=mysql
# JIANG13_DB_DSN=forum:secret@tcp(host:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4
services:
jiang13:
image: hangzhang714128/jiang13-forum:latest
build:
context: .
dockerfile: Dockerfile
args:
VERSION: dev
ports:
- "3000:3000"
volumes:
- jiang13-data:/data
environment:
TZ: Asia/Shanghai
# JIANG13_DB_TYPE: sqlite
# JIANG13_HTTP_PORT: "3000"
restart: unless-stopped
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
jiang13-data:

15
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -e
# 宿主机 / 1Panel 挂载的 volume 常为 root 所有(uid 0),
# 而应用以 jiang13(uid 1000)运行,需在启动时修正 /data 权限后降权。
DATA_DIR="/data"
if [ "$(id -u)" = '0' ]; then
mkdir -p "$DATA_DIR"
chown -R jiang13:jiang13 "$DATA_DIR"
exec su-exec jiang13 /app/jiang13 "$@"
fi
exec /app/jiang13 "$@"

158
docs/introduction.md Normal file
View File

@@ -0,0 +1,158 @@
# 姜十三论坛:为小圈子而生的轻量论坛
> 轻量 · 好看 · 单文件部署
> 面向内部交流,不堆功能,把「能聊、好看、好装」做到位。
---
## 它是什么
**姜十三论坛(Jiang13 Forum)** 是一款面向小圈子、团队与兴趣社群的现代化论坛软件。
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:公开页用 **Go `html/template` 真 SSR**,`web_src` 做渐进增强 CSS/JS(`go:embed`),数据库默认 **SQLite**,拷贝到服务器就能跑。
---
## 为什么做它
市面上的论坛方案往往落在两端:
- **重型产品**(Discourse、phpBB 等):功能完整,但部署与运维成本高,对小团队过重。
- **即时通讯 / 文档工具**:适合聊天与协作,却缺少「发帖—回复—沉淀」的社区节奏。
小圈子真正需要的,常常是中间态:
- 有板块、帖子、楼层回复,讨论可追溯;
- 界面清爽、信息密度够高,桌面与手机都好用;
- 部署简单到「一个文件 + 一份配置」;
- 数据在自己手里,备份一眼能懂。
姜十三论坛正是为这个缺口而生。
---
## 谁适合用
| 场景 | 说明 |
|------|------|
| 团队 / 工作室内部论坛 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 开源 / 私有项目配套社区 | 与 Gitea 等工具通过 OIDC(开放身份连接)做 SSO(单点登录) |
| 想自己托管的个人站长 | 单机即可,无需云数据库与一堆微服务 |
如果你需要百万用户级的公网社区、复杂插件生态或企业级工单流,它可能不是最优选;如果你要的是 **小而美、自己能装、自己能管**,它会对胃口。
---
## 用起来是什么感觉
### 浏览:高密度、不臃肿
前台采用熟悉的 **三栏布局**:
- **左栏**:板块导航,可折叠;
- **中间**:帖子 Feed(信息流),支持虚拟滚动,长列表依然流畅;
- **右栏**:热门帖与最新评论,社区动态一目了然。
Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗色主题一键切换,并会记住你的偏好。平板与手机上侧栏会自动收起,触控浏览同样顺手。
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
### 发帖:Markdown,够用就好
发帖使用 **Markdown 工具栏**(标题、粗斜体、列表、代码块、图片上传、内容门控插入)与服务端预览;标签、板块选择日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
### 讨论:楼层回复,聊得清楚
评论是楼层式的:可回复指定楼层、引用原文;支持嵌套展示。点赞、收藏、热门帖,把活跃内容自然推到前面。
### 管理:SSR 后台
管理后台统一在 `/admin`,与公开页同一套模板布局:
- 仪表盘、板块与帖子管理
- 用户禁言、删帖删评
- 论坛参数、限流、敏感词
- SQLite **一键备份**
权限模型很简单:**普通用户 / 管理员**;首次通过 **安装向导** 创建管理员账号。
---
## 部署:一个文件,真正开箱
这大概是姜十三论坛最「硬核」的卖点之一。
```text
编译 → 得到一个 jiang13(或 jiang13.exe)
放到目录里运行 → Env / CLI 引导(默认 SQLite)
打开浏览器完成安装向导 → 创建管理员
```
要点:
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
- **零外部数据库**:SQLite 落在数据目录,备份就是拷贝文件;
- **Env / CLI 引导**:端口、数据目录、数据库类型;业务项在管理后台热更新;
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
- **跨平台**:Windows / Linux / macOS 均可编译与运行。
典型启动后访问 `http://localhost:3000`,完成安装后即可开始。
---
## 技术素描(给好奇的人)
| 层级 | 技术 |
|------|------|
| 后端 / SSR | Go · Gin · GORM · SQLite · `html/template` |
| 渐进资源 | `web_src` → `public/assets`(`/ssr-assets/`) |
| 构建 | `web_src` + `go:embed` templates/assets,单二进制 |
| 认证 | bcrypt + 会话 Cookie;可选 OIDC Provider,对接 Gitea 等 SSO |
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:改模板/Go 后重启进程;改 `web_src` 后跑 `build.bat -Target web-src`。
---
## 现在走到哪一步
项目仍在积极开发中,核心体验已经可用(分支 `rebuild/gitea-ssr`):
- ✅ 三栏布局、主题切换、Feed 排序、真 SSR 公开页
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史(Markdown 编辑器)
- ✅ SSR 管理后台与论坛参数配置
- ✅ OIDC Provider(可作 Gitea 等站点的登录源)
- ✅ 单二进制部署与系统服务
计划中的方向见仓库 [ROADMAP.md](../ROADMAP.md) 与 [09-ssr-progress.md](rebuild-spec/09-ssr-progress.md)。
---
## 一句话总结
**姜十三论坛** = 小圈子论坛该有的功能 × 清新好用的界面 × 真正能单文件带走的部署方式。
如果你正在给团队或同好找一个「自己的小论坛」,不妨编译跑一下:注册第一个账号,从发第一帖开始。
---
## 快速上手
```bash
# Windows
.\build.bat
# Linux / macOS
make build
# 运行
./dist/jiang13 # 或 Windows: .\dist\jiang13.exe
```
浏览器打开 `http://localhost:3000/register`,第一个注册用户即为管理员。
更多细节(配置项、系统服务、前端开发)见项目 [README](../README.md)。欢迎通过 Issue / PR 参与共建。
**许可证:** MIT

65
docs/issue-templates.md Normal file
View File

@@ -0,0 +1,65 @@
# Issue 预填模板
以下可直接复制到本仓库 Issues 创建。本分支为 **SSR**(`rebuild/gitea-ssr`);对照 SPA 请注明 `main`。
---
## Issue · Bug 模板
**标题:** `[Bug] 简短描述`
**标签:** `bug` `ui/ux`
**正文:**
### 问题描述
(一句话)
### 复现步骤
1. …
2. …
### 期望行为 / 实际行为
### 相关代码(若已知)
- 模板:`templates/…`
- 路由:`routers/web/…`
- 样式/脚本:`web_src/…`
### 环境
- 分支:`rebuild/gitea-ssr`(或 `main` SPA)
- 浏览器 / OS
---
## Issue · 功能增强模板
**标题:** `[Feature] 简短描述`
**标签:** `enhancement`
**正文:**
### 要解决的问题
### 现状
| 能力 | 状态 |
| --- | --- |
| 规格(`docs/rebuild-spec`) | ✅ / ❌ |
| SSR 页面 / 表单 | ✅ / ❌ |
| 业务 `services/` | ✅ / ❌ |
### 期望方案
1. …
2. …
### 相关代码
- `routers/web/`、`templates/`、`services/`
- 规格:`docs/rebuild-spec/02-features.md` / `06-pages-ux.md`

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 帖子
- 富文本正文(HTML,TipTap 产出)+ 可选 Markdown 编辑面
- 标签、修订历史与 diff
- 五种类型:`normal` | `question` | `poll` | `bounty` | `lottery`
- 运营标记:全局置顶、版内置顶、精华、禁止编辑、禁止评论
- 审核状态:`pending` | `published` | `rejected`;软删回收站
### 4.4 内容门控
正文内嵌自定义标签(非独立表行,存在 `posts.content` HTML 中):
| 标签 | 含义 |
|------|------|
| `<members-only>` | 登录可见 |
| `<reply-only>` | 本帖已回复可见 |
| `<points-only data-cost="N">` | 积分解锁;按块计费 |
### 4.5 评论
- 楼层号;回复指定楼;嵌套展示;引用;@ 提及
- 游客评论字段;私密评论(仅相关人可见)
- 点赞、编辑时限、审核、软删
### 4.6 积分经济与成长
- **Points**:可消费积分(签到、抽奖、解锁、悬赏托管等)
- **Exp**:不可消费经验 → 等级 Lv1–10
- **CreatorIncomeTotal**:创作分成累计(徽章指标)
- 徽章:自动(门槛)+ 限定(管理员发放)
### 4.7 私信与通知
统一走 `private_messages` 表,用 `kind` 区分用户私信与系统事件。
### 4.8 友链与站点页
- 管理员维护品牌友链 JSON;用户申请审核(可选回链检测)
- 自定义单页(关于、版规等):slug、发布、导航/页脚展示
### 4.9 Gitea 开源码桶
后台配置后定时同步公开仓库到 `gitea_repos`,前台 `/projects` 展示。
### 4.10 OIDC Provider
本站作为 IdP:Discovery / Authorize / Token / UserInfo / Logout / JWKS;多 OAuth 客户端。
### 4.11 管理后台
仪表盘、板块、单页、友链、帖/评、举报、用户、徽章、媒体、系统设置、SQLite 备份。
### 4.12 SEO / 发现
`robots.txt`、`sitemap.xml`、Open Graph / Twitter / JSON-LD;当前另有爬虫 HTML。新站应用 SSR 统一,但 **meta 字段集合应保留**(见 [07-config-ops.md](07-config-ops.md))。
---
## 5. 权限一句话
- **读公开内容**:人人可(含游客)
- **写内容**:登录(部分评论允许游客)
- **审与运营**:管理员
- **免审写**:管理员或 `verified`
细节状态机见 [05-business-rules.md](05-business-rules.md)。

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`]((仅 main)frontend/src/App.tsx)、[`router/router.go`](../../routers/setup.go)、[`README.md`](../../README.md)
用复选框做验收;重构完成时应全部可勾选(或书面声明砍掉的功能)。
---
## A. 浏览与布局
- [x] 三栏布局:左导航 / 中 Feed / 右栏小组件 — SSR(桌面;≤900px 隐藏右栏)
- [x] 浅色 / 暗色主题;跟随系统偏好并本地记忆 — CSS `prefers-color-scheme` + `data-theme` 显式覆盖;顶栏循环:跟随系统 / 浅色 / 暗色(`j13-theme`)
- [ ] 响应式:平板/手机收起侧栏
- [ ] 长列表虚拟滚动或等价流畅方案
- [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
- [x] 板块筛选:全部 + 单板块
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`)
- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin `/admin/settings` 侧栏组件编辑器
- [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`)
- [ ] 下拉刷新(移动端)
- [x] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)— SSR `/admin/settings` 伪静态;公开路径 301 规范 URL
- [x] 404 页
---
## B. 认证与个人中心
- [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register`
- [x] 图形验证码接口(注册流程)— SSR 注册页强制;`GET /api/captcha` + `POST /api/register` 校验
- [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销
- [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪)
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
- [x] 个人中心:改昵称、签名、密码、上传头像、积分钱包/签到/抽奖 — SSR `/profile`(裁剪未做)
- [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id`
- [x] 公开用户主页 `/user/:id`(无邮箱)
- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
---
## C. 板块
- [x] 列出板块(含帖数等展示字段)— SSR `/boards` + 侧栏 + Admin
- [x] 管理员:创建 / 改 / 删板块 — SSR `/admin/boards`
- [x] 板块名称、描述、图标、色板索引、排序
- [x] 默认板块保障(空站可引导创建)
---
## D. 帖子(通用)
- [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`(normal)
- [x] 正文图片上传 — `/compose/upload` + Markdown 插入
- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— **本分支不做 TipTap**;改用 Markdown 工具栏渐进增强
- [x] Markdown 编辑(工具栏 + `/compose/preview`;发帖/改帖/评论共用 `shared/md_editor`)— 非 TipTap 双模
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
- [x] 删除帖子 → 软删进回收站 — SSR Admin(帖详情 + `/admin/trash`)
- [x] 修订历史列表与单条详情(可做 diff)— SSR `/post/:id/revisions`(作者/管理员;无 diff)
- [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites`
- [x] 浏览量 — 详情页计数
- [x] 举报帖子 — SSR 表单 + `/admin/reports`
- [x] 内容审核状态展示(作者可见待审/被拒)— SSR 帖详情横幅 + 评论楼层标签
### D.1 帖子类型
- [x] `normal` 普通讨论 — SSR compose 默认
- [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换
- [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
- [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)— SSR compose + 详情条 + award/refund
- [x] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 — SSR compose + 详情卡 + draw
### D.2 运营标记(管理员)
- [x] 全局置顶 / 取消 — SSR 帖详情
- [x] 版内置顶 / 取消(仅板块列表抬升)— SSR 帖详情
- [x] 精华 / 取消 — SSR 帖详情
- [x] 禁止编辑(edit lock)— SSR 帖详情
- [x] 禁止评论 / 结贴(comments lock)— SSR 帖详情
- [x] 审核通过 / 拒绝(拒绝可通知作者)— SSR `/admin/moderation`
- [x] 回收站:恢复 / 彻底删除 — SSR `/admin/trash`
---
## E. 内容门控
- [x] 编辑器可插入「登录可见」块 — compose 工具栏
- [x] 编辑器可插入「回复可见」块 — compose 工具栏
- [x] 编辑器可插入「积分可见」块(可设价格)— compose 工具栏 + prompt
- [x] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 — 锁定壳 UI
- [x] 已登录未回复:遮盖 reply-only(作者与管理员始终可见)
- [x] 积分块:未解锁遮盖;`POST /post/:id/unlock` 扣积分并返回 inner HTML
- [x] 搜索 / SEO 出口对门控内容做红action,不泄露正文(既有)
---
## F. 评论
- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;`ThreadParentID` 嵌套树 + 全局 `#floor` 缩进
- [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments`
- [ ] 游客评论(公开接口可写,字段 guest_*)
- [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站
- [x] 评论点赞 — `POST /post/:id/comments/:cid/like`
- [x] 评论举报 — SSR
- [x] @ 提及 → 通知 — SSR 发评/审通调用 `NotifyCommentMentions`
- [x] 回复提醒(站内信 + 可选邮件)— SSR 发评/审通调用 `NotifyCommentPublished`
- [x] 审核中 / 被拒评论可见性规则 — 服务层已有;SSR 楼层标签(作者/管理员)
- [x] 管理员:通过 / 拒绝待审评论 — SSR `/admin/moderation`(回收站/修订未迁)
---
## G. 私信与通知
- [x] 会话列表(含系统会话 peer=0)— SSR `/messages`
- [x] 会话消息(分页 / before 游标)— SSR `/messages/with/:peerId`
- [x] 发送私信 — 表单 PRG;用户主页「发私信」入口
- [x] 未读数(可分私信 / 通知)— 列表分项 + 导航角标
- [x] 标记会话已读 / 通知已读 / 全部已读 — 打开会话自动已读;全部标已读
- [x] 系统通知种类:`system` / `reject` / `report_result` / `reply` / `mention` / `moderation` 等(写入已有;`/messages/with/0?kind=` 筛选)
---
## H. 积分、签到、抽奖、徽章、等级
- [x] 积分流水查询 — SSR `/profile` 钱包近 N 条
- [x] 每日签到:基础 5,连签每日 +1,封顶 15 — `POST /profile/checkin`
- [x] 每日抽奖:奖池加权(0/2/5/10/20),成本 0 — `POST /profile/lottery`
- [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务
- [x] 短龄同 IP 互刷拒绝分成 — `UnlockPointsBlock` / `suspiciousUnlockPair`(双方注册未满 7 天且 LastLoginIP 相同)
- [x] Exp → 等级 Lv1–10 — 门槛推导 + 发帖/评论/获赞加 Exp;Admin 设等级 SSR `/admin/users`
- [x] 自动徽章(注册天数 / 获赞 / 创作分成)— 种子 + `EvaluateAuto`(用户页/资料页访问时评估);无独立定时调度 UI
- [x] 限定徽章:管理员定义与授予/撤销 — SSR `/admin/badges`
- [x] 管理员调整积分、设定认证(免审)、设定等级 — SSR `/admin/users`
---
## I. 友链
- [x] 前台友链页;导航/页脚入口可配 — SSR `/links`;`nav_show_friend_links` / `footer_show_friend_links`
- [x] 用户申请(名称、URL、Logo、是否上首页、回链页)— `POST /links/apply`
- [x] Logo 上传 — 表单 multipart 或 `POST /links/logo`
- [x] 我的申请列表;取消待审 — `/links`(修改待审:取消后重提)
- [x] 管理员审核:通过 / 拒绝 / 回链复检 — SSR `/admin/friend-links`(创建时可异步检测)
- [x] 管理员维护品牌友链列表 — `/admin/friend-links` 增删
---
## J. 站点单页
- [x] 公开:`/page/:slug` 列表入口(nav/footer)— SSR
- [x] 管理员 CRUD;发布开关;排序;nav/footer 展示开关 — SSR `/admin/pages`
---
## K. Gitea 码桶(**后置**)
> 本迭代**不做**产品化同步:不启后台定时任务、不挂管理入口。表结构与 settings 键可保留兼容。
- [ ] (后置)后台开关、Base URL、Token、同步间隔
- [ ] (后置)手动同步 + 后台定时同步
- [ ] (后置)前台 `/projects` 列表与搜索
---
## L. OIDC Provider
- [ ] Discovery、JWKS、Authorize、Token、UserInfo、Logout
- [ ] 多 OAuth 客户端 CRUD;密钥哈希存储;PKCE 字段支持
- [ ] groups claim 映射 admin/user 组
---
## M. 媒体与存储
- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置)— 后端已支持;Admin 热切换 UI 未迁
- [x] 头像 / 帖图 / 站点品牌资源分类 — 上传路径 + Admin 分类筛选
- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`)— 路由已有;全站默认策略未产品化
- [x] 管理后台媒体列表与删除 — SSR `/admin/media`
- [x] 媒体索引表同步 — 启动扫盘 + Admin「同步索引」
---
## N. 管理后台其它
- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard`
- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
- [x] 基础限流(post/comment/register/login/window)— SSR `/admin/settings`
- [x] 完整 Limits(字数 / 编辑窗 / 分页 / 签名头像 / Feed 样式 / 新标签)— SSR `/admin/settings/content-limits`
- [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区
- [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings`
- [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear`;`<head>` favicon + og:image
- [x] SQLite 一键备份与下载 — SSR `/admin/settings`(仅 sqlite;写入 data 目录后下载)
---
## O. 基础设施
- [x] `GET /health` — `routers/setup.go`
- [x] `robots.txt` / `sitemap.xml` — SSR 同源路径
- [x] 静态上传文件可达 — `Static /uploads` → data/uploads
- [x] 限流:发帖、评论、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`
---
## 明确不在当前规格内(计划中可后做)
摘自 [`ROADMAP.md`](../../ROADMAP.md),**不是**现网必交验收项:
- 通知动态 UX 大幅优化
- 帖子搜索增强(组合筛选更强)
若新站一并实现,可作为加分项,不阻塞「功能对等」验收。

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

391
docs/rebuild-spec/04-api.md Normal file
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, before;peerId=0 为系统 |
| POST | `/api/messages/conversations/:peerId/read` | |
| POST | `/api/messages` | JSON `{ to_user_id, subject?, content }` |
| POST | `/api/messages/read-all` | |
### 5.5 经济
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/me/points` | page;含 ledger、check_in、lottery |
| GET/POST | `/api/me/check-in` | 状态 / 执行签到 |
| GET/POST | `/api/me/lottery` | 状态 / 抽奖 |
### 5.6 友链申请
| 方法 | 路径 | Body |
|------|------|------|
| POST | `/api/friend-links/apply` | JSON name, url, logo, link_on_homepage, reciprocal_page_url? |
| POST | `/api/friend-links/logo` | Form logo=file → `{ url }` |
| GET | `/api/friend-links/my-applies` | |
| PUT | `/api/friend-links/applies/:id` | 同申请字段 |
| DELETE | `/api/friend-links/applies/:id` | 取消 |
---
## 6. 管理 API(`/api/admin` + Auth + Admin)
### 6.1 仪表盘与设置
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/dashboard` | AdminDashboard |
| GET | `/settings` | AdminSettings 聚合 |
| PUT | `/settings/forum` | ForumLimits |
| PUT | `/settings/mail` | MailConfig |
| POST | `/settings/mail/test` | `{ to }` |
| PUT | `/settings/oidc` | OIDCConfig |
| PUT | `/settings/gitea` | **后置**(501) |
| POST | `/settings/gitea/sync` | **后置**(501) |
| PUT | `/settings/storage` | StorageConfig |
| PUT | `/settings/branding` | SiteBranding |
| POST | `/settings/branding/upload` | Form kind=`logo`\|`favicon`\|`og_image`, file |
| POST | `/settings/branding/clear` | JSON `{ kind }` |
| GET/PUT | `/settings/filter-words` | GET 读;PUT `{ content }` |
(上表路径均相对于 `/api/admin`。)
### 6.2 OAuth 客户端
| 方法 | 路径 |
|------|------|
| GET/POST | `/oauth/clients` |
| PUT/DELETE | `/oauth/clients/:id` |
创建/更新 body:`name`, `redirect_uris`, `client_id?`, `enabled?`, `client_secret?`, `rotate_secret?`。
### 6.3 板块 / 单页 / 友链
| 方法 | 路径 |
|------|------|
| POST/PUT/DELETE | `/boards`, `/boards/:id` |
| GET/POST | `/pages` |
| GET/PUT/DELETE | `/pages/:id` |
| PUT | `/pages/:id/published` → `{ published }` |
| GET | `/friend-link-applies` |
| PUT | `/friend-link-settings` |
| POST | `/friend-link-applies/:id/approve` \| `reject` \| `recheck` |
### 6.4 帖子审核与运营
| 方法 | 路径 | Body |
|------|------|------|
| GET | `/posts` | page, keyword, status |
| GET | `/posts/trash` | |
| POST | `/posts/:id/pin` | `{ pinned }` |
| POST | `/posts/:id/board-pin` | `{ board_pinned }` |
| POST | `/posts/:id/feature` | `{ featured }` |
| POST | `/posts/:id/lock` | `{ locked }` → edit_locked |
| POST | `/posts/:id/comments-lock` | `{ locked }` |
| POST | `/posts/:id/approve` | |
| POST | `/posts/:id/reject` | `{ reason }` |
| POST | `/posts/:id/restore` | |
| DELETE | `/posts/:id/purge` | 硬删 |
| DELETE | `/posts/:id` | 软删 |
### 6.5 评论 / 举报 / 用户 / 徽章 / 媒体 / 备份
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/comments`, `/comments/trash` | |
| GET | `/comments/:id/revisions` | |
| POST | `/comments/:id/approve` \| `reject` \| `restore` | reject 可带 reason |
| DELETE | `/comments/:id`, `/comments/:id/purge` | |
| GET | `/reports` | page, status |
| POST | `/reports/:id/handle` | `{ action, handle_note?, reject_reason? }`;action=`dismiss`\|`resolve`\|`reject_post`\|`reject_comment` |
| GET | `/users` | page, keyword, filter |
| POST | `/users/:id/ban` | `{ banned }` |
| POST | `/users/:id/verify` | `{ verified }` |
| POST | `/users/:id/level` | `{ level }` |
| POST | `/users/:id/points` | `{ delta, note? }` |
| POST | `/users/:id/badges` | `{ badge_id, revoke? }` |
| GET/POST | `/badges` | 列表 / upsert |
| GET | `/media` | category, page, size, q |
| POST | `/media/delete` | `{ urls: string[] }` |
| POST | `/backup` | `{ filename, download }` |
| GET | `/backup/download/:name` | 文件下载 |
---
## 7. 核心类型速查(与前端对齐)
详见 [`frontend/src/api/types.ts`]((仅 main)frontend/src/api/types.ts)。实现时至少对齐:
- `User` / `UserPublic` / `UserActivityStats`
- `Board` / `PostItem` / `PostDetailResponse` / `Comment`
- `ForumLimits` / `ForumLimitsPublic` / `SiteBranding`
- `PollView` / `PostLotteryView`
- `PrivateMessage` / `MessageConversation`
- `PostReport` / `FriendLinkApply` / `BadgeDef` / `PointLedger`
- `CheckInStatus` / `LotteryStatus`
- `AdminDashboard` / `AdminSettings` / `StorageConfig` / `MailConfig` / `OIDCConfig`
---
## 8. 鉴权错误语义(现网)
中间件对未登录 / 过期 / 禁言返回 JSON error(并可能清 cookie)。前端统一 `throw new Error(data.error)`。新站应保持可区分的错误文案或错误码,避免前端无法提示。
源:[`middleware/auth.go`](../../modules/auth/auth.go)。

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=Lax;HTTPS 时 Secure;TTL 7 天滑动续期 |
| 吊销 | 登出删当前 session;禁言 / 重置密码删该用户全部 session |
| HMAC 密钥 | `{DATA}/.jwt_secret` 仅 CSRF 等 HMAC(**不是**浏览器登录 JWT) |
密码:bcrypt;最小长度来自 `password_min_len`(默认 6)。
---
## 2. 内容审核
```mermaid
stateDiagram-v2
[*] --> pending: 普通用户发帖或评论
[*] --> published: admin或verified免审
pending --> published: 管理员通过
pending --> rejected: 管理员拒绝
published --> pending: 非免审用户编辑后可再进审
```
| 规则 | 细节 |
|------|------|
| 免审 | `role=admin` 或 `verified=true`(`SkipsModeration`) |
| 可见性 | `pending`/`rejected`:**仅作者与管理员**可见(对外表现为 404) |
| 列表 | 公开 Feed 只出 `published` |
| 拒绝 | 可写原因;通知作者(站内信 kind=`reject`,可选邮件) |
| 待审提醒 | 通知管理员(kind=`moderation`) |
| 游客评论 | 通常直接或按实现进入审核;勿假设与登录用户完全相同 |
源:[`service/post.go`](../../services/post.go) `CanViewPost`、[`service/comment.go`](../../services/comment.go)。
---
## 3. 编辑时限与锁
| 对象 | 规则 |
|------|------|
| 帖子 | 普通用户在 `post_edit_window_hours`(默认 24)内可编;超时不可(管理员除外) |
| 帖子 | `edit_locked=true` 时非管理员不可编 |
| 评论 | `comment_edit_window_minutes`(默认 3) |
| 评论 | 帖子 `comments_locked` 时禁止新评论 |
详情接口返回 `can_edit`、`edit_block_reason`、`post_edit_window_hours`。
修订:每次成功修改前写入 `post_revisions` / `comment_revisions`(旧内容快照)。
---
## 4. 经验 Exp(不可消费)
| 事件 | Delta |
|------|------|
| 发帖成功(公开路径) | +10 |
| 评论成功 | +2 |
| 帖子被点赞 | +1(作者) |
源:[`service/post.go`](../../services/post.go)、[`service/comment.go`](../../services/comment.go)、[`service/badge.go`](../../services/badge.go) `AddExp`。
等级门槛见 [03-data-model.md](03-data-model.md) §4。管理员设 level 时应同步 Exp 到门槛值。
---
## 5. 内容门控(红action)
源:[`service/content.go`](../../services/content.go)、[`handler/api.go`](../../routers/api/api.go) `APIPostDetail`、[`service/unlock.go`](../../services/unlock.go)
### 5.1 出口顺序(详情)
1. `SanitizePostHTML`(消毒)
2. 若 **游客**:`RedactMembersOnlyHTML` + `RedactReplyOnlyHTML`
3. 若 **已登录** 且非管理员、非作者、且未回复:仅 `RedactReplyOnlyHTML`
4. 作者与管理员:members/reply 块不遮
5. 积分块:按已解锁 key 集合 `RedactPointsOnlyHTML`;作者/管理员策略以实现为准(作者可免费解锁记录)
搜索 / SEO:`RedactGatedPostHTML` = members + reply + points 全遮。
### 5.2 积分解锁
| 项 | 值 |
|----|-----|
| block_key | `hex(sha256(innerHTML))[:16]` |
| cost | `data-cost`,最小 1 |
| 读者 | 扣 `cost`(reason=`unlock_spend`) |
| 作者分成 | `cost * 70 / 100`(`CreatorSharePercent`),reason=`creator_income`;并累加 `creator_income_total` |
| 平台留存 | 剩余 30%(无单独流水,表现为读者扣全额、作者只加 70%) |
| 作者自己 | cost=0 记解锁,无分成 |
| 已解锁 | 返回错误「已解锁」 |
| 防刷 | 双方账号注册未满 **7 天** 且 **LastLoginIP 相同** → 拒绝整单 |
---
## 6. 特殊帖类型
### 6.1 问答 question
- `question_resolved` 布尔;作者(或管理员)可切换
- 列表/详情用图标展示已解决状态
### 6.2 投票 poll
| 规则 | 细节 |
|------|------|
| 选项数 | 2–10;单选项 ≤64 字 |
| 多选 | `multi`;`max_choices` 钳制在 1..选项数 |
| 截止 | `ends_at` 可选;过期或 `closed` 不可再投 |
| 投票 | 每用户每帖;已投不可改(`ErrPollAlreadyVoted`) |
| 结束 | 作者或管理员 `poll/close` |
### 6.3 悬赏 bounty
```mermaid
stateDiagram-v2
[*] --> open: 发帖托管积分
open --> awarded: 采纳他人已发布评论
open --> refunded: 退款
```
| 规则 | 细节 |
|------|------|
| 发帖 | 积分 ≥1;立即 `bounty_escrow` 扣作者积分 |
| 采纳 | 不能采纳自己的回复;评论须 published;全额给评论作者 `bounty_award` |
| 退款 | 状态 open;作者在**无他人已发布回复**时可退;**管理员始终可强制退** |
| 退款后 | status=`refunded`,`bounty_points=0`,积分退回作者 |
### 6.4 抽奖帖 lottery
| 规则 | 细节 |
|------|------|
| 中奖人数 | 1–20 |
| 参与者 | 已发布评论且 **非楼主**;按用户去重(保留最早评论) |
| 开奖 | 作者/管理员;人数不足报错;随机抽取;写 `post_lottery_winners`;status=`drawn` |
---
## 7. 签到与每日抽奖
源:[`service/points.go`](../../services/points.go)
### 签到
- 自然日 `YYYY-MM-DD`(服务器本地时区)每用户一次
- 连续:若昨日报到则 streak+1,否则 1
- 奖励:`5 + (streak-1)`,封顶 **15**,保底 5
- 写 `check_ins` + `point_ledgers` reason=`check_in`
### 每日抽奖
- 每天一次;`cost=0`
- 奖池权重:0×40, 2×30, 5×18, 10×10, 20×2
- 中奖积分入账 reason=`lottery`
---
## 8. 评论特殊规则
| 规则 | 细节 |
|------|------|
| 楼层 | 按帖递增 |
| 私密评论 | 仅作者、帖作者、管理员、以及相关可见链可见(见 `canViewPrivate`) |
| 嵌套 | `thread_parent_id` 在父不可见时回挂祖先 |
| @提及 | 解析后发 kind=`mention` |
| 回复提醒 | kind=`reply`;可选 SMTP |
| HasUserReplied | 已发布或审核中的评论算「已回复」(不含被拒),用于 reply-only |
---
## 9. 举报处理
管理员 `handle` action:
| action | 效果 |
|--------|------|
| dismiss | 驳回举报 |
| resolve | 标记已处理(不必然删内容) |
| reject_post | 拒绝/下架帖 |
| reject_comment | 拒绝评论 |
结果通知举报人(kind=`report_result`)。
---
## 10. 友链
| 规则 | 细节 |
|------|------|
| 申请 | 登录用户;可上传 logo |
| 回链检测 | 设置开启时抓取 reciprocal 页检查是否含本站链接 |
| 通过 | 可写入品牌 `site_friend_links`(视实现:首页展示链接) |
| 展示开关 | nav / footer / aside 独立 |
---
## 11. 敏感词与限流
- 敏感词:`forum_settings.filter_words`(Admin SSR 可改并热更);旧文件可导入
- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings(Admin 可改基础四项+窗口)
---
## 12. 徽章自动授予
定期或触发时检查 `BadgeDef`(kind=auto):tenure_days / likes_received / creator_income 达阈值则写入 `user_badges`。限定徽章仅管理员发放。
---
## 13. 置顶排序语义
| 标记 | 首页全部 Feed | 板块 Feed |
|------|---------------|-----------|
| `pinned` | 抬升 | 抬升 |
| `board_pinned` | **不**抬升 | 抬升 |
| `featured` | 标记展示,不一定改变排序 | 同左 |
具体 SQL/排序实现见 [`service/post.go`](../../services/post.go) ListItems。

View File

@@ -0,0 +1,229 @@
# 06 · 页面、交互与信息架构
> **读者**:实现前台 / 后台 UI 的 AI
> **前置**:[02-features.md](02-features.md)
> **源码**:[`frontend/src/App.tsx`]((仅 main)frontend/src/App.tsx)、[`frontend/src/pages/`]((仅 main)frontend/src/pages/)、[`frontend/src/components/`]((仅 main)frontend/src/components/)、[`frontend/src/layouts/`]((仅 main)frontend/src/layouts/)
视觉可重设(见 [10-design-system.md](10-design-system.md));**信息架构与关键操作流应对齐**。新站建议 SSR 直出同等信息,而不是先空壳再 fetch。
---
## 1. 路由表
> **本分支(`rebuild/gitea-ssr`)**:浏览器 UI 走 `routers/web` 模板 + 表单,**不依赖**论坛 JSON `/api`。下表「SSR」列表示是否已迁。
### 1.1 认证
| 路径 | 说明 | SSR |
|------|------|-----|
| `/login` | 登录 / 登出 | 已迁 |
| `/register` | 注册(图形验证码;邮件就绪时要邮箱验证码) | 已迁 |
| `/forgot-password` | 忘记密码 | 已迁 |
### 1.2 前台
| 路径 | 说明 | SSR |
|------|------|-----|
| `/` | Feed | 已迁 |
| `/board/:id` | 板块 Feed | 已迁 |
| `/boards` | 板块索引 | 已迁 |
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
| `/compose` | 发帖(类型字段 + Markdown 工具栏/预览/图片/门控) | 已迁 |
| `/post/:id/edit` | 编辑帖 | 已迁 |
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
| `/user/:id` | 公开用户页 | 已迁 |
| `/favorites` | 收藏 | 已迁 |
| `/projects` | Gitea 码桶 | 后置 |
| `/links` | 友链 | 已迁 |
| `/messages` | 私信/通知会话列表 | 已迁 |
| `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 |
| `/page/:slug` | 站点单页 | 已迁 |
| `*` | 404 / pending | 已迁 |
### 1.3 后台(Admin SSR,表单 + CSRF,不挂管理 JSON `/api`)
| 路径 | 说明 | SSR |
|------|------|-----|
| `/admin` | 重定向 dashboard | 已迁 |
| `/admin/dashboard` | 概览计数 | 已迁 |
| `/admin/boards` | 板块 CRUD | 已迁 |
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
| `/admin/settings` | 品牌(含 Logo/Favicon/OG)+ 限流 + 内容限制 + 伪静态 + 敏感词 + SMTP + 备份 | 已迁 |
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
| `/admin/reports` | 举报处理 | 已迁 |
| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 |
| `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 |
| `/admin/media` | 媒体库分类浏览、删除、同步索引 | 已迁 |
| `/admin/trash` | 帖回收站 | 已迁 |
| `/admin/login` | 重定向前台登录 | 已迁 |
未迁(原 SPA):OIDC·Gitea·存储热切换 Admin 等。
---
## 2. 三栏布局(桌面)
```text
+------------------+---------------------------+------------------+
| Sidebar | Feed / 主内容 | RightPanel |
| - 全部/收藏/码桶 | - FeedHeader / SortBar | - 签到条(登录) |
| - 板块列表 | - VirtualPostList | - 热门帖 |
| - 站点页/友链 | - 或 PostDetail 等 | - aside_widgets |
| - 管理入口 | | 标签云/评论/ |
| | | 用户/友链 |
+------------------+---------------------------+------------------+
| Footer: ICP / 友链入口 / 站点页链接 |
+------------------------------------------------------------------+
```
### 2.1 左栏 Sidebar
- 全部帖子、我的收藏(登录)、开源码桶
- 板块列表(图标 + 色点 + 名称);空站引导「创建第一个板块」
- 站点区:友链入口(`nav_show_friend_links`)、`show_in_nav` 的站点页
- 管理员:管理后台入口
### 2.2 中栏
**Feed**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题;已迁 SSR GET)+ 列表项(标题、作者、板块徽章、回复数、置顶/精华)。虚拟滚动 / `feed_list_style` 未迁。
**帖子详情**:标题区操作(赞、藏、举报、编辑、管理操作)→ 待审/被拒横幅(作者/管理员)→ 特殊组件(投票卡 / 悬赏条 / 抽奖卡)→ 正文 `PostContent`(门控块 UI)→ 文章目录 → 作者卡片 → 修订入口 → 评论线程 + 评论框。
### 2.3 右栏 RightPanel
- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **已迁** SSR 右栏;流水/详情仍在 `/profile#wallet`
- 热门帖 — **已迁** SSR(固定块)
- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin `/admin/settings` 侧栏组件开关与排序;友链页 aside 勾选共用同一 JSON
- 桌面三列;≤900px 隐藏右栏
### 2.4 移动端
- 侧栏抽屉化;顶栏搜索/发帖/登录触手可及
- `PullToRefresh` 下拉刷新
- 触控友好列表行高
### 2.5 主题
- 浅色 / 暗色;默认 **跟随系统**(CSS `@media (prefers-color-scheme)`);`localStorage` 键 `j13-theme`(`system` | `light` | `dark`)
- 显式浅/深时设 `<html data-theme="light|dark">`;`system` 时去掉该属性,交给媒体查询
- 顶栏按钮循环切换;兼容旧 SPA 仅存 `light`/`dark` 的值
---
## 3. 发帖页 Compose
组件:`ComposeHeader`、`ComposeContextBar`(帖类型)、`ComposeSpecialFields`、`ComposeDocument` / `ArticleEditor`。
### 3.1 帖类型切换
| 类型 | 附加 UI |
|------|---------|
| 讨论 | 无 |
| 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 |
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 |
| 抽奖 | 中奖人数 1–20 — SSR compose |
### 3.2 编辑器能力(本分支:Markdown 渐进增强)
源对照:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx)(TipTap,**不迁**)
| 能力 | SSR 状态 |
|------|----------|
| 标题 h2–h6(`#` 映射为 h2) | Markdown 工具栏 + `ComposeBodyToHTML` |
| 粗体/斜体/删除线/行内代码 | 已迁 |
| 链接 | prompt 插入 |
| 围栏代码块 | 已迁(语言 class) |
| 列表 / 引用 | 已迁 |
| 图片上传 | `/compose/upload` |
| 登录/回复/积分可见门控 | compose 工具栏 |
| 预览 | `POST /compose/preview`(同消毒管线) |
| Tab 缩进 / 未保存离开 | `beforeunload` |
| 表格 / 图片组 / 表情贴纸 / TipTap 双模 | **未做**(后置) |
共用片段:`templates/shared/md_editor.tmpl`(发帖、改帖、评论、改评)。
---
## 4. 帖子详情关键交互
| 模块 | 行为 |
|------|------|
| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
| 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR |
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` |
| 评论 | 楼层列表、`reply_to` 引用、嵌套树(`ThreadParentID` + 缩进,全局 `#floor`)、私密/赞/举报/编辑/删除、回复/@ 通知 — SSR |
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) |
| 图片 | Lightbox 查看 |
---
## 5. 消息页
- 左:会话列表(系统会话单独)— SSR `/messages`;快捷链到回复/提及筛选
- 右:消息时间线;发送框 — SSR `/messages/with/:peerId`
- 顶:未读角标(全局导航也可显示)
- 通知筛选(kind)— 系统会话 `?kind=reply|mention|…`
---
## 6. 个人中心 / 公开主页
- 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile`
- 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱)
- 收藏列表 — SSR `/favorites`
- 积分钱包面板(余额、近 N 条流水、签到/抽奖表单 PRG)— SSR `/profile`;导航展示当前积分
- 徽章展示:用户主页 / 个人中心 — SSR(访问时 `EvaluateAuto`)
---
## 7. 友链页
- 展示品牌友链 — SSR `/links`
- 登录申请表单:名称、URL、Logo(地址或上传)、是否上首页、回链页 — `POST /links/apply`
- 我的申请状态列表;待审可取消
- Admin:`/admin/friend-links` 品牌增删、申请通过/拒绝、nav/footer/回链检测开关
---
## 8. 管理后台操作流(按页)
| 页 | 关键操作 |
|----|----------|
| Dashboard | 看计数与待办;点进对应列表 |
| Boards | 拖拽或数字排序;图标/色板选择;增删改 |
| Pages | 列表发布开关;编辑正文(HTML textarea);nav/footer 勾选 — SSR `/admin/pages` |
| Links | 品牌友链增删;申请通过/拒绝/回链复检;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(aside 与 Settings 侧栏组件共用 `aside_widgets`) |
| Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` |
| Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 |
| Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
| Users | 搜索;禁言;认证;调积分;设等级 — SSR `/admin/users`(授徽章在 `/admin/badges`) |
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` |
| Media | 分类浏览;批量删 |
| Settings | 品牌(含 Logo/Favicon/OG)、限流、内容限制、伪静态、侧栏、敏感词、邮件、SQLite 备份 — SSR `/admin/settings`(OIDC/Gitea/存储热切换未迁) |
---
## 9. 全局 UX 细节
- Toast(sonner)反馈成功/失败
- 路由级 ErrorBoundary / AppRouteError
- 懒加载页面 + retry(`lazyWithRetry`)
- 新标签打开帖子 / 正文外链:受 `open_posts_in_new_tab`、`open_content_links_in_new_tab` 控制
- 文档标题:`站点名 - 标语`;详情页应换成帖标题(SSR 时首屏即正确)
---
## 10. SSR 重构提示(交互层)
当前 SPA 在客户端挂载后才拉 `/api/posts/:id`。新站应:
1. 服务端渲染列表项与帖文 HTML(已按门控红action)
2. 水合后接上赞/评/解锁等交互
3. 管理后台仍可为 CSR,但前台公开页优先 SSR
勿再维护「爬虫一套 HTML、用户一套空壳」双轨,除非过渡期兼容。

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 HMAC(CSRF 等;勿提交;非登录 JWT)
├── .oidc_rsa.pem # 遗留:仅启用 OIDC 且从文件迁移时可能存在;新站优先 DB
├── uploads/
│ ├── avatars/
│ ├── posts/
│ └── site/
└── jiang13_backup_*.db # SQLite 一键备份(其它引擎请用库方工具)
```
---
## 3. 部署方式(现网)
| 方式 | 说明 |
|------|------|
| 单二进制 | `build.bat` / `make build` → `dist/jiang13(.exe)` |
| Docker | 镜像挂载 `/data`;健康检查 `GET /health` |
| Compose | `docker compose up -d --build` |
| systemd / Windows Service | `--service install` 后启停 |
构建约定见 [`.cursor/rules/build-scripts.mdc`](../../.cursor/rules/build-scripts.mdc):Windows 用 `build.bat`,勿直接 `make` / `.\build.ps1`。
---
## 4. 存储后端
| type | 行为 |
|------|------|
| `local` | 文件落在 `data/uploads`;URL 通常 `/uploads/...` |
| `s3` | S3 兼容;endpoint、bucket、密钥、public_base_url、prefix、force_path_style |
`image_delivery`:`webp`(默认,经 `/media/thumb`)或 `original`。
媒体索引表 `media` 供后台列表;启动时可后台 SyncMediaIndex。
---
## 5. SEO / 社交分享字段集合
新站应用真 SSR,但 **meta 字段应对齐**:
| 字段 | 来源 |
|------|------|
| `<title>` | 站点 DocumentTitle 或帖标题 |
| meta description | 站点简介优先,否则标语;帖文则摘要 |
| meta keywords | 站点 keywords |
| canonical | 绝对 URL |
| og:* / twitter:* | |
| JSON-LD | 结构化数据 |
| robots | 个别页可 noindex |
| 机制 | 说明 |
|------|------|
| robots.txt / sitemap.xml | 动态生成 |
重构验收:普通浏览器「查看网页源代码」应能看到帖文正文。
---
## 6. 伪静态 Permalink
设置:`permalink_enabled`、`permalink_ext`(默认 `html`)。
---
## 7. 安全相关运维注意
| 项 | 说明 |
|----|------|
| HMAC / OIDC | 勿提交 `.jwt_secret`;OIDC PEM 优先在 DB;遗留 `.oidc_rsa.pem` 亦勿提交 |
| Cookie | `jiang13_session` HttpOnly + SameSite=Lax;生产 HTTPS 下 Secure |
| 上传 | 类型/大小限制 |
| 敏感词 | `forum_settings.filter_words`,后台可改热更 |
| 备份 | SQLite 文件备份含哈希与私信;PG/MySQL 用官方工具 |
---
## 8. 健康检查
`GET /health` → JSON `status`。DB 不可用时非 200。
---
## 9. 从旧站迁数据建议
1. SQLite:复制 `jiang13.db`;或 dump 到 PG/MySQL 并映射表
2. 复制 `uploads/`、`.jwt_secret`(HMAC 兼容);敏感词若仍在文件可启动导入
3. 迁移 `forum_settings` 或后台重配(含 `filter_words`)
4. OIDC:`oauth_clients` + settings 中 PEM(或遗留 `.oidc_rsa.pem` 一次迁移)
5. 旧站 JWT Cookie 无效;用户需重新登录(opaque session)
表语义以 [03-data-model.md](03-data-model.md) 为准。
---
## 10. 文档包索引
返回 [README.md](README.md) 阅读顺序;功能验收用 [02-features.md](02-features.md);规则用 [05-business-rules.md](05-business-rules.md)。

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 HMAC;OIDC PEM 启用时进 settings。详见 [07-config-ops.md](07-config-ops.md)。
**会话:** Cookie `jiang13_session` → 表 `sessions`;可吊销。
**数据库:** GORM 方言 `sqlite`(默认)| `postgres` | `mysql`;连库失败不回落。
**后置:** Gitea 仓库同步(不启后台任务)。
---
## 安装
- 锁文件:`data/install.lock`
- 未安装:除 `/install`、`/ssr-assets/*`、`/health` 外一律重定向到安装向导
- 管理员仅由安装向导创建;已有用户数据启动时会自动补写锁
- 向导不选库:库由启动 Env 决定
---
## 渲染与交互原则
1. 已迁路径「查看源代码」须含内容 DOM。
2. UI 读写不依赖 `/api` 灌首屏或写操作(`/compose/upload` 为同站表单辅助 JSON,带 CSRF)。
3. 模板默认转义;`safeHTML` 仅用于消毒 + 门控后正文/评论 HTML。
4. 未迁路径用 `status/pending.tmpl` 或 404,不维护 SPA 占位语义。
5. 会话 Cookie:`jiang13_session`;`SameSite=Lax`;HTTPS 下 `Secure`。
### 已迁路径(摘要)
公开写:`/install`、`/login`、`/logout`、`/register`、`/forgot-password`(发码/重置)、`/compose`(含门控插入)、`/post/:id/edit`、帖详情评论(reply_to/赞/私密)/赞/藏、评论赞、积分解锁 `POST /post/:id/unlock`。
个人闭环:`/profile`(昵称/签名/密码/头像、积分钱包/签到/抽奖)、`/user/:id`、`/favorites`。
`POST /profile/checkin`、`POST /profile/lottery`(CSRF + PRG)。
私信:`/messages`、`/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。
公开浏览:`/boards` 板块索引(链到 `/board/:id`)。
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。
右栏:首页与帖详情三栏;登录用户签到/抽奖条(PRG 可回跳当前 Feed/帖页);固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链);Admin `/admin/settings` 可开关排序,友链页 aside 勾选与其共用同一配置。
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、nav/footer/aside 入口开关)。
站点单页:公开 `/page/:slug`;nav/footer 按 `show_in_nav` / `show_in_footer` 注入;Admin `/admin/pages`(CRUD、发布、排序、展示开关)。
举报:帖/评 SSR 表单;Admin `/admin/reports`(dismiss / resolve / reject_post / reject_comment)。
运营标记:帖详情 Admin 条(全局/版内置顶、精华、禁编、锁评、软删);回收站 `/admin/trash`(恢复/彻底删除)。
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/侧栏组件/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`、`/admin/trash`。

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.72–0.75rem | 口号、辅助 |
| `sm` | 0.8125rem | 元信息、侧栏、顶栏次级 |
| `md` | 0.9375rem | 正文默认 |
| `lg` | 1.15rem | Feed 标题 |
| `xl` | 1.35rem | 帖标题 |
| `brand` | 1.3rem / 700 | 顶栏站点名 |
字体栈:**中文优先** PingFang SC / Hiragino / Microsoft YaHei / Noto Sans SC(与 SPA 一致,确保本机可见);代码 Cascadia / Consolas。不引入 Inter/Roboto,不依赖本机 IBM Plex。
### 2.3 间距与圆角
- 栅格:4px 基准;总宽 `--j13-frame: 1320px`
- 圆角:`--j13-radius: 8px`;搜索 pill / 排序分段可用满圆角
- 阴影:sticky 顶栏微阴影
### 2.4 动效
| 处 | 行为 |
|----|------|
| 主题 | `color` / `background` / `border-color` `150ms ease` |
| Feed 行 | hover 背景 |
| 顶栏 | sticky 阴影;搜索 focus soft ring |
---
## 3. 布局
| 断点 | 行为 |
|------|------|
| ≥901px | 三栏:左 210px / 中 1fr / 右 280px;总宽 ~1400px(对齐 SPA) |
| ≤900px | 隐藏右栏;顶栏搜索折行(抽屉化 **下一刀**) |
---
## 4. 组件约定
| 组件 | 规则 |
|------|------|
| 顶栏 | SPA:品牌 \| 搜索胶囊(筛选钮 + Ctrl+K)\| 发帖绿钮 \| 主题/私信/头像菜单 |
| 左栏 | 浏览 / 板块;active 绿底;板块色槽 + 帖数 |
| Feed 行 | `post-row--v2`:头像 · 徽章+标题 · 摘要(依 feed_list_style)· 元信息 · 回复数 |
| 高级搜索 | 顶栏筛选打开;有条件时 details 展开 |
| 排序 | SPA `feed-sort-tab` 绿 active |
---
## 5. 范围边界
- **覆盖**:公开页壳层(含首页门面密度)、资源 `?v=`、浅/暗色
- **不覆盖**:Admin 深重绘、移动抽屉、虚拟滚动、TipTap、改站名
交叉:[06-pages-ux.md](06-pages-ux.md) · [09-ssr-progress.md](09-ssr-progress.md)

140
docs/rebuild-spec/README.md Normal file
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 |
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(SSR)HTML;SEO meta 与正文同源。技术选型自定(Next.js / Nuxt / Remix / 其它均可)。
---
## 重构时必须保留 vs 可以改
### 必须保留(产品语义)
- [02-features.md](02-features.md) 中列出的功能能力
- [03-data-model.md](03-data-model.md) 中的实体关系与枚举含义(表名可改,语义对齐)
- [05-business-rules.md](05-business-rules.md) 中的数值与状态机(积分、审核、门控、悬赏分成等)
- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;**管理员仅由 `/install` 创建**(不再首注册变管理员)
### 建议兼容(降低迁移成本)
- [04-api.md](04-api.md) 的 JSON 字段命名与路径形状(可做版本前缀,但旧字段名便于对照)
- Cookie 名 `jiang13_session`(opaque session id;重建分支不做 `jiang13_token` 双读)
- 数据目录语义:`jiang13.db`、`uploads/`;敏感词在 `forum_settings.filter_words`(旧 `filter_words.txt` 可导入)
### 可以彻底改
- 语言与框架(不必再 Go + React SPA)
- 单二进制 / `go:embed`(可改为前后端分离部署)
- SQLite(可换 PostgreSQL 等;规格不强制)
- UI 视觉(布局信息密度见 [06-pages-ux.md](06-pages-ux.md),视觉可重设)
- 爬虫专用 HTML 双轨(用真 SSR 取代)
---
## 术语表(首次出现)
| 术语 | 中文 | 说明 |
|------|------|------|
| SSR | 服务端渲染 | 首屏 HTML 含正文,非纯客户端壳 |
| SPA | 单页应用 | 当前前台实现形态 |
| OIDC | 开放身份连接 | 本站可作 Provider,供 Gitea 等 SSO |
| JWT | JSON Web Token | OIDC 对外 `id_token`/`access_token` 仍用;**浏览器登录不用 JWT** |
| Opaque session | 不透明会话 | Cookie 只存随机 id,服务端 `sessions` 表可吊销 |
| Feed | 信息流 | 首页 / 板块帖列表 |
| 门控 | Content gate | 登录可见 / 回复可见 / 积分可见区块 |
| 伪静态 | Permalink | 如 `/post/123.html` 的可选后缀 |
---
## 源码速查(核对规格时)
| 主题 | 路径 |
|------|------|
| 路由总装 | [`routers/setup.go`](../../routers/setup.go) |
| GORM 模型 | [`models/models.go`](../../models/models.go) |
| AutoMigrate | [`models/db.go`](../../models/db.go) |
| 论坛设置键 | [`services/settings.go`](../../services/settings.go) |
| SSR 页面路由 | [`routers/web/`](../../routers/web/) |
| JSON API | [`routers/api/`](../../routers/api/) |
| 前端 API / 页面(对照) | 仅 `main`:`frontend/src/api/`、`frontend/src/App.tsx` |
| 产品介绍 | [`docs/introduction.md`](../introduction.md)、[`README.md`](../../README.md) |
---
## 文档包完成标准
另一 AI 仅阅读本目录、**不打开业务源码**,应能:
1. 列出全部用户可见功能与后台能力
2. 画出核心表 ER 并理解枚举
3. 实现或 mock 与现网兼容的 API 形状
4. 复现审核 / 积分 / 门控 / 特殊帖规则
5. 搭出等价的页面信息架构与关键交互
若规格与代码冲突:**以代码为准**,并应回写修正本目录文档。

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` |
## 不做
自动徽章规则引擎调度 UI(P2,服务层 `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;用户页可见等级。

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

28
docs/site-posts/README.md Normal file
View File

@@ -0,0 +1,28 @@
# 站务公告 · 发帖素材
## 板块设置
| 项 | 建议值 |
| --- | --- |
| 名称 | 站务公告 |
| 描述 | 本站说明与姜十三论坛介绍。请勿灌水。 |
| 图标 | `megaphone` |
| 色标 | 建议第 0 或 1 槽 |
| 排序 | `0` |
## 主文(必发)
| 文件 | 标题 | 标签 | 操作 |
| --- | --- | --- | --- |
| [about-jiang13.md](./about-jiang13.md) | 关于本站 · 姜十三论坛 | `站务` `介绍` | **置顶**(可选精华) |
把原先分篇的「上手 / 功能 / 部署」三帖 **删除**,避免与长文重复。
## 可选短文
若站务区希望不止一篇,见 [optional.md](./optional.md):
- [community-guidelines.md](./community-guidelines.md) — 发帖约定
- [feedback.md](./feedback.md) — 反馈与建议
复制正文时:从各文件第一个 `---` **之后**开始粘贴。

View File

@@ -0,0 +1,185 @@
# 关于本站 · 姜十三论坛
> 建议标题:关于本站 · 姜十三论坛
> 建议标签:`站务` `介绍`
> 建议操作:置顶(可选精华)
---
你好,欢迎加入本站。
这里运行的是开源论坛 **姜十三论坛(Jiang13 Forum)**——面向小圈子、团队与同好社群,目标很明确:
**能聊 · 好看 · 好装**
下文介绍它是什么、怎么上手、有哪些能力,以及为何采用单文件部署。读完即可开始发帖;细节可随时回来查阅。
---
## 一、它是什么
姜十三论坛不是大而全的社区平台,而是一款轻量、现代化的论坛软件。
它做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
- 有板块、帖子、楼层回复,讨论能沉淀、能追溯
- 界面清爽,信息密度适中,桌面与手机都好用
- 单个程序即可部署,备份路径清晰
技术上,它是一个 **Go 单二进制**:前端页面内嵌,数据库用内置 **SQLite**。拷到服务器、启动、注册——就可以用。
### 谁适合用
| 场景 | 说明 |
| --- | --- |
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 项目配套社区 | 可与 Gitea 等做 SSO 联动 |
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
若你需要百万级公网社区、复杂插件生态或企业级工单流,它可能不是最优选。
若你要的是 **小而美、自己能装、自己能管**,就对了。
### 本板块说明
「站务公告」只发站务与介绍类内容。日常聊天、提问、分享,请到其他讨论板块。
---
## 二、三分钟上手
### 注册与登录
1. 打开注册页,填写用户名、邮箱与密码。
2. 若已开启邮件服务,可能需要邮箱验证码。
3. **第一个注册用户自动成为管理员**,无需安装向导。
登录后,右上角会出现头像与私信入口。
### 认识首页
| 区域 | 内容 |
| --- | --- |
| 左侧 | 板块导航、常用入口 |
| 中间 | 帖子列表(Feed) |
| 右侧 | 热门帖、最新评论等 |
列表可按 **最新发帖 / 最新回复 / 热门** 切换。
支持浅色 / 暗色主题,偏好会记住。手机上侧栏会收起,不影响浏览与发帖。
### 发帖与回复
点「发帖」→ 选板块 → 写标题、标签(可选)与正文。
正文支持富文本与图片上传。评论是 **楼层式** 的,可回复、引用、@。
帖子可点赞、收藏;公开主页可展示你的资料与动态。
### 个人入口
头像菜单常见项:个人主页、账号设置、站内私信、我的收藏。
### 管理后台(仅管理员)
访问 `/admin`:板块、帖子、评论、举报、用户、媒体、系统设置,以及备份等。
| 改什么 | 去哪里 |
| --- | --- |
| 端口、数据目录、DB、JWT | CLI / Env + `data/.jwt_secret`(改引导项需重启) |
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
逛完板块,发第一帖或回一楼——聊起来就对了。
---
## 三、功能速览
功能刻意收在「小圈子真正用得到」的范围内。
### 浏览与发帖
- 三栏布局,桌面 / 移动自适应
- Feed 排序
- 浅色 / 暗色主题(可跟随系统);板块图标与主题色可配
- Markdown 编辑器、标签、正文图片、内容门控
- 楼层评论:回复、引用、嵌套树
- 点赞、收藏;置顶 / 精华
- 编辑修订历史
- 可配置普通用户的编辑时限
### 社交与个人
- 公开用户主页
- 会话式站内私信
- 昵称、签名、头像等资料
### 管理后台
SSR 后台统一在 `/admin`:
| 模块 | 能力 |
| --- | --- |
| 仪表盘 | 站点概览 |
| 板块 | 创建、编辑、排序、外观 |
| 帖子 | 审核、置顶、精华、锁定、回收站 |
| 评论 / 举报 | 审核与处理 |
| 用户 | 禁言等 |
| 媒体 | 查看与清理上传 |
| 设置 | 品牌、限流、敏感词、邮件、OIDC、存储等 |
| 备份 | SQLite 一键备份 |
### 可选能力
按需在后台开启,不是装完就必须用:
- 邮件验证码注册
- OIDC Provider(可作 Gitea 等登录源)
- Gitea 公开仓库同步到「开源码桶」
- 本地磁盘或 S3 兼容对象存储
### 权限
只有两种角色:**普通用户** / **管理员**。
第一个注册用户即管理员。小圈子够用,不堆角色矩阵。
项目仍在积极开发。完整规划见仓库 `ROADMAP.md`,欢迎反馈与共建。
---
## 四、为什么是单文件部署
最硬核的一点,是部署路径极短——不是「理论上能打包」,而是日常用法就是:
1. 编译得到一个二进制
2. 放到目录里运行
3. 自动生成 `data/.jwt_secret`
4. 浏览器注册,第一个账号即管理员
### 它补哪块空缺
论坛方案常落在两端:很重(功能全、运维贵),或很碎(聊天 / 文档方便,却缺少发帖沉淀)。
小圈子往往需要中间态:有论坛结构、界面顺手、一个人能装、备份好懂。单文件部署,就是为后两点服务的。
### 具体意味着什么
| 能力 | 说明 |
| --- | --- |
| 单二进制 | 前端已内嵌,不必单独部署前端目录 |
| 内置 SQLite | 零外部数据库,数据在本地目录 |
| Env / CLI 引导 | 端口、数据目录、数据库 |
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
| 系统服务 | 内置 Linux systemd / Windows Service |
| 跨平台 | Windows / Linux / macOS 均可编译运行 |
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
进程级项改 Env/CLI 后重启;业务项在管理后台改。引导面保持精简,热更新走数据库。
---
## 写在最后
**姜十三论坛** = 小圈子该有的论坛能力 × 清新界面 × 真正能单文件带走的部署。
源码与说明见项目仓库。许可证:MIT。
有问题可联系管理员。祝你在这里聊得愉快。

View File

@@ -0,0 +1,30 @@
# 本站发帖约定
> 建议标题:本站发帖约定
> 建议标签:`站务` `公约`
> 建议操作:普通发布(可选)
---
本站是小圈子论坛,约定尽量少、好遵守。
## 请这样做
- 选对板块再发;不确定就发到综合讨论类板块
- 标题写清楚在说什么,方便后人搜索
- 讨论对事不对人;引用、回复尽量指向具体楼层
- 分享链接时简单说明来源与用途
## 请避免
- 在「站务公告」灌水、闲聊(请到其他板块)
- 无意义刷屏、重复灌帖
- 人身攻击、骚扰、泄露他人隐私
- 明显的广告、引流(除非板块明确允许)
## 管理说明
管理员可能会:移动板块、编辑标题、锁定、删除或禁言。
有争议可私信管理员说明情况。
一句话:**把讨论留给值得留下的内容。**

View File

@@ -0,0 +1,26 @@
# 反馈与建议
> 建议标题:反馈与建议
> 建议标签:`站务` `反馈`
> 建议操作:普通发布(可选)
---
本站程序是开源的 **姜十三论坛**,仍在积极开发。欢迎反馈。
## 站内怎么说
- **功能建议 / 体验吐槽**:开新帖,尽量写清场景与期望
- **疑似缺陷**:说明步骤、浏览器、是否可复现;有截图更好
- **站务问题**(注册、权限、内容处理):私信管理员
## 开发仓库(可选)
若你也参与程序本身:
- 查路线图、提 Issue / PR:见项目仓库
- 许可证:MIT
站内讨论与仓库 Issue 不必重复提交;选一处说清楚即可。
感谢一起把小圈子聊好。

View File

@@ -0,0 +1,10 @@
# 可选短文(不与介绍长文重复)
长文 [`about-jiang13.md`](./about-jiang13.md) 置顶后,原先分篇的「上手 / 功能 / 部署」建议 **直接删除**。
若希望「站务公告」里还有几篇配套短帖,可用下面两篇(按需发,不必全发)。
| 文件 | 标题 | 标签 | 说明 |
| --- | --- | --- | --- |
| [community-guidelines.md](./community-guidelines.md) | 本站发帖约定 | `站务` `公约` | 轻量规则,避免灌水 |
| [feedback.md](./feedback.md) | 反馈与建议 | `站务` `反馈` | 引导提问题、提想法 |

76
go.mod Normal file
View File

@@ -0,0 +1,76 @@
module git.iioio.com/freefire/jiang13-forum
go 1.26
require (
github.com/KarpelesLab/gowebp v0.1.1
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/kardianos/service v1.2.2
github.com/microcosm-cc/bluemonday v1.0.27
github.com/minio/minio-go/v7 v7.0.98
golang.org/x/crypto v0.46.0
golang.org/x/image v0.44.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

177
go.sum Normal file
View File

@@ -0,0 +1,177 @@
github.com/KarpelesLab/gowebp v0.1.1 h1:W11ZrRVx+Zk4ypW5NBEU31FQzghICXIrAbAbO5yd4M0=
github.com/KarpelesLab/gowebp v0.1.1/go.mod h1:Js8OXPQ94yl94HqaO/9XuUqk0wOPod6uycryhzmTgsU=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

177
models/db.go Normal file
View File

@@ -0,0 +1,177 @@
package models
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
// DatabaseConfig 与 config.DatabaseConfig 对齐的精简结构(避免 models→config 循环依赖)
type DatabaseConfig struct {
Type string
DSN string
SQLitePath string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetimeSec int
}
// InitDB 按方言初始化数据库并自动迁移
func InitDB(cfg DatabaseConfig) error {
typ := strings.ToLower(strings.TrimSpace(cfg.Type))
if typ == "" {
typ = "sqlite"
}
dialector, err := openDialector(typ, cfg)
if err != nil {
return err
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return fmt.Errorf("连接数据库失败 (%s): %w — 请检查 JIANG13_DB_TYPE / JIANG13_DB_DSN", typ, err)
}
sqlDB, err := db.DB()
if err != nil {
return err
}
if cfg.MaxOpenConns > 0 {
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
}
if cfg.MaxIdleConns > 0 {
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
}
if cfg.ConnMaxLifetimeSec > 0 {
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSec) * time.Second)
}
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{},
&PostLike{}, &CommentLike{}, &PostFavorite{}, &PostRevision{}, &CommentRevision{}, &ForumSetting{},
&OAuthClient{}, &OAuthAuthCode{},
&GiteaRepo{},
&PrivateMessage{}, &PostReport{}, &FriendLinkApply{},
&Media{},
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
&BadgeDef{}, &UserBadge{},
&SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
&Session{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
_ = db.Model(&Post{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
_ = db.Model(&Comment{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
_ = db.Model(&Post{}).Where("post_type = '' OR post_type IS NULL").Update("post_type", PostTypeNormal).Error
DB = db
seedDefaultBadges(db)
backfillUserExp(db)
log.Printf("[models] 数据库初始化完成 type=%s", typ)
return nil
}
func openDialector(typ string, cfg DatabaseConfig) (gorm.Dialector, error) {
switch typ {
case "sqlite", "sqlite3":
path := cfg.SQLitePath
if path == "" {
path = cfg.DSN
}
if path == "" {
return nil, fmt.Errorf("sqlite 需要文件路径")
}
if path != ":memory:" {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
}
return sqlite.Open(path), nil
case "postgres", "postgresql", "pg":
if cfg.DSN == "" {
return nil, fmt.Errorf("postgres 需要 JIANG13_DB_DSN 或 HOST/USER/NAME")
}
return postgres.Open(cfg.DSN), nil
case "mysql", "mariadb":
if cfg.DSN == "" {
return nil, fmt.Errorf("mysql 需要 JIANG13_DB_DSN 或 HOST/USER/NAME")
}
return mysql.Open(cfg.DSN), nil
default:
return nil, fmt.Errorf("不支持的数据库类型 %q", typ)
}
}
// DialectorName 当前驱动名(sqlite/postgres/mysql)
func DialectorName() string {
if DB == nil {
return ""
}
return DB.Dialector.Name()
}
// PingDB 检测数据库连接是否可用
func PingDB() error {
if DB == nil {
return fmt.Errorf("数据库未初始化")
}
sqlDB, err := DB.DB()
if err != nil {
return err
}
return sqlDB.Ping()
}
// seedDefaultBadges 写入内置自动徽章(已存在则跳过)
func seedDefaultBadges(db *gorm.DB) {
defs := []BadgeDef{
{Code: "tenure_30", Name: "初来乍到", Description: "注册满 30 天", Icon: "calendar", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 30, SortOrder: 10, Enabled: true},
{Code: "tenure_365", Name: "常驻居民", Description: "注册满 365 天", Icon: "calendar-heart", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 365, SortOrder: 20, Enabled: true},
{Code: "likes_10", Name: "小有人气", Description: "帖子获赞累计 10", Icon: "heart", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 10, SortOrder: 30, Enabled: true},
{Code: "likes_100", Name: "人气作者", Description: "帖子获赞累计 100", Icon: "heart-handshake", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 100, SortOrder: 40, Enabled: true},
{Code: "likes_1000", Name: "超级人气", Description: "帖子获赞累计 1000", Icon: "flame", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 1000, SortOrder: 50, Enabled: true},
{Code: "income_100", Name: "小有进账", Description: "创作者分成累计 100 积分", Icon: "coins", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 100, SortOrder: 60, Enabled: true},
{Code: "income_1000", Name: "创收达人", Description: "创作者分成累计 1000 积分", Icon: "gem", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 1000, SortOrder: 70, Enabled: true},
}
for _, d := range defs {
var n int64
db.Model(&BadgeDef{}).Where("code = ?", d.Code).Count(&n)
if n == 0 {
_ = db.Create(&d).Error
}
}
}
// backfillUserExp 对 Exp 仍为 0 的用户按发帖/评论/获赞粗算经验(仅补一次语义)
func backfillUserExp(db *gorm.DB) {
var users []User
if err := db.Select("id", "exp").Where("exp = 0").Find(&users).Error; err != nil {
return
}
for _, u := range users {
var posts, comments int64
var likeSum int64
_ = db.Model(&Post{}).Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Count(&posts).Error
_ = db.Model(&Comment{}).Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Count(&comments).Error
_ = db.Model(&Post{}).Select("COALESCE(SUM(like_count), 0)").Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Scan(&likeSum).Error
exp := int(posts)*10 + int(comments)*2 + int(likeSum)
if exp > 0 {
_ = db.Model(&User{}).Where("id = ? AND exp = 0", u.ID).Update("exp", exp).Error
}
}
}

27
models/gitea.go Normal file
View File

@@ -0,0 +1,27 @@
package models
import "time"
// GiteaRepo 从 Gitea 同步的公开仓库缓存(侧栏 /projects 读取)
type GiteaRepo struct {
ID uint `gorm:"primaryKey" json:"id"`
GiteaID int64 `gorm:"uniqueIndex;not null" json:"gitea_id"`
OwnerLogin string `gorm:"size:128;not null;index" json:"owner_login"`
Name string `gorm:"size:255;not null" json:"name"`
FullName string `gorm:"size:512;not null" json:"full_name"`
Description string `gorm:"size:2048;default:''" json:"description"`
HTMLURL string `gorm:"size:1024;not null" json:"html_url"`
Language string `gorm:"size:64;default:''" json:"language"`
StarsCount int `gorm:"default:0" json:"stars_count"`
ForksCount int `gorm:"default:0" json:"forks_count"`
Private bool `gorm:"default:false" json:"private"`
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
ForumUserID *uint `gorm:"index" json:"forum_user_id"`
SyncedAt time.Time `json:"synced_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (GiteaRepo) TableName() string {
return "gitea_repos"
}

34
models/level.go Normal file
View File

@@ -0,0 +1,34 @@
package models
// LevelThresholds 各等级所需最低 Exp(下标 0 对应 Lv1)
var LevelThresholds = []int{0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000}
// LevelFromExp 由经验推导等级(1–10)
func LevelFromExp(exp int) int {
if exp < 0 {
exp = 0
}
level := 1
for i, th := range LevelThresholds {
if exp >= th {
level = i + 1
}
}
return level
}
// ExpForLevel 某等级的门槛 Exp(超出范围则钳制)
func ExpForLevel(level int) int {
if level < 1 {
level = 1
}
if level > len(LevelThresholds) {
level = len(LevelThresholds)
}
return LevelThresholds[level-1]
}
// MaxLevel 最高等级
func MaxLevel() int {
return len(LevelThresholds)
}

467
models/models.go Normal file
View File

@@ -0,0 +1,467 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Role 用户角色
type Role string
const (
RoleUser Role = "user"
RoleAdmin Role = "admin"
)
// 内容审核状态(帖子 / 评论)
const (
ContentStatusPending = "pending" // 审核中(仅作者与管理员可见)
ContentStatusPublished = "published" // 已公开
ContentStatusRejected = "rejected" // 未通过(仅作者与管理员可见)
)
// 帖子类型
const (
PostTypeNormal = "normal" // 普通讨论
PostTypeQuestion = "question" // 问答(未解决 / 已解决)
PostTypePoll = "poll" // 投票
PostTypeBounty = "bounty" // 悬赏
PostTypeLottery = "lottery" // 抽奖
)
// 悬赏状态
const (
BountyStatusOpen = "open"
BountyStatusAwarded = "awarded"
BountyStatusRefunded = "refunded"
)
// 帖内抽奖状态
const (
PostLotteryStatusOpen = "open"
PostLotteryStatusDrawn = "drawn"
)
// User 用户表
// Email / Password / LastLogin* / LastAccessAt 默认不随帖子等嵌套 User 序列化;
// 个人中心与后台列表请用 UserSelf / UserAdmin。
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
Email string `gorm:"index;size:128;default:''" json:"-"`
Password string `gorm:"size:128;not null" json:"-"`
Nickname string `gorm:"size:64" json:"nickname"`
Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL
Role Role `gorm:"size:16;default:user" json:"role"`
Verified bool `gorm:"default:false;index" json:"verified"` // 站长认证:免审发帖/评论
Exp int `gorm:"default:0" json:"exp"` // 经验(不可消费)
Points int `gorm:"default:0" json:"points"` // 可用积分
CreatorIncomeTotal int `gorm:"default:0" json:"creator_income_total"` // 累计创作分成
Banned bool `gorm:"default:false" json:"banned"`
BannedAt *time.Time `json:"banned_at,omitempty"`
LastLoginAt *time.Time `json:"-"`
LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
LastAccessAt *time.Time `json:"-"` // 最近一次带鉴权的访问(与登录分开)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 仅序列化展示用,不落库
Level int `gorm:"-" json:"level"`
Badges []UserBadgeView `gorm:"-" json:"badges,omitempty"`
}
// AfterFind 填充展示用等级
func (u *User) AfterFind(tx *gorm.DB) (err error) {
u.Level = LevelFromExp(u.Exp)
return nil
}
// SkipsModeration 站长或认证用户发帖/评论免审
func (u *User) SkipsModeration() bool {
return u != nil && (u.Role == RoleAdmin || u.Verified)
}
// Board 论坛板块
type Board struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
Icon string `gorm:"size:64;default:''" json:"icon"`
ColorIndex int `gorm:"default:-1" json:"color_index"` // -1 表示按 id 自动取色
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// Post 帖子
type Post struct {
ID uint `gorm:"primaryKey" json:"id"`
BoardID uint `gorm:"index;not null" json:"board_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Title string `gorm:"size:256;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
Tags string `gorm:"size:256" json:"tags"`
PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery
QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty)
BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded
BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
EditLocked bool `gorm:"default:false" json:"edit_locked"`
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
LikeCount int `gorm:"default:0" json:"like_count"`
ViewCount int `gorm:"default:0" json:"view_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Comments []Comment `gorm:"foreignKey:PostID" json:"comments,omitempty"`
}
// PostRevision 帖子编辑历史(每次修改前保存旧版本)
type PostRevision struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
Title string `gorm:"size:256;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tags string `gorm:"size:256" json:"tags"`
CreatedAt time.Time `json:"created_at"`
Editor User `gorm:"foreignKey:EditorID" json:"editor,omitempty"`
}
// CommentRevision 评论编辑历史(每次修改前保存旧版本,管理员可查看)
type CommentRevision struct {
ID uint `gorm:"primaryKey" json:"id"`
CommentID uint `gorm:"index;not null" json:"comment_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
Content string `gorm:"type:text;not null" json:"content"`
CreatedAt time.Time `json:"created_at"`
Editor User `gorm:"foreignKey:EditorID" json:"editor,omitempty"`
}
// ForumSetting 论坛全局设置(键值对)
type ForumSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
Value string `gorm:"size:2048" json:"value"`
}
// Comment 楼层评论
type Comment struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
UserID uint `gorm:"index" json:"user_id"` // 0 表示游客
Floor int `gorm:"not null" json:"floor"`
Content string `gorm:"type:text;not null" json:"content"`
ReplyTo *uint `gorm:"index" json:"reply_to,omitempty"`
GuestNick string `gorm:"size:64" json:"guest_nick,omitempty"`
GuestEmail string `gorm:"size:128" json:"guest_email,omitempty"`
GuestURL string `gorm:"size:256" json:"guest_url,omitempty"`
IsPrivate bool `gorm:"default:false" json:"is_private"`
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
LikeCount int `gorm:"default:0" json:"like_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
ReplyTarget *Comment `gorm:"-" json:"reply_target,omitempty"`
// ThreadParentID 嵌套展示用父评论(父评论不可见时回挂到最近可见祖先)
ThreadParentID *uint `gorm:"-" json:"thread_parent_id,omitempty"`
ContentHidden bool `gorm:"-" json:"content_hidden"`
Liked bool `gorm:"-" json:"liked"`
}
// PostLike 帖子点赞
type PostLike struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"post_id"`
UserID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
// CommentLike 评论点赞
type CommentLike struct {
ID uint `gorm:"primaryKey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_user;not null" json:"comment_id"`
UserID uint `gorm:"uniqueIndex:idx_comment_user;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
// PostFavorite 帖子收藏
type PostFavorite struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"post_id"`
UserID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
}
// 私信类型
const (
MessageKindUser = "user" // 用户互发
MessageKindSystem = "system" // 系统通知
MessageKindReject = "reject" // 帖子被拒/下架
MessageKindReportResult = "report_result" // 举报处理结果
MessageKindReply = "reply" // 帖子/评论被回复
MessageKindMention = "mention" // 被 @提及
MessageKindModeration = "moderation" // 新内容待审核(通知管理员)
)
// PrivateMessage 站内私信
type PrivateMessage struct {
ID uint `gorm:"primaryKey" json:"id"`
FromUserID uint `gorm:"index;not null" json:"from_user_id"` // 0 表示系统
ToUserID uint `gorm:"index;not null" json:"to_user_id"`
Subject string `gorm:"size:256;not null" json:"subject"`
Content string `gorm:"type:text;not null" json:"content"`
Kind string `gorm:"size:32;default:user;index" json:"kind"`
RelatedPostID *uint `gorm:"index" json:"related_post_id,omitempty"`
RelatedReportID *uint `gorm:"index" json:"related_report_id,omitempty"`
IsRead bool `gorm:"default:false;index" json:"is_read"`
CreatedAt time.Time `json:"created_at"`
FromUser User `gorm:"foreignKey:FromUserID" json:"from_user,omitempty"`
ToUser User `gorm:"foreignKey:ToUserID" json:"to_user,omitempty"`
}
// 举报状态
const (
ReportStatusPending = "pending"
ReportStatusResolved = "resolved"
ReportStatusDismissed = "dismissed"
)
// 举报原因
const (
ReportReasonSpam = "spam"
ReportReasonAbuse = "abuse"
ReportReasonIllegal = "illegal"
ReportReasonIrrelevant = "irrelevant"
ReportReasonOther = "other"
)
// 友链申请状态
const (
FriendLinkApplyStatusPending = "pending"
FriendLinkApplyStatusApproved = "approved"
FriendLinkApplyStatusRejected = "rejected"
)
// FriendLinkApply 友情链接申请
type FriendLinkApply struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:32;not null" json:"name"`
URL string `gorm:"size:512;not null" json:"url"`
Description string `gorm:"size:200;default:''" json:"description,omitempty"`
Logo string `gorm:"size:512;default:''" json:"logo"`
ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"`
LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"`
ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"`
ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"`
ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"`
Status string `gorm:"size:16;default:pending;index" json:"status"`
ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// PostReport 帖子/评论举报(CommentID 有值时为评论举报)
type PostReport struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
CommentID *uint `gorm:"index" json:"comment_id,omitempty"`
ReporterID uint `gorm:"index;not null" json:"reporter_id"`
Reason string `gorm:"size:32;not null" json:"reason"`
Detail string `gorm:"size:1000" json:"detail"`
Status string `gorm:"size:16;default:pending;index" json:"status"`
HandlerID *uint `gorm:"index" json:"handler_id,omitempty"`
HandleNote string `gorm:"size:1000" json:"handle_note"`
CreatedAt time.Time `json:"created_at"`
HandledAt *time.Time `json:"handled_at,omitempty"`
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"`
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
}
// Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计)
type Media struct {
ID uint `gorm:"primaryKey" json:"id"`
Category string `gorm:"size:32;index;not null" json:"category"` // avatars|posts|site
Name string `gorm:"size:255;not null" json:"name"`
URL string `gorm:"size:512;uniqueIndex;not null" json:"url"`
Size int64 `gorm:"not null;default:0" json:"size"`
ContentType string `gorm:"size:64;default:''" json:"content_type"`
StorageType string `gorm:"size:16;index;default:local" json:"storage_type"` // local|s3
UserID *uint `gorm:"index" json:"user_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// 积分流水原因
const (
PointReasonCheckIn = "check_in"
PointReasonLottery = "lottery"
PointReasonUnlockSpend = "unlock_spend"
PointReasonCreatorIncome = "creator_income"
PointReasonAdminAdjust = "admin_adjust"
PointReasonBountyEscrow = "bounty_escrow"
PointReasonBountyAward = "bounty_award"
PointReasonBountyRefund = "bounty_refund"
)
// PointLedger 积分流水
type PointLedger struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Delta int `gorm:"not null" json:"delta"`
Balance int `gorm:"not null" json:"balance"` // 变动后余额
Reason string `gorm:"size:32;index;not null" json:"reason"`
RefType string `gorm:"size:32;default:''" json:"ref_type"`
RefID uint `gorm:"default:0" json:"ref_id"`
Note string `gorm:"size:256;default:''" json:"note"`
CreatedAt time.Time `json:"created_at"`
}
// CheckIn 每日签到(用户+自然日唯一)
type CheckIn struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_checkin_user_day;not null" json:"user_id"`
Day string `gorm:"uniqueIndex:idx_checkin_user_day;size:10;not null" json:"day"` // YYYY-MM-DD
Points int `gorm:"not null" json:"points"`
Streak int `gorm:"default:1" json:"streak"`
CreatedAt time.Time `json:"created_at"`
}
// LotteryDraw 每日抽奖记录
type LotteryDraw struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_lottery_user_day;not null" json:"user_id"`
Day string `gorm:"uniqueIndex:idx_lottery_user_day;size:10;not null" json:"day"`
Points int `gorm:"not null" json:"points"` // 抽中积分(可为 0)
CreatedAt time.Time `json:"created_at"`
}
// PostContentUnlock 帖子积分解锁记录
type PostContentUnlock struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_unlock_user_post_block;not null" json:"user_id"`
PostID uint `gorm:"uniqueIndex:idx_unlock_user_post_block;index;not null" json:"post_id"`
BlockKey string `gorm:"uniqueIndex:idx_unlock_user_post_block;size:64;not null" json:"block_key"`
Cost int `gorm:"not null" json:"cost"`
CreatedAt time.Time `json:"created_at"`
}
// SitePage 自定义单页(关于我们、版规等)
type SitePage struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:128;not null" json:"title"`
Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"`
Content string `gorm:"type:text;not null" json:"content"`
Published bool `gorm:"default:false;index" json:"published"`
SortOrder int `gorm:"default:0" json:"sort_order"`
ShowInFooter bool `gorm:"default:false" json:"show_in_footer"`
ShowInNav bool `gorm:"default:false" json:"show_in_nav"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// Poll 投票帖配置
type Poll struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex;not null" json:"post_id"`
Multi bool `gorm:"default:false" json:"multi"`
MaxChoices int `gorm:"default:1" json:"max_choices"`
Closed bool `gorm:"default:false;index" json:"closed"`
EndsAt *time.Time `gorm:"index" json:"ends_at,omitempty"`
}
// PollOption 投票选项
type PollOption struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
Text string `gorm:"size:64;not null" json:"text"`
SortOrder int `gorm:"default:0" json:"sort_order"`
VoteCount int `gorm:"default:0" json:"vote_count"`
}
// PollVote 投票记录
type PollVote struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"post_id"`
OptionID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"option_id"`
UserID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"user_id"`
}
// PostLotteryWinner 帖内抽奖中奖记录
type PostLotteryWinner struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
CommentID uint `gorm:"default:0" json:"comment_id"`
CreatedAt time.Time `json:"created_at"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// 徽章类型
const (
BadgeKindAuto = "auto"
BadgeKindLimited = "limited"
)
// 自动徽章指标
const (
BadgeMetricTenureDays = "tenure_days"
BadgeMetricLikesReceived = "likes_received"
BadgeMetricCreatorIncome = "creator_income"
)
// BadgeDef 徽章定义
type BadgeDef struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"uniqueIndex;size:64;not null" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:256;default:''" json:"description"`
Icon string `gorm:"size:64;default:''" json:"icon"` // lucide / 固定 key
Kind string `gorm:"size:16;index;not null" json:"kind"` // auto|limited
Metric string `gorm:"size:32;default:''" json:"metric"`
Threshold int `gorm:"default:0" json:"threshold"`
SortOrder int `gorm:"default:0" json:"sort_order"`
Enabled bool `gorm:"default:true;index" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserBadge 用户已获徽章
type UserBadge struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_badge;not null" json:"user_id"`
BadgeID uint `gorm:"uniqueIndex:idx_user_badge;index;not null" json:"badge_id"`
AwardedAt time.Time `json:"awarded_at"`
AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统
Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"`
}

31
models/oauth.go Normal file
View File

@@ -0,0 +1,31 @@
package models
import "time"
// OAuthClient OIDC/OAuth2 客户端应用(密钥仅存 bcrypt 哈希)
type OAuthClient struct {
ID uint `gorm:"primaryKey" json:"id"`
ClientID string `gorm:"uniqueIndex;size:128;not null" json:"client_id"`
ClientSecretHash string `gorm:"size:128;not null" json:"-"`
Name string `gorm:"size:128;not null" json:"name"`
RedirectURIs string `gorm:"size:2048;not null" json:"redirect_uris"`
Enabled bool `gorm:"default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OAuthAuthCode 一次性授权码(OIDC Authorization Code Flow)
type OAuthAuthCode struct {
ID uint `gorm:"primaryKey"`
Code string `gorm:"uniqueIndex;size:64;not null"`
ClientID string `gorm:"size:128;not null;index"`
UserID uint `gorm:"not null;index"`
RedirectURI string `gorm:"size:512;not null"`
Scope string `gorm:"size:256;default:''"`
Nonce string `gorm:"size:128;default:''"`
CodeChallenge string `gorm:"size:128;default:''"`
CodeChallengeMethod string `gorm:"size:16;default:''"`
ExpiresAt time.Time `gorm:"not null;index"`
Used bool `gorm:"default:false"`
CreatedAt time.Time
}

14
models/session.go Normal file
View File

@@ -0,0 +1,14 @@
package models
import "time"
// Session 浏览器 opaque 会话(Cookie 只存 Id)
type Session struct {
ID string `gorm:"primaryKey;size:64" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
IP string `gorm:"size:45" json:"ip,omitempty"`
UserAgent string `gorm:"size:256" json:"user_agent,omitempty"`
}

149
models/user_view.go Normal file
View File

@@ -0,0 +1,149 @@
package models
import "time"
// UserBadgeView 对外展示的徽章摘要
type UserBadgeView struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Kind string `json:"kind"`
}
// UserPublic 公开用户主页视图(无邮箱与登录信息)
type UserPublic struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Signature string `json:"signature"`
Avatar string `json:"avatar"`
Role Role `json:"role"`
Verified bool `json:"verified"`
Exp int `json:"exp"`
Level int `json:"level"`
CreatorIncomeTotal int `json:"creator_income_total"`
Badges []UserBadgeView `json:"badges,omitempty"`
Banned bool `json:"banned"`
BannedAt *time.Time `json:"banned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// UserSelf 当前登录用户视图(含邮箱,不含登录 IP)
type UserSelf struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Nickname string `json:"nickname"`
Signature string `json:"signature"`
Avatar string `json:"avatar"`
Role Role `json:"role"`
Verified bool `json:"verified"`
Exp int `json:"exp"`
Level int `json:"level"`
Points int `json:"points"`
CreatorIncomeTotal int `json:"creator_income_total"`
Badges []UserBadgeView `json:"badges,omitempty"`
Banned bool `json:"banned"`
BannedAt *time.Time `json:"banned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserAdmin 后台用户管理视图(含邮箱、上次登录与最近访问)
type UserAdmin struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Nickname string `json:"nickname"`
Signature string `json:"signature"`
Avatar string `json:"avatar"`
Role Role `json:"role"`
Verified bool `json:"verified"`
Exp int `json:"exp"`
Level int `json:"level"`
Points int `json:"points"`
CreatorIncomeTotal int `json:"creator_income_total"`
Banned bool `json:"banned"`
BannedAt *time.Time `json:"banned_at,omitempty"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
LastLoginIP string `json:"last_login_ip,omitempty"`
LastAccessAt *time.Time `json:"last_access_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToPublic 转为公开主页视图
func (u *User) ToPublic() UserPublic {
return UserPublic{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Signature: u.Signature,
Avatar: u.Avatar,
Role: u.Role,
Verified: u.Verified,
Exp: u.Exp,
Level: LevelFromExp(u.Exp),
CreatorIncomeTotal: u.CreatorIncomeTotal,
Banned: u.Banned,
BannedAt: u.BannedAt,
CreatedAt: u.CreatedAt,
}
}
// ToSelf 转为个人中心 /api/me 视图
func (u *User) ToSelf() UserSelf {
return UserSelf{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Nickname: u.Nickname,
Signature: u.Signature,
Avatar: u.Avatar,
Role: u.Role,
Verified: u.Verified,
Exp: u.Exp,
Level: LevelFromExp(u.Exp),
Points: u.Points,
CreatorIncomeTotal: u.CreatorIncomeTotal,
Banned: u.Banned,
BannedAt: u.BannedAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// ToAdmin 转为后台用户列表视图
func (u *User) ToAdmin() UserAdmin {
return UserAdmin{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Nickname: u.Nickname,
Signature: u.Signature,
Avatar: u.Avatar,
Role: u.Role,
Verified: u.Verified,
Exp: u.Exp,
Level: LevelFromExp(u.Exp),
Points: u.Points,
CreatorIncomeTotal: u.CreatorIncomeTotal,
Banned: u.Banned,
BannedAt: u.BannedAt,
LastLoginAt: u.LastLoginAt,
LastLoginIP: u.LastLoginIP,
LastAccessAt: u.LastAccessAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// UsersToAdmin 批量转换后台用户列表
func UsersToAdmin(users []User) []UserAdmin {
out := make([]UserAdmin, 0, len(users))
for i := range users {
out = append(out, users[i].ToAdmin())
}
return out
}

178
modules/auth/auth.go Normal file
View File

@@ -0,0 +1,178 @@
package auth
import (
"fmt"
"net/http"
"net/url"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
const (
CtxUserID = "user_id"
CtxUsername = "username"
CtxRole = "role"
CtxSessionID = "session_id"
CookieName = "jiang13_session"
)
type AuthMiddleware struct {
auth *services.AuthService
}
func NewAuthMiddleware(auth *services.AuthService) *AuthMiddleware {
return &AuthMiddleware{auth: auth}
}
// OptionalAuth 可选鉴权:有会话则加载用户;禁言/无效则清 Cookie
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
sid := extractSessionID(c)
if sid == "" {
c.Next()
return
}
user, sess, err := services.ResolveSession(sid)
if err != nil || user == nil {
ClearAuthCookie(c)
c.Next()
return
}
if user.Banned {
services.RevokeUserSessions(user.ID)
ClearAuthCookie(c)
c.Next()
return
}
c.Set(CtxUserID, user.ID)
c.Set(CtxUsername, user.Username)
c.Set(CtxRole, user.Role)
c.Set(CtxSessionID, sess.ID)
m.auth.TouchLastAccess(user.ID)
c.Next()
}
}
// RequireAuth 必须登录且未禁言
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
sid := extractSessionID(c)
if sid == "" {
respondAuthRequired(c)
c.Abort()
return
}
user, sess, err := services.ResolveSession(sid)
if err != nil || user == nil {
respondAuthExpired(c)
c.Abort()
return
}
if user.Banned {
services.RevokeUserSessions(user.ID)
ClearAuthCookie(c)
respondBanned(c)
c.Abort()
return
}
c.Set(CtxUserID, user.ID)
c.Set(CtxUsername, user.Username)
c.Set(CtxRole, user.Role)
c.Set(CtxSessionID, sess.ID)
m.auth.TouchLastAccess(user.ID)
c.Next()
}
}
// RequireAdmin 必须管理员(依赖上游已跑 OptionalAuth/RequireAuth,role 来自 DB)
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
uid, ok := c.Get(CtxUserID)
if !ok || uid == nil {
respondAuthRequired(c)
c.Abort()
return
}
role, exists := c.Get(CtxRole)
if !exists || role != models.RoleAdmin {
if isAPI(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
} else {
c.Redirect(http.StatusFound, "/")
}
c.Abort()
return
}
c.Next()
}
}
func extractSessionID(c *gin.Context) string {
if sid, err := c.Cookie(CookieName); err == nil && sid != "" {
return sid
}
// 兼容清理旧 Cookie 名(一次性)
if old, err := c.Cookie("jiang13_token"); err == nil && old != "" {
ClearNamedCookie(c, "jiang13_token")
}
return ""
}
func isAPI(c *gin.Context) bool {
return strings.HasPrefix(c.Request.URL.Path, "/api/")
}
func adminLoginPath(c *gin.Context) string {
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
return "/login?redirect=" + url.QueryEscape("/admin/dashboard")
}
return "/login"
}
func respondAuthRequired(c *gin.Context) {
if isAPI(c) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
redir := c.Request.URL.RequestURI()
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
redir = "/admin/dashboard"
}
c.Redirect(http.StatusFound, "/login?redirect="+url.QueryEscape(redir))
}
func respondAuthExpired(c *gin.Context) {
ClearAuthCookie(c)
if isAPI(c) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期"})
return
}
c.Redirect(http.StatusFound, adminLoginPath(c))
}
func respondBanned(c *gin.Context) {
if isAPI(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
return
}
c.Redirect(http.StatusFound, "/login?banned=1")
}
// RateLimitMiddleware 限流中间件
func RateLimitMiddleware(limiter *services.RateLimiter, action string) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.ClientIP()
if uid, ok := c.Get(CtxUserID); ok {
key = fmt.Sprintf("%d", uid.(uint))
}
if !limiter.Allow(action, key) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
c.Abort()
return
}
c.Next()
}
}

43
modules/auth/cookie.go Normal file
View File

@@ -0,0 +1,43 @@
package auth
import (
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// ClearAuthCookie 清除登录会话 Cookie
func ClearAuthCookie(c *gin.Context) {
ClearNamedCookie(c, CookieName)
ClearNamedCookie(c, "jiang13_token") // 清旧名
}
// ClearNamedCookie 按名清除
func ClearNamedCookie(c *gin.Context, name string) {
secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// SetSessionCookie 写入 opaque session id
func SetSessionCookie(c *gin.Context, sessionID string) {
secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName,
Value: sessionID,
Path: "/",
MaxAge: services.SessionCookieMaxAge(),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}

16
modules/seo/pagemeta.go Normal file
View File

@@ -0,0 +1,16 @@
package seo
// PageMeta 页面级 SEO / 社交预览元数据(SSR 模板 head)
type PageMeta struct {
Title string // 完整 <title>
Description string
Keywords string // meta keywords
Canonical string
OGType string // 默认 website
OGImage string
SiteName string // og:site_name
Locale string // og:locale,默认 zh_CN
Robots string // 如 noindex,nofollow
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
Status int // HTTP 状态码,0 视为 200
}

181
modules/webctx/context.go Normal file
View File

@@ -0,0 +1,181 @@
package webctx
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"html/template"
"net/http"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/modules/webrender"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
const (
csrfCookie = "jiang13_csrf"
flashCookie = "jiang13_flash"
)
// Context 浏览器请求上下文(对齐 Gitea context 的精简版)
type Context struct {
C *gin.Context
Doer *models.User
Secret string
}
// New 从 Gin 构造;依赖 OptionalAuth / RequireAuth 已写入 user 信息时可再查库
func New(c *gin.Context, secret string) *Context {
ctx := &Context{C: c, Secret: secret}
if id, ok := c.Get(auth.CtxUserID); ok {
if uid, ok := id.(uint); ok && uid > 0 {
var u models.User
if err := models.DB.First(&u, uid).Error; err == nil {
ctx.Doer = &u
}
}
}
return ctx
}
func (ctx *Context) IsSigned() bool { return ctx.Doer != nil }
func (ctx *Context) IsAdmin() bool {
return ctx.Doer != nil && ctx.Doer.Role == models.RoleAdmin
}
func (ctx *Context) UserID() uint {
if ctx.Doer == nil {
return 0
}
return ctx.Doer.ID
}
// SkipsModeration 管理员或认证用户免审
func (ctx *Context) SkipsModeration() bool {
return ctx.Doer != nil && ctx.Doer.SkipsModeration()
}
// HTML 渲染命名模板
func (ctx *Context) HTML(status int, name string, data any) {
ctx.C.Header("Content-Type", "text/html; charset=utf-8")
ctx.C.Status(status)
if err := webrender.Execute(ctx.C.Writer, name, data); err != nil {
ctx.C.String(http.StatusInternalServerError, "模板渲染失败")
}
}
// Redirect 303 见其它 URI(PRG)
func (ctx *Context) Redirect(url string) {
ctx.C.Redirect(http.StatusSeeOther, url)
}
// SetFlash 一次性提示(下一请求读取)
func (ctx *Context) SetFlash(msg string) {
v := base64.RawURLEncoding.EncodeToString([]byte(msg))
ctx.writeCookie(flashCookie, v, 120, true)
}
// TakeFlash 读取并清除
func (ctx *Context) TakeFlash() string {
v, err := ctx.C.Cookie(flashCookie)
if err != nil || v == "" {
return ""
}
ctx.writeCookie(flashCookie, "", -1, true)
b, err := base64.RawURLEncoding.DecodeString(v)
if err != nil {
return ""
}
return string(b)
}
// EnsureCSRF 保证 CSRF cookie,并返回表单 token
func (ctx *Context) EnsureCSRF() string {
if t, err := ctx.C.Cookie(csrfCookie); err == nil && t != "" && ctx.validCSRF(t) {
return t
}
t := ctx.newCSRF()
ctx.writeCookie(csrfCookie, t, int((12 * time.Hour).Seconds()), true)
return t
}
// CheckCSRF 校验表单 _csrf(或请求头 X-CSRF-Token,供上传 fetch)
func (ctx *Context) CheckCSRF() bool {
form := strings.TrimSpace(ctx.C.PostForm("_csrf"))
if form == "" {
form = strings.TrimSpace(ctx.C.GetHeader("X-CSRF-Token"))
}
cookie, _ := ctx.C.Cookie(csrfCookie)
if form == "" || cookie == "" || form != cookie {
return false
}
return ctx.validCSRF(form)
}
func (ctx *Context) newCSRF() string {
ts := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, []byte(ctx.Secret))
_, _ = mac.Write([]byte(ts))
sig := hex.EncodeToString(mac.Sum(nil))[:32]
return ts + "." + sig
}
func (ctx *Context) validCSRF(token string) bool {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return false
}
ts, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return false
}
if time.Since(time.Unix(ts, 0)) > 12*time.Hour {
return false
}
mac := hmac.New(sha256.New, []byte(ctx.Secret))
_, _ = mac.Write([]byte(parts[0]))
sig := hex.EncodeToString(mac.Sum(nil))[:32]
return hmac.Equal([]byte(sig), []byte(parts[1]))
}
// SetLoginCookie 写入 opaque 会话 Cookie
func (ctx *Context) SetLoginCookie(sessionID string) {
auth.SetSessionCookie(ctx.C, sessionID)
}
// ClearLoginCookie 退出并删 Cookie;若有 session id 则吊销
func (ctx *Context) ClearLoginCookie() {
if sid, err := ctx.C.Cookie(auth.CookieName); err == nil && sid != "" {
services.DeleteSession(sid)
}
auth.ClearAuthCookie(ctx.C)
}
func (ctx *Context) writeCookie(name, value string, maxAge int, httpOnly bool) {
secure := requestIsHTTPS(ctx.C)
http.SetCookie(ctx.C.Writer, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: httpOnly,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// requestIsHTTPS 直连 TLS 或反代 X-Forwarded-Proto
func requestIsHTTPS(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
// SafeHTML 供模板使用的类型别名说明(实际转换在 webrender FuncMap)
type SafeHTML = template.HTML

124
modules/webrender/render.go Normal file
View File

@@ -0,0 +1,124 @@
package webrender
import (
"fmt"
"html/template"
"io"
"net/url"
"strconv"
"sync"
apptemplates "git.iioio.com/freefire/jiang13-forum/templates"
)
var (
loadOnce sync.Once
tpl *template.Template
loadErr error
)
func funcMap() template.FuncMap {
return template.FuncMap{
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"dict": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict 需要偶数个参数")
}
m := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
k, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict 键必须是字符串")
}
m[k] = values[i+1]
}
return m, nil
},
"sortURL": func(boardID uint, sort string) string {
q := url.Values{}
if sort != "" && sort != "latest" {
q.Set("sort", sort)
}
path := "/"
if boardID > 0 {
path = fmt.Sprintf("/board/%d", boardID)
}
if enc := q.Encode(); enc != "" {
return path + "?" + enc
}
return path
},
"pageURL": func(boardID uint, sort string, page int) string {
q := url.Values{}
if sort != "" && sort != "latest" {
q.Set("sort", sort)
}
if page > 1 {
q.Set("page", strconv.Itoa(page))
}
path := "/"
if boardID > 0 {
path = fmt.Sprintf("/board/%d", boardID)
}
if enc := q.Encode(); enc != "" {
return path + "?" + enc
}
return path
},
"postURL": func(id uint) string {
return fmt.Sprintf("/post/%d", id)
},
"queryEscape": url.QueryEscape,
}
}
var parseGlobs = []string{
"*.tmpl",
"base/*.tmpl",
"home/*.tmpl",
"post/*.tmpl",
"shared/*.tmpl",
"status/*.tmpl",
"auth/*.tmpl",
"admin/*.tmpl",
"profile/*.tmpl",
"user/*.tmpl",
"favorites/*.tmpl",
"messages/*.tmpl",
"links/*.tmpl",
"boards/*.tmpl",
"page/*.tmpl",
}
// Load 解析全部模板(进程内一次)
func Load() (*template.Template, error) {
loadOnce.Do(func() {
root := template.New("root").Funcs(funcMap())
var err error
for _, g := range parseGlobs {
root, err = root.ParseFS(apptemplates.FS, g)
if err != nil {
loadErr = err
return
}
}
tpl = root
})
return tpl, loadErr
}
// Execute 渲染命名模板到 w
func Execute(w io.Writer, name string, data any) error {
t, err := Load()
if err != nil {
return err
}
return t.ExecuteTemplate(w, name, data)
}
// ResetForTest 测试用重置
func ResetForTest() {
loadOnce = sync.Once{}
tpl = nil
loadErr = nil
}

2358
public/assets/site.css Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,482 @@
// 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1";
(function themeToggle() {
var KEY = "j13-theme";
var LABELS = { system: "跟随系统", light: "浅色", dark: "暗色" };
var ORDER = ["system", "light", "dark"];
function normalizePref(raw) {
if (raw === "light" || raw === "dark" || raw === "system") return raw;
return "system";
}
function apply(pref) {
pref = normalizePref(pref);
var root = document.documentElement;
if (pref === "system") {
root.removeAttribute("data-theme");
root.style.removeProperty("color-scheme");
} else {
root.setAttribute("data-theme", pref);
root.style.colorScheme = pref;
}
root.setAttribute("data-theme-pref", pref);
try {
localStorage.setItem(KEY, pref);
} catch (e) {}
var btn = document.getElementById("j13-theme-toggle");
if (btn) {
var label = btn.querySelector(".j13-theme-toggle__label");
var text = LABELS[pref] || LABELS.system;
if (label) {
label.textContent = text;
} else {
btn.textContent = text;
}
btn.setAttribute("aria-label", "主题:" + text);
btn.title = "当前:" + text + "(点击切换)";
}
}
function currentPref() {
try {
return normalizePref(localStorage.getItem(KEY) || "system");
} catch (e) {
return "system";
}
}
apply(currentPref());
var btn = document.getElementById("j13-theme-toggle");
if (btn) {
btn.addEventListener("click", function () {
var cur = currentPref();
var idx = ORDER.indexOf(cur);
var next = ORDER[(idx + 1) % ORDER.length];
apply(next);
});
}
})();
(function headerSearchUX() {
var input = document.getElementById("j13-header-search-input");
var filterBtn = document.getElementById("j13-search-filter-btn");
var advanced = document.getElementById("search");
if (filterBtn && advanced) {
filterBtn.addEventListener("click", function () {
advanced.open = true;
filterBtn.classList.add("header-search-filter-btn--active");
var kw = advanced.querySelector('input[name="keyword"]');
if (kw) kw.focus();
advanced.scrollIntoView({ behavior: "smooth", block: "nearest" });
});
}
document.addEventListener("keydown", function (e) {
if (!(e.ctrlKey || e.metaKey) || (e.key !== "k" && e.key !== "K")) return;
var tag = (e.target && e.target.tagName) || "";
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target && e.target.isContentEditable)) {
if (e.target === input) return;
}
e.preventDefault();
if (input) {
input.focus();
input.select();
}
});
})();
(function mdEditors() {
function insertAtCursor(textarea, text) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
textarea.value =
textarea.value.slice(0, start) + text + textarea.value.slice(end);
const pos = start + text.length;
textarea.focus();
textarea.setSelectionRange(pos, pos);
}
function wrapSelection(textarea, before, after, fallback) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
let selected = textarea.value.slice(start, end);
if (!selected) selected = fallback || "";
const block = before + selected + after;
textarea.value =
textarea.value.slice(0, start) + block + textarea.value.slice(end);
textarea.focus();
if (fallback && !textarea.value.slice(start, end)) {
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
} else {
textarea.setSelectionRange(start + block.length, start + block.length);
}
}
function prefixLines(textarea, prefix) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
const value = textarea.value;
let lineStart = value.lastIndexOf("\n", start - 1) + 1;
let lineEnd = value.indexOf("\n", end);
if (lineEnd < 0) lineEnd = value.length;
const chunk = value.slice(lineStart, lineEnd);
const next = chunk
.split("\n")
.map((ln) => (ln.trim() ? prefix + ln.replace(/^\s+/, "") : prefix.trimEnd()))
.join("\n");
textarea.value = value.slice(0, lineStart) + next + value.slice(lineEnd);
textarea.focus();
textarea.setSelectionRange(lineStart, lineStart + next.length);
}
function wrapGate(textarea, openTag, closeTag, statusEl) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
let selected = textarea.value.slice(start, end);
if (!selected.trim()) selected = "在此填写隐藏内容";
let inner = selected;
if (!/^\s*</.test(inner)) {
inner = "<p>" + selected.replace(/\n/g, "<br>\n") + "</p>";
}
const block = openTag + inner + closeTag;
textarea.value =
textarea.value.slice(0, start) + block + textarea.value.slice(end);
textarea.focus();
textarea.setSelectionRange(start + block.length, start + block.length);
if (statusEl) statusEl.textContent = "已插入门控块";
}
function initEditor(root) {
const textarea = root.querySelector("[data-md-input]");
if (!textarea) return;
const statusEl = root.querySelector("[data-md-status]");
const previewEl = root.querySelector("[data-md-preview]");
const csrf = root.getAttribute("data-csrf") || "";
const uploadURL = root.getAttribute("data-upload") || "/compose/upload";
const previewURL = root.getAttribute("data-preview") || "/compose/preview";
const unsaved = root.getAttribute("data-unsaved") === "1";
let dirty = false;
let previewOn = false;
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg || "";
}
root.querySelectorAll("[data-md-cmd]").forEach((btn) => {
btn.addEventListener("click", async () => {
const cmd = btn.getAttribute("data-md-cmd");
if (cmd === "preview") {
previewOn = !previewOn;
if (previewOn) {
setStatus("预览中…");
try {
const fd = new FormData();
fd.append("content", textarea.value);
fd.append("_csrf", csrf);
const res = await fetch(previewURL, {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "预览失败");
previewEl.innerHTML = data.html || "<p class=\"j13-muted\">(空)</p>";
previewEl.hidden = false;
textarea.hidden = true;
btn.classList.add("is-active");
setStatus("预览模式(再点返回编辑)");
} catch (e) {
previewOn = false;
setStatus(e.message || "预览失败");
}
} else {
previewEl.hidden = true;
previewEl.innerHTML = "";
textarea.hidden = false;
btn.classList.remove("is-active");
textarea.focus();
setStatus("");
}
return;
}
if (previewOn) return;
switch (cmd) {
case "bold":
wrapSelection(textarea, "**", "**", "粗体");
break;
case "italic":
wrapSelection(textarea, "*", "*", "斜体");
break;
case "strike":
wrapSelection(textarea, "~~", "~~", "删除线");
break;
case "code":
wrapSelection(textarea, "`", "`", "code");
break;
case "link": {
const url = window.prompt("链接地址", "https://");
if (!url) return;
wrapSelection(textarea, "[", "](" + url + ")", "链接文字");
break;
}
case "h2":
prefixLines(textarea, "## ");
break;
case "h3":
prefixLines(textarea, "### ");
break;
case "ul":
prefixLines(textarea, "- ");
break;
case "ol":
prefixLines(textarea, "1. ");
break;
case "quote":
prefixLines(textarea, "> ");
break;
case "fence":
wrapSelection(textarea, "```\n", "\n```", "code");
break;
case "gate-login":
wrapGate(textarea, '<members-only data-gate="login">', "</members-only>", statusEl);
break;
case "gate-reply":
wrapGate(textarea, '<reply-only data-gate="reply">', "</reply-only>", statusEl);
break;
case "gate-points": {
let cost = window.prompt("积分价格(1–9999)", "10");
if (cost == null) return;
const n = parseInt(String(cost).trim(), 10);
if (!n || n < 1 || n > 9999) {
setStatus("价格须为 1–9999 的整数");
return;
}
wrapGate(
textarea,
'<points-only data-gate="points" data-cost="' + n + '">',
"</points-only>",
statusEl
);
break;
}
}
dirty = true;
});
});
const fileInput = root.querySelector("[data-md-upload]");
if (fileInput) {
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!file || previewOn) return;
setStatus("上传中…");
const fd = new FormData();
fd.append("image", file);
fd.append("_csrf", csrf);
try {
const res = await fetch(uploadURL, {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "上传失败");
if (!data.url) throw new Error("未返回图片地址");
insertAtCursor(textarea, "\n\n![](" + data.url + ")\n\n");
dirty = true;
setStatus("已插入图片");
} catch (e) {
setStatus(e.message || "上传失败");
}
});
}
textarea.addEventListener("keydown", (e) => {
if (e.key !== "Tab" || previewOn) return;
e.preventDefault();
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
if (e.shiftKey) {
const value = textarea.value;
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
const line = value.slice(lineStart, end);
const next = line.replace(/^( {1,2}|\t)/gm, "");
textarea.value = value.slice(0, lineStart) + next + value.slice(end);
textarea.setSelectionRange(lineStart, lineStart + next.length);
} else {
insertAtCursor(textarea, " ");
}
dirty = true;
});
textarea.addEventListener("input", () => {
dirty = true;
});
if (unsaved) {
const form = root.closest("form");
window.addEventListener("beforeunload", (e) => {
if (!dirty) return;
e.preventDefault();
e.returnValue = "";
});
if (form) {
form.addEventListener("submit", () => {
dirty = false;
});
}
}
}
document.querySelectorAll("[data-j13-md-editor]").forEach(initEditor);
})();
(function postContentGates() {
const article = document.querySelector(".j13-post[data-post-id]");
if (!article) return;
const postId = article.getAttribute("data-post-id");
const csrf = article.getAttribute("data-csrf") || "";
const loggedIn = article.getAttribute("data-logged-in") === "1";
const path = window.location.pathname + window.location.hash;
article.querySelectorAll('[data-locked="true"]').forEach((el) => {
const gate = el.getAttribute("data-gate") || "";
const shell = el.querySelector("[data-gate-shell]");
if (!shell) return;
if (gate === "login" && !loggedIn) {
const a = document.createElement("a");
a.href = "/login?redirect=" + encodeURIComponent(path || "/");
a.textContent = "登录查看";
const p = document.createElement("p");
p.appendChild(a);
shell.appendChild(p);
}
if (gate === "reply" && loggedIn) {
const a = document.createElement("a");
a.href = "#comments";
a.textContent = "去评论解锁";
const p = document.createElement("p");
p.appendChild(a);
shell.appendChild(p);
}
if (gate === "points") {
const key = el.getAttribute("data-block-key") || "";
const cost = el.getAttribute("data-cost") || "";
if (!loggedIn) {
const a = document.createElement("a");
a.href = "/login?redirect=" + encodeURIComponent(path || "/");
a.textContent = "登录后解锁";
const p = document.createElement("p");
p.appendChild(a);
shell.appendChild(p);
return;
}
if (!key) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "j13-gate__unlock";
btn.textContent = "花费 " + cost + " 积分解锁";
btn.addEventListener("click", async () => {
btn.disabled = true;
btn.textContent = "解锁中…";
try {
const res = await fetch("/post/" + postId + "/unlock", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
},
credentials: "same-origin",
body: JSON.stringify({ block_key: key }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "解锁失败");
const inner = data.unlock && data.unlock.inner_html;
if (typeof inner !== "string") throw new Error("未返回内容");
el.setAttribute("data-locked", "false");
el.innerHTML = inner;
} catch (e) {
btn.disabled = false;
btn.textContent = "花费 " + cost + " 积分解锁";
alert(e.message || "解锁失败");
}
});
const p = document.createElement("p");
p.appendChild(btn);
shell.appendChild(p);
}
});
})();
// 友链申请:选择文件后先上传并回填 LOGO URL
(function () {
const form = document.querySelector("form[data-logo-upload]");
if (!form) return;
const fileInput = form.querySelector("#fl-logo-file");
const urlInput = form.querySelector("#fl-logo-url");
const csrfInput = form.querySelector('input[name="_csrf"]');
if (!fileInput || !urlInput || !csrfInput) return;
const uploadURL = form.getAttribute("data-logo-upload") || "/links/logo";
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
const fd = new FormData();
fd.append("logo", file);
fd.append("_csrf", csrfInput.value);
try {
const res = await fetch(uploadURL, { method: "POST", body: fd, credentials: "same-origin" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "上传失败");
if (data.url) urlInput.value = data.url;
} catch (e) {
alert(e.message || "上传失败");
fileInput.value = "";
}
});
})();
// 评论回复:点击「回复」填入 reply_to
(function () {
const form = document.getElementById("comment-form");
if (!form) return;
const replyInput = document.getElementById("comment-reply-to");
const hint = document.getElementById("comment-reply-hint");
const clearBtn = document.getElementById("comment-reply-clear");
const content = document.getElementById("comment-content");
function clearReply() {
if (replyInput) replyInput.value = "";
if (hint) {
hint.hidden = true;
hint.textContent = "";
}
if (clearBtn) clearBtn.hidden = true;
}
function setReply(id, floor, author) {
if (!replyInput) return;
replyInput.value = id;
if (hint) {
hint.hidden = false;
hint.textContent = "回复 #" + floor + (author ? " " + author : "");
}
if (clearBtn) clearBtn.hidden = false;
if (content) content.focus();
form.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
document.querySelectorAll(".j13-comment__reply-btn").forEach((btn) => {
btn.addEventListener("click", () => {
setReply(
btn.getAttribute("data-reply-to") || "",
btn.getAttribute("data-reply-floor") || "",
btn.getAttribute("data-reply-author") || ""
);
});
});
if (clearBtn) clearBtn.addEventListener("click", clearReply);
})();

8
public/embed.go Normal file
View File

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

32
routers/api/handlers.go Normal file
View File

@@ -0,0 +1,32 @@
package api
import (
"net/http"
"git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// Handlers 本分支仅挂载机器入口(health / SEO / thumb / OIDC)。
// 论坛 CRUD JSON 已删除,对照见 main 与 docs/rebuild-spec/04-api.md。
type Handlers struct {
Cfg *config.Config
Settings *services.ForumSettingsService
Board *services.BoardService
Post *services.PostService
OIDC *services.OIDCService
}
func (h *Handlers) currentUserID(c *gin.Context) uint {
if v, ok := c.Get(auth.CtxUserID); ok {
return v.(uint)
}
return 0
}
// APIHealth 探活
func (h *Handlers) APIHealth(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

224
routers/api/oidc.go Normal file
View File

@@ -0,0 +1,224 @@
package api
import (
"encoding/base64"
"errors"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/services"
)
// OIDCDiscovery OpenID Provider 元数据
func (h *Handlers) OIDCDiscovery(c *gin.Context) {
if h.OIDC == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
return
}
doc, err := h.OIDC.Discovery()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, doc)
}
// OIDCJWKS JSON Web Key Set
func (h *Handlers) OIDCJWKS(c *gin.Context) {
if h.OIDC == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
return
}
doc, err := h.OIDC.JWKS()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, doc)
}
// OIDCAuthorize 授权端点:已登录则静默发码;未登录跳转论坛登录
func (h *Handlers) OIDCAuthorize(c *gin.Context) {
if h.OIDC == nil || !h.OIDC.Enabled() {
c.String(http.StatusServiceUnavailable, "OIDC 未配置,请在管理后台「系统设置 → OIDC / SSO」启用并创建 OAuth 应用")
return
}
req := services.AuthorizeRequest{
ClientID: c.Query("client_id"),
RedirectURI: c.Query("redirect_uri"),
ResponseType: c.Query("response_type"),
Scope: c.Query("scope"),
State: c.Query("state"),
Nonce: c.Query("nonce"),
CodeChallenge: c.Query("code_challenge"),
CodeChallengeMethod: c.Query("code_challenge_method"),
}
if err := h.OIDC.ValidateAuthorize(req); err != nil {
// redirect_uri 未通过校验时不能重定向,避免开放重定向
if errors.Is(err, services.ErrOIDCInvalidRedirect) || errors.Is(err, services.ErrOIDCInvalidClient) {
c.String(http.StatusBadRequest, err.Error())
return
}
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "invalid_request", err.Error())
return
}
uid := h.currentUserID(c)
if uid == 0 {
from := c.Request.URL.RequestURI()
c.Redirect(http.StatusFound, "/login?from="+url.QueryEscape(from))
return
}
callback, err := h.OIDC.IssueAuthCode(uid, req)
if err != nil {
if errors.Is(err, services.ErrOIDCUserBanned) {
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
return
}
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "server_error", err.Error())
return
}
c.Redirect(http.StatusFound, callback)
}
func (h *Handlers) oidcErrorRedirect(c *gin.Context, redirectURI, state, code, desc string) {
if redirectURI == "" {
c.String(http.StatusBadRequest, desc)
return
}
u, err := url.Parse(redirectURI)
if err != nil {
c.String(http.StatusBadRequest, desc)
return
}
q := u.Query()
q.Set("error", code)
q.Set("error_description", desc)
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
c.Redirect(http.StatusFound, u.String())
}
// OIDCToken 令牌端点
func (h *Handlers) OIDCToken(c *gin.Context) {
if h.OIDC == nil || !h.OIDC.Enabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "temporarily_unavailable", "error_description": "OIDC 未配置"})
return
}
clientID, clientSecret := c.PostForm("client_id"), c.PostForm("client_secret")
if clientID == "" && clientSecret == "" {
if id, secret, ok := parseBasicAuth(c.GetHeader("Authorization")); ok {
clientID, clientSecret = id, secret
}
}
resp, err := h.OIDC.ExchangeCode(services.TokenRequest{
GrantType: c.PostForm("grant_type"),
Code: c.PostForm("code"),
RedirectURI: c.PostForm("redirect_uri"),
ClientID: clientID,
ClientSecret: clientSecret,
CodeVerifier: c.PostForm("code_verifier"),
})
if err != nil {
status := http.StatusBadRequest
code := "invalid_grant"
switch {
case errors.Is(err, services.ErrOIDCInvalidClient):
status = http.StatusUnauthorized
code = "invalid_client"
case errors.Is(err, services.ErrOIDCInvalidRequest):
code = "invalid_request"
case errors.Is(err, services.ErrOIDCPKCEFailed):
code = "invalid_grant"
}
c.JSON(status, gin.H{"error": code, "error_description": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// OIDCUserInfo 用户信息端点
func (h *Handlers) OIDCUserInfo(c *gin.Context) {
if h.OIDC == nil || !h.OIDC.Enabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未配置"})
return
}
token := extractBearer(c)
if token == "" {
c.Header("WWW-Authenticate", `Bearer`)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
info, err := h.OIDC.UserInfo(token)
if err != nil {
c.Header("WWW-Authenticate", `Bearer error="invalid_token"`)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
c.JSON(http.StatusOK, info)
}
// OIDCLogout RP-Initiated Logout:清除论坛会话并可选跳回客户端
func (h *Handlers) OIDCLogout(c *gin.Context) {
postLogout := c.Query("post_logout_redirect_uri")
if postLogout == "" {
postLogout = c.PostForm("post_logout_redirect_uri")
}
state := c.Query("state")
if state == "" {
state = c.PostForm("state")
}
c.SetCookie(auth.CookieName, "", -1, "/", "", false, true)
if h.OIDC == nil {
c.Redirect(http.StatusFound, "/")
return
}
target, err := h.OIDC.ResolveLogoutRedirect(postLogout, state)
if err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
c.Redirect(http.StatusFound, target)
}
func extractBearer(c *gin.Context) string {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
}
return c.Query("access_token")
}
func parseBasicAuth(header string) (user, pass string, ok bool) {
const prefix = "Basic "
if !strings.HasPrefix(header, prefix) {
return "", "", false
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(prefix):]))
if err != nil {
return "", "", false
}
parts := strings.SplitN(string(raw), ":", 2)
if len(parts) != 2 {
return "", "", false
}
// client_id / client_secret 可能被 URL 编码
uid, err1 := url.QueryUnescape(parts[0])
sec, err2 := url.QueryUnescape(parts[1])
if err1 != nil || err2 != nil {
return parts[0], parts[1], true
}
return uid, sec, true
}

132
routers/api/seo.go Normal file
View File

@@ -0,0 +1,132 @@
package api
import (
"net/http"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
const seoSitemapLimit = 5000
// RobotsTxt 搜索引擎抓取规则
func (h *Handlers) RobotsTxt(c *gin.Context) {
base := h.publicBaseURL(c)
var b strings.Builder
b.WriteString("User-agent: *\n")
b.WriteString("Allow: /\n")
b.WriteString("Disallow: /api/\n")
b.WriteString("Disallow: /admin\n")
b.WriteString("Disallow: /install\n")
b.WriteString("Disallow: /login\n")
b.WriteString("Disallow: /compose\n")
b.WriteString("Disallow: /oauth/\n")
if base != "" {
b.WriteString("\nSitemap: ")
b.WriteString(base)
b.WriteString("/sitemap.xml\n")
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
}
// SitemapXML 公开页面站点地图(与 SSR 同源路径)
func (h *Handlers) SitemapXML(c *gin.Context) {
base := h.publicBaseURL(c)
if base == "" {
c.String(http.StatusServiceUnavailable, "未配置站点 ROOT_URL,无法生成 sitemap")
return
}
now := time.Now().UTC()
permalink := h.Settings.Permalink()
urls := []services.SitemapURL{
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
}
if boards, err := h.Board.List(); err == nil {
for _, board := range boards {
urls = append(urls, services.SitemapURL{
Loc: base + services.QueryBoardHome(board.ID, permalink),
LastMod: board.UpdatedAt.UTC(),
ChangeFreq: "daily",
Priority: "0.7",
})
}
}
if posts, e1 := h.Post.ListSitemap(seoSitemapLimit); e1 == nil {
for _, p := range posts {
lm := p.UpdatedAt
if lm.IsZero() {
lm = p.CreatedAt
}
urls = append(urls, services.SitemapURL{
Loc: base + permalink.PostPath(p.ID),
LastMod: lm.UTC(),
ChangeFreq: "weekly",
Priority: "0.8",
})
}
}
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
for _, u := range urls {
b.WriteString("<url><loc>")
b.WriteString(xmlEscape(u.Loc))
b.WriteString("</loc>")
if !u.LastMod.IsZero() {
b.WriteString("<lastmod>")
b.WriteString(u.LastMod.Format("2006-01-02"))
b.WriteString("</lastmod>")
}
if u.ChangeFreq != "" {
b.WriteString("<changefreq>")
b.WriteString(u.ChangeFreq)
b.WriteString("</changefreq>")
}
if u.Priority != "" {
b.WriteString("<priority>")
b.WriteString(u.Priority)
b.WriteString("</priority>")
}
b.WriteString("</url>")
}
b.WriteString("</urlset>")
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
}
func (h *Handlers) publicBaseURL(c *gin.Context) string {
return h.Settings.SitePublicBaseURL(requestOrigin(c))
}
func requestOrigin(c *gin.Context) string {
proto := c.GetHeader("X-Forwarded-Proto")
if proto == "" {
if c.Request.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
host := c.GetHeader("X-Forwarded-Host")
if host == "" {
host = c.Request.Host
}
if host == "" {
return ""
}
return proto + "://" + host
}
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

32
routers/api/thumb.go Normal file
View File

@@ -0,0 +1,32 @@
package api
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/services"
)
// ServeImageThumb 帖子图片缩略图(按需生成并缓存)
// GET /media/thumb/posts/xxx.webp → 最长边 1280 的 WebP 预览
func (h *Handlers) ServeImageThumb(c *gin.Context) {
rel := strings.TrimPrefix(c.Param("filepath"), "/")
uploadsRoot := filepath.Join(h.Cfg.DataDir, "uploads")
thumbPath, err := services.EnsureUploadThumb(uploadsRoot, rel)
if err != nil {
// 生成失败时回退原图,避免正文裂图
orig := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
if st, e := os.Stat(orig); e == nil && !st.IsDir() {
c.Header("Cache-Control", "public, max-age=3600")
c.File(orig)
return
}
c.Status(http.StatusNotFound)
return
}
c.Header("Cache-Control", "public, max-age=604800, immutable")
c.File(thumbPath)
}

145
routers/install/install.go Normal file
View File

@@ -0,0 +1,145 @@
package install
import (
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// Deps 安装依赖
type Deps struct {
DataDir string
JWTSecret string
AssetVersion string
Auth *services.AuthService
Settings *services.ForumSettingsService
}
type pageData struct {
Title string
Description string
SiteName string
Slogan string
LogoMark string
LogoURL string
FaviconURL string
OGImageURL string
AssetVersion string
ShowFriendLinksFooter bool
FooterPages []struct {
Title string
Slug string
}
CSRF string
Error string
Flash string
AdminUsername string
AdminEmail string
AdminNickname string
}
// Register 未安装时的路由(仅 /install)
func Register(r *gin.Engine, deps Deps) {
r.GET("/install", deps.Get)
r.POST("/install", deps.Post)
}
// Get 安装页
func (d Deps) Get(c *gin.Context) {
if services.IsInstalled(d.DataDir) {
c.Redirect(http.StatusFound, "/")
return
}
ctx := webctx.New(c, d.JWTSecret)
brand := d.Settings.SiteBranding()
name := brand.Name
if name == "" {
name = "姜十三论坛"
}
ctx.HTML(http.StatusOK, "install", pageData{
Title: "安装 · " + name, SiteName: name, LogoMark: "姜",
AssetVersion: d.assetVersion(),
CSRF: ctx.EnsureCSRF(), AdminUsername: "admin",
})
}
// Post 提交安装
func (d Deps) Post(c *gin.Context) {
if services.IsInstalled(d.DataDir) {
c.Redirect(http.StatusSeeOther, "/")
return
}
ctx := webctx.New(c, d.JWTSecret)
brand := d.Settings.SiteBranding()
siteName := strings.TrimSpace(c.PostForm("site_name"))
if siteName == "" {
siteName = "姜十三论坛"
}
data := pageData{
Title: "安装 · " + siteName, SiteName: siteName, LogoMark: "姜",
AssetVersion: d.assetVersion(),
CSRF: ctx.EnsureCSRF(),
AdminUsername: strings.TrimSpace(c.PostForm("admin_username")),
AdminEmail: strings.TrimSpace(c.PostForm("admin_email")),
AdminNickname: strings.TrimSpace(c.PostForm("admin_nickname")),
}
if !ctx.CheckCSRF() {
data.Error = "无效请求,请重试"
ctx.HTML(http.StatusBadRequest, "install", data)
return
}
pass := c.PostForm("admin_password")
pass2 := c.PostForm("admin_password2")
if pass != pass2 {
data.Error = "两次密码不一致"
ctx.HTML(http.StatusBadRequest, "install", data)
return
}
if _, err := d.Auth.CreateAdmin(data.AdminUsername, pass, data.AdminNickname, data.AdminEmail); err != nil {
data.Error = err.Error()
ctx.HTML(http.StatusBadRequest, "install", data)
return
}
b := d.Settings.SiteBranding()
b.Name = siteName
_ = d.Settings.UpdateSiteBranding(b)
if err := services.WriteInstallLock(d.DataDir); err != nil {
data.Error = "写入安装锁失败: " + err.Error()
ctx.HTML(http.StatusInternalServerError, "install", data)
return
}
ctx.HTML(http.StatusOK, "post-install", pageData{
Title: "安装完成", SiteName: siteName, LogoMark: "姜",
AssetVersion: d.assetVersion(),
})
_ = brand
}
func (d Deps) assetVersion() string {
v := strings.TrimSpace(d.AssetVersion)
if v == "" {
return "dev"
}
return v
}
// Guard 未安装则只允许 install / assets / health
func Guard(dataDir string) gin.HandlerFunc {
return func(c *gin.Context) {
if services.IsInstalled(dataDir) {
c.Next()
return
}
path := c.Request.URL.Path
if path == "/install" || path == "/health" ||
strings.HasPrefix(path, "/ssr-assets/") {
c.Next()
return
}
c.Redirect(http.StatusFound, "/install")
c.Abort()
}
}

142
routers/setup.go Normal file
View File

@@ -0,0 +1,142 @@
package routers
import (
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
webpublic "git.iioio.com/freefire/jiang13-forum/public"
"git.iioio.com/freefire/jiang13-forum/routers/api"
"git.iioio.com/freefire/jiang13-forum/routers/install"
webpages "git.iioio.com/freefire/jiang13-forum/routers/web"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
func Setup(cfg *config.Config) (*gin.Engine, error) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
if err := services.EnsureInstallLockFromExistingData(cfg.DataDir); err != nil {
fmt.Fprintf(os.Stderr, "警告: 安装锁检查失败: %v\n", err)
}
if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil {
ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub)))
r.GET("/ssr-assets/*filepath", func(c *gin.Context) {
// 模板带 ?v=AssetVersion;部署换版本即穿透,可长缓存
c.Header("Cache-Control", "public, max-age=31536000, immutable")
ssrFiles.ServeHTTP(c.Writer, c.Request)
})
}
r.Use(install.Guard(cfg.DataDir))
filter := services.NewSensitiveFilter()
settingsSvc := services.NewForumSettingsService()
services.EnsureFilterWordsInSettings(settingsSvc, cfg.FilterWordsPath(), filter)
authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := services.NewUserService(filter, settingsSvc)
boardSvc := services.NewBoardService()
boardSvc.EnsureDefaultBoard()
postSvc := services.NewPostService(filter, settingsSvc)
commentSvc := services.NewCommentService(filter, settingsSvc)
messageSvc := services.NewMessageService(filter, settingsSvc)
reportSvc := services.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
backupSvc := services.NewBackupService(cfg.DBPath(), cfg.DataDir)
limiter := services.NewRateLimiter(settingsSvc)
captchaSvc := services.NewCaptchaService()
mailSvc := services.NewMailService(settingsSvc)
emailCodeSvc := services.NewEmailCodeService(mailSvc)
notifySvc := services.NewNotifyService(messageSvc, mailSvc, settingsSvc)
friendLinkApplySvc := services.NewFriendLinkApplyService(settingsSvc, messageSvc)
oidcSvc, err := services.NewOIDCService(cfg, settingsSvc)
if err != nil {
return nil, err
}
uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc)
if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil {
fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err)
_ = uploadStore.Apply(services.StorageConfig{Type: "local"})
}
go func() {
if n, err := uploadStore.SyncMediaIndex(); err != nil {
fmt.Fprintf(os.Stderr, "警告: 媒体索引同步失败: %v\n", err)
} else if n > 0 {
fmt.Fprintf(os.Stderr, "媒体索引已同步 %d 条\n", n)
}
}()
sitePageSvc := services.NewSitePageService(filter)
h := &api.Handlers{
Cfg: cfg, Settings: settingsSvc,
Board: boardSvc, Post: postSvc,
OIDC: oidcSvc,
}
authMW := auth.NewAuthMiddleware(authSvc)
assetVer := strings.TrimSpace(cfg.Version)
if assetVer == "" {
assetVer = "dev"
}
install.Register(r, install.Deps{
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
Auth: authSvc, Settings: settingsSvc,
AssetVersion: assetVer,
})
webpages.Register(r, webpages.Deps{
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
Settings: settingsSvc, Auth: authSvc, User: userSvc,
Board: boardSvc, Post: postSvc, Comment: commentSvc,
Message: messageSvc, Filter: filter,
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
Points: services.NewPointsService(),
Badge: services.NewBadgeService(),
FriendLink: friendLinkApplySvc,
Mail: mailSvc,
SitePage: sitePageSvc,
Report: reportSvc,
Captcha: captchaSvc,
Notify: notifySvc,
Backup: backupSvc,
AssetVersion: assetVer,
}, authMW)
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
r.GET("/health", h.APIHealth)
r.GET("/robots.txt", h.RobotsTxt)
r.GET("/sitemap.xml", h.SitemapXML)
// OIDC Provider(外部机器 / Gitea SSO)
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
r.GET("/oauth/jwks", h.OIDCJWKS)
r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize)
r.POST("/oauth/token", h.OIDCToken)
r.GET("/oauth/userinfo", h.OIDCUserInfo)
r.POST("/oauth/userinfo", h.OIDCUserInfo)
r.GET("/oauth/logout", h.OIDCLogout)
r.POST("/oauth/logout", h.OIDCLogout)
// 机器入口:健康检查 / SEO / OIDC / 缩略图;论坛 UI 仅走 routers/web
r.NoRoute(webpages.Deps{
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
Settings: settingsSvc, Auth: authSvc,
Board: boardSvc, Post: postSvc, Comment: commentSvc,
AssetVersion: assetVer,
}.NotFound)
return r, nil
}

843
routers/web/admin.go Normal file
View File

@@ -0,0 +1,843 @@
package web
import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// AdminChrome 后台布局公共字段
type AdminChrome struct {
PageChrome
NavActive string
}
func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome {
site := d.Settings.SiteBranding().Name
if title == "" {
title = "管理后台 · " + site
} else {
title = title + " · " + site
}
base := d.chrome(ctx, title, "", "")
return AdminChrome{PageChrome: base, NavActive: nav}
}
type adminDashData struct {
AdminChrome
UserCount int64
PostCount int64
PendingPosts int64
PendingComments int64
PendingReports int64
BoardCount int64
}
// AdminDashboard 概览
func (d Deps) AdminDashboard(c *gin.Context) {
ctx := d.ctx(c)
var users, posts, boards int64
_ = models.DB.Model(&models.User{}).Count(&users).Error
_ = models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&posts).Error
_ = models.DB.Model(&models.Board{}).Count(&boards).Error
pendingPosts, _ := d.Post.PendingPostCount()
pendingComments, _ := d.Comment.PendingCommentCount()
var pendingReports int64
if d.Report != nil {
pendingReports, _ = d.Report.PendingCount()
}
ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{
AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"),
UserCount: users,
PostCount: posts,
PendingPosts: pendingPosts,
PendingComments: pendingComments,
PendingReports: pendingReports,
BoardCount: boards,
})
}
type adminBoardRow struct {
ID uint
Name string
Description string
Icon string
ColorIndex int
SortOrder int
PostCount int
}
type adminBoardsData struct {
AdminChrome
Boards []adminBoardRow
Form adminBoardForm
}
type adminBoardForm struct {
ID uint
Name string
Description string
Icon string
ColorIndex int
SortOrder int
}
// AdminBoardsGet 板块列表
func (d Deps) AdminBoardsGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminBoards(ctx, "", adminBoardForm{})
}
func (d Deps) renderAdminBoards(ctx *webctx.Context, errMsg string, form adminBoardForm) {
list, _ := d.Board.ListWithStats()
rows := make([]adminBoardRow, 0, len(list))
for _, b := range list {
rows = append(rows, adminBoardRow{
ID: b.ID, Name: b.Name, Description: b.Description,
Icon: b.Icon, ColorIndex: b.ColorIndex, SortOrder: b.SortOrder,
PostCount: b.PostCount,
})
}
data := adminBoardsData{
AdminChrome: d.adminChrome(ctx, "板块", "boards"),
Boards: rows,
Form: form,
}
data.Error = errMsg
ctx.HTML(http.StatusOK, "admin/boards", data)
}
// AdminBoardCreate 新建板块
func (d Deps) AdminBoardCreate(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
return
}
form := adminBoardFormFrom(c)
if strings.TrimSpace(form.Name) == "" {
d.renderAdminBoards(ctx, "请填写板块名称", form)
return
}
if _, err := d.Board.Create(form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
d.renderAdminBoards(ctx, err.Error(), form)
return
}
ctx.SetFlash("板块已创建")
ctx.Redirect("/admin/boards")
}
// AdminBoardUpdate 更新板块
func (d Deps) AdminBoardUpdate(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
form := adminBoardFormFrom(c)
form.ID = uint(id)
if strings.TrimSpace(form.Name) == "" {
d.renderAdminBoards(ctx, "请填写板块名称", form)
return
}
if err := d.Board.Update(uint(id), form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
d.renderAdminBoards(ctx, err.Error(), form)
return
}
ctx.SetFlash("板块已更新")
ctx.Redirect("/admin/boards")
}
// AdminBoardDelete 删除板块
func (d Deps) AdminBoardDelete(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/boards")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Board.Delete(uint(id)); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/boards")
return
}
ctx.SetFlash("板块已删除")
ctx.Redirect("/admin/boards")
}
func adminBoardFormFrom(c *gin.Context) adminBoardForm {
color, _ := strconv.Atoi(c.PostForm("color_index"))
sort, _ := strconv.Atoi(c.PostForm("sort_order"))
return adminBoardForm{
Name: strings.TrimSpace(c.PostForm("name")),
Description: strings.TrimSpace(c.PostForm("description")),
Icon: strings.TrimSpace(c.PostForm("icon")),
ColorIndex: color,
SortOrder: sort,
}
}
type adminModPostRow struct {
ID uint
Title string
AuthorName string
BoardName string
CreatedAt string
}
type adminModCommentRow struct {
ID uint
PostID uint
PostTitle string
Floor int
AuthorName string
Excerpt string
CreatedAt string
}
type adminModData struct {
AdminChrome
Posts []adminModPostRow
Comments []adminModCommentRow
}
// AdminModerationGet 待审帖/评
func (d Deps) AdminModerationGet(c *gin.Context) {
ctx := d.ctx(c)
posts, _, _ := d.Post.List(services.PostListQuery{
Page: 1, Size: 50,
ViewerIsAdmin: true,
Status: models.ContentStatusPending,
Sort: "latest",
})
postRows := make([]adminModPostRow, 0, len(posts))
for _, p := range posts {
author := ""
if p.User.ID > 0 {
author = p.User.Nickname
if author == "" {
author = p.User.Username
}
}
board := ""
if p.Board.ID > 0 {
board = p.Board.Name
}
postRows = append(postRows, adminModPostRow{
ID: p.ID, Title: p.Title, AuthorName: author, BoardName: board,
CreatedAt: p.CreatedAt.Format("2006-01-02 15:04"),
})
}
comments, _, _ := d.Comment.ListPending(1, 50)
commentRows := make([]adminModCommentRow, 0, len(comments))
for _, cm := range comments {
author := "游客"
if cm.UserID > 0 {
author = cm.User.Nickname
if author == "" {
author = cm.User.Username
}
} else if cm.GuestNick != "" {
author = cm.GuestNick
}
excerpt := strings.TrimSpace(stripTagsRough(cm.Content))
runes := []rune(excerpt)
if len(runes) > 80 {
excerpt = string(runes[:80]) + "…"
}
title := ""
if cm.Post.ID > 0 {
title = cm.Post.Title
}
commentRows = append(commentRows, adminModCommentRow{
ID: cm.ID, PostID: cm.PostID, PostTitle: title, Floor: cm.Floor,
AuthorName: author, Excerpt: excerpt,
CreatedAt: cm.CreatedAt.Format("2006-01-02 15:04"),
})
}
ctx.HTML(http.StatusOK, "admin/moderation", adminModData{
AdminChrome: d.adminChrome(ctx, "内容审核", "moderation"),
Posts: postRows,
Comments: commentRows,
})
}
func stripTagsRough(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return b.String()
}
// AdminPostApprove 通过帖子
func (d Deps) AdminPostApprove(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/moderation")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
ctx.SetFlash(err.Error())
} else {
ctx.SetFlash("帖子已通过")
}
ctx.Redirect("/admin/moderation")
}
// AdminPostReject 拒绝帖子
func (d Deps) AdminPostReject(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/moderation")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
reason := strings.TrimSpace(c.PostForm("reason"))
if reason == "" {
ctx.SetFlash("请填写拒绝原因")
ctx.Redirect("/admin/moderation")
return
}
post, err := d.Post.FindByID(uint(id))
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/moderation")
return
}
if err := d.Post.SetStatus(post.ID, models.ContentStatusRejected); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/moderation")
return
}
pid := post.ID
if d.Message != nil {
_, _ = d.Message.SendSystem(
post.UserID,
"帖子《"+post.Title+"》未通过审核",
services.FormatRejectContent(post.Title, post.ID, reason),
models.MessageKindReject,
&pid,
nil,
)
}
ctx.SetFlash("已拒绝该帖")
ctx.Redirect("/admin/moderation")
}
// AdminCommentApprove 通过评论
func (d Deps) AdminCommentApprove(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/moderation")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
ctx.SetFlash(err.Error())
} else {
if d.Notify != nil {
if comment, err := d.Comment.GetByID(uint(id)); err == nil {
comment.Status = models.ContentStatusPublished
d.Notify.AsyncNotifyCommentPublished(comment)
d.Notify.AsyncNotifyCommentMentions(comment)
}
}
ctx.SetFlash("评论已通过")
}
ctx.Redirect("/admin/moderation")
}
// AdminCommentReject 拒绝评论
func (d Deps) AdminCommentReject(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/moderation")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
reason := strings.TrimSpace(c.PostForm("reason"))
if reason == "" {
ctx.SetFlash("请填写拒绝原因")
ctx.Redirect("/admin/moderation")
return
}
cm, err := d.Comment.GetByID(uint(id))
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/moderation")
return
}
if err := d.Comment.SetStatus(cm.ID, models.ContentStatusRejected); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/moderation")
return
}
if d.Message != nil && cm.UserID > 0 {
pid := cm.PostID
title := cm.Post.Title
_, _ = d.Message.SendSystem(
cm.UserID,
fmt.Sprintf("评论未通过审核(帖 #%d)", cm.PostID),
services.FormatCommentRejectContent(title, cm.PostID, cm.Floor, reason),
models.MessageKindReject,
&pid,
nil,
)
}
ctx.SetFlash("已拒绝该评论")
ctx.Redirect("/admin/moderation")
}
type adminAsideWidgetRow struct {
ID string
Label string
Enabled bool
Index int
CanMoveUp bool
CanMoveDown bool
}
type adminSettingsData struct {
AdminChrome
Brand services.SiteBranding
RatePost int
RateComment int
RateReg int
RateLogin int
RateWindow int
Limits services.ForumLimits
FilterWords string
FilterCount int
Mail services.MailConfig
MailReady bool
TestTo string
AsideWidgets []adminAsideWidgetRow
CanBackup bool
}
// AdminSettingsGet 设置页
func (d Deps) AdminSettingsGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminSettings(ctx, "")
}
func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
words := d.Settings.FilterWordsContent()
lim := d.Settings.Limits()
mail := d.Settings.MailConfigPublic()
widgets := d.Settings.AsideWidgets()
asideRows := make([]adminAsideWidgetRow, 0, len(widgets))
last := len(widgets) - 1
for i, w := range widgets {
asideRows = append(asideRows, adminAsideWidgetRow{
ID: w.ID, Label: asideWidgetLabel(w.ID), Enabled: w.Enabled, Index: i,
CanMoveUp: i > 0, CanMoveDown: i < last,
})
}
data := adminSettingsData{
AdminChrome: d.adminChrome(ctx, "站点设置", "settings"),
Brand: d.Settings.SiteBranding(),
RatePost: lim.RateLimitPost,
RateComment: lim.RateLimitComment,
RateReg: lim.RateLimitRegister,
RateLogin: lim.RateLimitLogin,
RateWindow: lim.RateLimitWindowSec,
Limits: lim,
FilterWords: words,
FilterCount: services.CountFilterWords(words),
Mail: mail,
MailReady: d.Settings.MailReady(),
AsideWidgets: asideRows,
CanBackup: models.DialectorName() == "sqlite",
}
data.Error = errMsg
ctx.HTML(http.StatusOK, "admin/settings", data)
}
func asideWidgetLabel(id string) string {
switch id {
case services.AsideWidgetTagCloud:
return "标签云"
case services.AsideWidgetRecentComments:
return "最新评论"
case services.AsideWidgetRecentUsers:
return "最新用户"
case services.AsideWidgetFriendLinks:
return "友链"
default:
return id
}
}
// AdminSettingsBrandPost 品牌
func (d Deps) AdminSettingsBrandPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
cur := d.Settings.SiteBranding()
in := services.SiteBranding{
Name: strings.TrimSpace(c.PostForm("name")),
Slogan: strings.TrimSpace(c.PostForm("slogan")),
Description: strings.TrimSpace(c.PostForm("description")),
Keywords: strings.TrimSpace(c.PostForm("keywords")),
LogoMark: strings.TrimSpace(c.PostForm("logo_mark")),
Logo: cur.Logo,
Favicon: cur.Favicon,
OGImage: cur.OGImage,
ICPBeian: strings.TrimSpace(c.PostForm("icp_beian")),
ICPBeianURL: strings.TrimSpace(c.PostForm("icp_beian_url")),
FriendLinks: cur.FriendLinks,
}
if err := d.Settings.UpdateSiteBranding(in); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("品牌设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsBrandUploadPost 上传 Logo / Favicon / OG 图
func (d Deps) AdminSettingsBrandUploadPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
if d.Store == nil {
d.renderAdminSettings(ctx, "上传存储未就绪")
return
}
kind := strings.TrimSpace(c.PostForm("kind"))
if kind != "logo" && kind != "favicon" && kind != "og_image" {
d.renderAdminSettings(ctx, "请选择 Logo、Favicon 或 OG 图")
return
}
file, err := c.FormFile("file")
if err != nil {
d.renderAdminSettings(ctx, "请选择图片文件")
return
}
const maxBytes = 2 * 1024 * 1024
if file.Size > maxBytes {
d.renderAdminSettings(ctx, "图片不能超过 2MB")
return
}
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, kind)
if err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
prev := d.Settings.SiteBranding()
switch kind {
case "logo":
_ = d.Settings.SetSiteLogo(url)
d.Store.DeleteByURL(prev.Logo)
ctx.SetFlash("Logo 已上传")
case "favicon":
_ = d.Settings.SetSiteFavicon(url)
d.Store.DeleteByURL(prev.Favicon)
ctx.SetFlash("Favicon 已上传")
case "og_image":
_ = d.Settings.SetSiteOGImage(url)
d.Store.DeleteByURL(prev.OGImage)
ctx.SetFlash("默认 OG 图已上传")
}
ctx.Redirect("/admin/settings")
}
// AdminSettingsBrandClearPost 清除品牌图片
func (d Deps) AdminSettingsBrandClearPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
kind := strings.TrimSpace(c.PostForm("kind"))
brand := d.Settings.SiteBranding()
switch kind {
case "logo":
_ = d.Settings.SetSiteLogo("")
if d.Store != nil {
d.Store.DeleteByURL(brand.Logo)
}
ctx.SetFlash("已清除 Logo")
case "favicon":
_ = d.Settings.SetSiteFavicon("")
if d.Store != nil {
d.Store.DeleteByURL(brand.Favicon)
}
ctx.SetFlash("已清除 Favicon")
case "og_image":
_ = d.Settings.SetSiteOGImage("")
if d.Store != nil {
d.Store.DeleteByURL(brand.OGImage)
}
ctx.SetFlash("已清除 OG 图")
default:
d.renderAdminSettings(ctx, "无效的资源类型")
return
}
ctx.Redirect("/admin/settings")
}
// AdminSettingsLimitsPost 限流
func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
postN, _ := strconv.Atoi(c.PostForm("rate_limit_post"))
commentN, _ := strconv.Atoi(c.PostForm("rate_limit_comment"))
regN, _ := strconv.Atoi(c.PostForm("rate_limit_register"))
loginN, _ := strconv.Atoi(c.PostForm("rate_limit_login"))
windowN, _ := strconv.Atoi(c.PostForm("rate_limit_window_sec"))
if err := d.Settings.UpdateRateLimits(postN, commentN, regN, loginN, windowN); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("限流设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsContentLimitsPost 字数与编辑窗等
func (d Deps) AdminSettingsContentLimitsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
atoi := func(name string) int {
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
return n
}
err := d.Settings.UpdateContentLimits(
atoi("post_edit_window_hours"), atoi("comment_edit_window_minutes"),
atoi("post_title_max"), atoi("post_tags_max"), atoi("post_content_max"), atoi("comment_max"),
atoi("search_keyword_min"), atoi("search_keyword_max"), atoi("page_size_default"),
atoi("password_min_len"), atoi("avatar_max_mb"), atoi("signature_max"),
c.PostForm("open_posts_in_new_tab") == "1" || c.PostForm("open_posts_in_new_tab") == "on",
c.PostForm("open_content_links_in_new_tab") == "1" || c.PostForm("open_content_links_in_new_tab") == "on",
strings.TrimSpace(c.PostForm("feed_list_style")),
)
if err != nil {
d.renderAdminSettings(ctx, "内容限制无效,请检查数值范围")
return
}
ctx.SetFlash("内容限制已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsPermalinkPost 伪静态
func (d Deps) AdminSettingsPermalinkPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
enabled := c.PostForm("permalink_enabled") == "1" || c.PostForm("permalink_enabled") == "on"
ext := strings.TrimSpace(c.PostForm("permalink_ext"))
if err := d.Settings.UpdatePermalinkSettings(enabled, ext); err != nil {
d.renderAdminSettings(ctx, "伪静态后缀无效(1–16 位字母数字)")
return
}
ctx.SetFlash("伪静态设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsFilterWordsPost 敏感词
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
content := c.PostForm("filter_words")
if err := d.Settings.UpdateFilterWords(content, d.Filter); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05")))
ctx.Redirect("/admin/settings")
}
// AdminSettingsMailPost 保存 SMTP
func (d Deps) AdminSettingsMailPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
port, _ := strconv.Atoi(c.PostForm("port"))
cfg := services.MailConfig{
Enabled: c.PostForm("enabled") == "1" || c.PostForm("enabled") == "on",
Host: strings.TrimSpace(c.PostForm("host")),
Port: port,
Username: strings.TrimSpace(c.PostForm("username")),
Password: c.PostForm("password"),
From: strings.TrimSpace(c.PostForm("from")),
FromName: strings.TrimSpace(c.PostForm("from_name")),
Encryption: strings.TrimSpace(c.PostForm("encryption")),
}
if err := d.Settings.UpdateMailConfig(cfg); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("邮件设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsMailTestPost 发送测试信
func (d Deps) AdminSettingsMailTestPost(c *gin.Context) {
ctx := d.ctx(c)
to := strings.TrimSpace(c.PostForm("test_to"))
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
if err := services.ValidateEmail(to); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
if !d.Settings.MailReady() {
d.renderAdminSettings(ctx, services.ErrMailNotConfigured.Error())
return
}
if d.Mail == nil {
d.renderAdminSettings(ctx, "邮件服务未就绪")
return
}
siteName := d.Settings.SiteBranding().Name
err := d.Mail.Send(services.NormalizeEmail(to), "邮件配置测试",
fmt.Sprintf("这是一封来自%s的测试邮件,说明 SMTP 配置正常。", siteName))
if err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("测试邮件已发送至 " + to)
ctx.Redirect("/admin/settings")
}
// AdminSettingsAsideWidgetsPost 侧栏组件开关与排序
func (d Deps) AdminSettingsAsideWidgetsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
order := c.PostFormArray("order")
enabledSet := make(map[string]bool)
for _, id := range c.PostFormArray("enabled") {
id = strings.TrimSpace(id)
if id != "" {
enabledSet[id] = true
}
}
widgets := make([]services.AsideWidget, 0, len(order))
for _, id := range order {
id = strings.TrimSpace(id)
if id == "" {
continue
}
widgets = append(widgets, services.AsideWidget{ID: id, Enabled: enabledSet[id]})
}
widgets = services.NormalizeAsideWidgets(widgets)
if move := strings.TrimSpace(c.PostForm("move")); move != "" {
widgets = moveAsideWidget(widgets, move)
}
if err := d.Settings.UpdateAsideWidgets(widgets); err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
ctx.SetFlash("侧栏组件已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsBackupPost 一键导出 SQLite 备份并跳转下载
func (d Deps) AdminSettingsBackupPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
if d.Backup == nil {
d.renderAdminSettings(ctx, "备份服务未就绪")
return
}
path, err := d.Backup.ExportSQLite()
if err != nil {
d.renderAdminSettings(ctx, err.Error())
return
}
name := filepath.Base(path)
ctx.Redirect("/admin/settings/backup/download/" + name)
}
// AdminSettingsBackupDownload 下载 data 目录下的备份文件
func (d Deps) AdminSettingsBackupDownload(c *gin.Context) {
name := c.Param("name")
if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") {
c.String(http.StatusBadRequest, "无效的备份文件名")
return
}
path := filepath.Join(d.DataDir, name)
c.FileAttachment(path, name)
}
// moveAsideWidget 解析 move 值「up:id」或「down:id」并交换相邻项
func moveAsideWidget(widgets []services.AsideWidget, move string) []services.AsideWidget {
parts := strings.SplitN(move, ":", 2)
if len(parts) != 2 {
return widgets
}
dir, id := parts[0], strings.TrimSpace(parts[1])
idx := -1
for i, w := range widgets {
if w.ID == id {
idx = i
break
}
}
if idx < 0 {
return widgets
}
switch dir {
case "up":
if idx == 0 {
return widgets
}
widgets[idx-1], widgets[idx] = widgets[idx], widgets[idx-1]
case "down":
if idx >= len(widgets)-1 {
return widgets
}
widgets[idx], widgets[idx+1] = widgets[idx+1], widgets[idx]
}
return widgets
}

319
routers/web/admin_badges.go Normal file
View File

@@ -0,0 +1,319 @@
package web
import (
"errors"
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"github.com/gin-gonic/gin"
)
type adminBadgeRow struct {
ID uint
Code string
Name string
Description string
Icon string
Kind string
KindLabel string
Metric string
Threshold int
SortOrder int
Enabled bool
IsLimited bool
}
type adminBadgeForm struct {
ID uint
Code string
Name string
Description string
Icon string
Kind string
Metric string
Threshold int
SortOrder int
Enabled bool
IsEdit bool
}
type adminBadgesData struct {
AdminChrome
Badges []adminBadgeRow
LimitedBadges []adminBadgeRow
Form adminBadgeForm
AwardUser string
AwardBadgeID uint
}
// AdminBadgesGet 徽章定义列表
func (d Deps) AdminBadgesGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminBadges(ctx, "", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
}
func (d Deps) renderAdminBadges(ctx *webctx.Context, errMsg string, form adminBadgeForm, awardUser string, awardBadgeID uint) {
chrome := d.adminChrome(ctx, "徽章", "badges")
chrome.Error = errMsg
if form.Kind == "" {
form.Kind = models.BadgeKindLimited
}
data := adminBadgesData{
AdminChrome: chrome,
Form: form,
AwardUser: awardUser,
AwardBadgeID: awardBadgeID,
}
if d.Badge != nil {
list, err := d.Badge.ListDefs(true)
if err != nil {
chrome.Error = err.Error()
data.AdminChrome = chrome
} else {
data.Badges = make([]adminBadgeRow, 0, len(list))
data.LimitedBadges = make([]adminBadgeRow, 0)
for _, b := range list {
row := adminBadgeRowFrom(b)
data.Badges = append(data.Badges, row)
if row.IsLimited && row.Enabled {
data.LimitedBadges = append(data.LimitedBadges, row)
}
}
}
}
ctx.HTML(http.StatusOK, "admin/badges", data)
}
func adminBadgeRowFrom(b models.BadgeDef) adminBadgeRow {
label := "自动"
if b.Kind == models.BadgeKindLimited {
label = "限定"
}
return adminBadgeRow{
ID: b.ID, Code: b.Code, Name: b.Name, Description: b.Description, Icon: b.Icon,
Kind: b.Kind, KindLabel: label, Metric: b.Metric, Threshold: b.Threshold,
SortOrder: b.SortOrder, Enabled: b.Enabled, IsLimited: b.Kind == models.BadgeKindLimited,
}
}
func adminBadgeFormFrom(c *gin.Context) adminBadgeForm {
th, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("threshold")))
so, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("sort_order")))
kind := strings.TrimSpace(c.PostForm("kind"))
if kind == "" {
kind = models.BadgeKindLimited
}
return adminBadgeForm{
Code: strings.TrimSpace(c.PostForm("code")),
Name: strings.TrimSpace(c.PostForm("name")),
Description: strings.TrimSpace(c.PostForm("description")),
Icon: strings.TrimSpace(c.PostForm("icon")),
Kind: kind,
Metric: strings.TrimSpace(c.PostForm("metric")),
Threshold: th,
SortOrder: so,
Enabled: c.PostForm("enabled") == "1" || c.PostForm("enabled") == "on",
}
}
func (f adminBadgeForm) toDef() models.BadgeDef {
return models.BadgeDef{
Code: f.Code, Name: f.Name, Description: f.Description, Icon: f.Icon,
Kind: f.Kind, Metric: f.Metric, Threshold: f.Threshold,
SortOrder: f.SortOrder, Enabled: f.Enabled,
}
}
// AdminBadgeCreate 新建徽章定义
func (d Deps) AdminBadgeCreate(c *gin.Context) {
ctx := d.ctx(c)
form := adminBadgeFormFrom(c)
if !ctx.CheckCSRF() {
d.renderAdminBadges(ctx, "无效请求,请重试", form, "", 0)
return
}
if d.Badge == nil {
d.renderAdminBadges(ctx, "徽章服务未就绪", form, "", 0)
return
}
def := form.toDef()
if err := d.Badge.UpsertDef(&def); err != nil {
d.renderAdminBadges(ctx, err.Error(), form, "", 0)
return
}
ctx.SetFlash("徽章已保存")
ctx.Redirect("/admin/badges")
}
// AdminBadgeEditGet 编辑表单
func (d Deps) AdminBadgeEditGet(c *gin.Context) {
ctx := d.ctx(c)
if d.Badge == nil {
d.renderAdminBadges(ctx, "徽章服务未就绪", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
list, err := d.Badge.ListDefs(true)
if err != nil {
d.renderAdminBadges(ctx, err.Error(), adminBadgeForm{}, "", 0)
return
}
for _, b := range list {
if b.ID == uint(id64) {
d.renderAdminBadges(ctx, "", adminBadgeForm{
ID: b.ID, Code: b.Code, Name: b.Name, Description: b.Description, Icon: b.Icon,
Kind: b.Kind, Metric: b.Metric, Threshold: b.Threshold, SortOrder: b.SortOrder,
Enabled: b.Enabled, IsEdit: true,
}, "", 0)
return
}
}
ctx.SetFlash("徽章不存在")
ctx.Redirect("/admin/badges")
}
// AdminBadgeUpdate 更新徽章定义
func (d Deps) AdminBadgeUpdate(c *gin.Context) {
ctx := d.ctx(c)
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
form := adminBadgeFormFrom(c)
form.ID = uint(id64)
form.IsEdit = true
if !ctx.CheckCSRF() {
d.renderAdminBadges(ctx, "无效请求,请重试", form, "", 0)
return
}
if d.Badge == nil {
d.renderAdminBadges(ctx, "徽章服务未就绪", form, "", 0)
return
}
// 编辑时 code 不可改:从已有定义取 code,避免误覆盖其它徽章
list, err := d.Badge.ListDefs(true)
if err != nil {
d.renderAdminBadges(ctx, err.Error(), form, "", 0)
return
}
var found *models.BadgeDef
for i := range list {
if list[i].ID == uint(id64) {
found = &list[i]
break
}
}
if found == nil {
d.renderAdminBadges(ctx, "徽章不存在", form, "", 0)
return
}
form.Code = found.Code
def := form.toDef()
if err := d.Badge.UpsertDef(&def); err != nil {
d.renderAdminBadges(ctx, err.Error(), form, "", 0)
return
}
ctx.SetFlash("徽章已更新")
ctx.Redirect("/admin/badges")
}
// AdminBadgeDelete 删除徽章定义
func (d Deps) AdminBadgeDelete(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminBadges(ctx, "无效请求,请重试", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
return
}
if d.Badge == nil {
d.renderAdminBadges(ctx, "徽章服务未就绪", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Badge.DeleteDef(uint(id64)); err != nil {
d.renderAdminBadges(ctx, err.Error(), adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0)
return
}
ctx.SetFlash("徽章已删除")
ctx.Redirect("/admin/badges")
}
func (d Deps) resolveAwardUser(raw string) (uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, errors.New("请填写用户名或用户 ID")
}
if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 {
u, e := d.User.GetByID(uint(id))
if e != nil || u == nil {
return 0, errors.New("用户不存在")
}
return u.ID, nil
}
u, err := d.User.GetByUsername(raw)
if err != nil || u == nil {
return 0, errors.New("用户不存在")
}
return u.ID, nil
}
// AdminBadgeAwardPost 颁发限定徽章
func (d Deps) AdminBadgeAwardPost(c *gin.Context) {
ctx := d.ctx(c)
userRaw := strings.TrimSpace(c.PostForm("user"))
badgeID, _ := strconv.ParseUint(strings.TrimSpace(c.PostForm("badge_id")), 10, 64)
form := adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}
if !ctx.CheckCSRF() {
d.renderAdminBadges(ctx, "无效请求,请重试", form, userRaw, uint(badgeID))
return
}
if d.Badge == nil || d.User == nil {
d.renderAdminBadges(ctx, "服务未就绪", form, userRaw, uint(badgeID))
return
}
uid, err := d.resolveAwardUser(userRaw)
if err != nil {
d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID))
return
}
if badgeID == 0 {
d.renderAdminBadges(ctx, "请选择徽章", form, userRaw, 0)
return
}
if err := d.Badge.AwardLimited(uid, uint(badgeID), ctx.UserID()); err != nil {
d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID))
return
}
ctx.SetFlash("已颁发徽章")
ctx.Redirect("/admin/badges")
}
// AdminBadgeRevokePost 收回徽章
func (d Deps) AdminBadgeRevokePost(c *gin.Context) {
ctx := d.ctx(c)
userRaw := strings.TrimSpace(c.PostForm("user"))
badgeID, _ := strconv.ParseUint(strings.TrimSpace(c.PostForm("badge_id")), 10, 64)
form := adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}
if !ctx.CheckCSRF() {
d.renderAdminBadges(ctx, "无效请求,请重试", form, userRaw, uint(badgeID))
return
}
if d.Badge == nil || d.User == nil {
d.renderAdminBadges(ctx, "服务未就绪", form, userRaw, uint(badgeID))
return
}
uid, err := d.resolveAwardUser(userRaw)
if err != nil {
d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID))
return
}
if badgeID == 0 {
d.renderAdminBadges(ctx, "请选择徽章", form, userRaw, 0)
return
}
if err := d.Badge.Revoke(uid, uint(badgeID)); err != nil {
d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID))
return
}
ctx.SetFlash("已收回徽章")
ctx.Redirect("/admin/badges")
}

View File

@@ -0,0 +1,234 @@
package web
import (
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type adminFriendLinkBrandRow struct {
Name string
URL string
Logo string
}
type adminFriendLinkApplyRow struct {
ID uint
UserID uint
Username string
Name string
URL string
Logo string
Status string
StatusLabel string
OnHome bool
Reciprocal string
RecipOK bool
RecipNote string
CreatedAt string
CanReview bool
CanRecheck bool
}
type adminFriendLinksData struct {
AdminChrome
BrandLinks []adminFriendLinkBrandRow
Applies []adminFriendLinkApplyRow
PendingCount int64
NavShow bool
FooterShow bool
AsideShow bool
ReciprocalCheck bool
BrandName string
BrandURL string
BrandLogo string
}
// AdminFriendLinksGet 友链管理
func (d Deps) AdminFriendLinksGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminFriendLinks(ctx, "")
}
func (d Deps) renderAdminFriendLinks(ctx *webctx.Context, errMsg string) {
chrome := d.adminChrome(ctx, "友链", "friend-links")
chrome.Error = errMsg
data := adminFriendLinksData{
AdminChrome: chrome,
NavShow: d.Settings.NavShowFriendLinks(),
FooterShow: d.Settings.FooterShowFriendLinks(),
AsideShow: d.Settings.AsideShowFriendLinks(),
ReciprocalCheck: d.Settings.FriendLinkReciprocalCheckEnabled(),
}
brand := d.Settings.SiteBranding()
for _, l := range brand.FriendLinks {
data.BrandLinks = append(data.BrandLinks, adminFriendLinkBrandRow{Name: l.Name, URL: l.URL, Logo: l.Logo})
}
if d.FriendLink != nil {
data.PendingCount, _ = d.FriendLink.PendingCount()
rows, _, _ := d.FriendLink.ListAdmin(services.FriendLinkApplyListQuery{Page: 1, Size: 50, Status: "all"})
data.Applies = make([]adminFriendLinkApplyRow, 0, len(rows))
for _, a := range rows {
uname := ""
if a.User.ID > 0 {
uname = a.User.Username
}
data.Applies = append(data.Applies, adminFriendLinkApplyRow{
ID: a.ID,
UserID: a.UserID,
Username: uname,
Name: a.Name,
URL: a.URL,
Logo: a.Logo,
Status: a.Status,
StatusLabel: friendLinkApplyStatusLabel(a.Status),
OnHome: a.LinkOnHomepage,
Reciprocal: a.ReciprocalPageURL,
RecipOK: a.ReciprocalVerified,
RecipNote: a.ReciprocalCheckNote,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"),
CanReview: a.Status == models.FriendLinkApplyStatusPending,
CanRecheck: data.ReciprocalCheck && strings.TrimSpace(a.ReciprocalPageURL) != "",
})
}
}
ctx.HTML(http.StatusOK, "admin/friend_links", data)
}
// AdminFriendLinksSettingsPost nav/footer/回链开关
func (d Deps) AdminFriendLinksSettingsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
return
}
_ = d.Settings.SetNavShowFriendLinks(c.PostForm("nav_show") == "1" || c.PostForm("nav_show") == "on")
_ = d.Settings.SetFooterShowFriendLinks(c.PostForm("footer_show") == "1" || c.PostForm("footer_show") == "on")
_ = d.Settings.SetAsideFriendLinksEnabled(c.PostForm("aside_show") == "1" || c.PostForm("aside_show") == "on")
_ = d.Settings.SetFriendLinkReciprocalCheckEnabled(c.PostForm("reciprocal_check") == "1" || c.PostForm("reciprocal_check") == "on")
ctx.SetFlash("友链入口设置已保存")
ctx.Redirect("/admin/friend-links")
}
// AdminFriendLinksBrandAddPost 添加品牌友链
func (d Deps) AdminFriendLinksBrandAddPost(c *gin.Context) {
ctx := d.ctx(c)
name := strings.TrimSpace(c.PostForm("name"))
url := strings.TrimSpace(c.PostForm("url"))
logo := strings.TrimSpace(c.PostForm("logo"))
if !ctx.CheckCSRF() {
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
return
}
brand := d.Settings.SiteBranding()
next := append([]services.FriendLink{}, brand.FriendLinks...)
next = append(next, services.FriendLink{Name: name, URL: url, Logo: logo})
if err := d.Settings.UpdateSiteBranding(services.SiteBranding{
Name: brand.Name, Slogan: brand.Slogan, Description: brand.Description,
Keywords: brand.Keywords, LogoMark: brand.LogoMark, Logo: brand.Logo,
Favicon: brand.Favicon, OGImage: brand.OGImage,
ICPBeian: brand.ICPBeian, ICPBeianURL: brand.ICPBeianURL,
FriendLinks: next,
}); err != nil {
d.renderAdminFriendLinks(ctx, err.Error())
return
}
ctx.SetFlash("已添加品牌友链")
ctx.Redirect("/admin/friend-links")
}
// AdminFriendLinksBrandDeletePost 删除品牌友链(按 URL)
func (d Deps) AdminFriendLinksBrandDeletePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
return
}
target := strings.TrimSpace(c.PostForm("url"))
brand := d.Settings.SiteBranding()
next := make([]services.FriendLink, 0, len(brand.FriendLinks))
for _, l := range brand.FriendLinks {
if l.URL == target {
continue
}
next = append(next, l)
}
if err := d.Settings.UpdateSiteBranding(services.SiteBranding{
Name: brand.Name, Slogan: brand.Slogan, Description: brand.Description,
Keywords: brand.Keywords, LogoMark: brand.LogoMark, Logo: brand.Logo,
Favicon: brand.Favicon, OGImage: brand.OGImage,
ICPBeian: brand.ICPBeian, ICPBeianURL: brand.ICPBeianURL,
FriendLinks: next,
}); err != nil {
d.renderAdminFriendLinks(ctx, err.Error())
return
}
ctx.SetFlash("已删除品牌友链")
ctx.Redirect("/admin/friend-links")
}
// AdminFriendLinkApprovePost 通过申请
func (d Deps) AdminFriendLinkApprovePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
return
}
if d.FriendLink == nil {
d.renderAdminFriendLinks(ctx, "友链服务未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if _, err := d.FriendLink.Approve(uint(id64)); err != nil {
d.renderAdminFriendLinks(ctx, err.Error())
return
}
ctx.SetFlash("已通过友链申请")
ctx.Redirect("/admin/friend-links")
}
// AdminFriendLinkRejectPost 拒绝申请
func (d Deps) AdminFriendLinkRejectPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
return
}
if d.FriendLink == nil {
d.renderAdminFriendLinks(ctx, "友链服务未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
note := strings.TrimSpace(c.PostForm("note"))
if _, err := d.FriendLink.Reject(uint(id64), note); err != nil {
d.renderAdminFriendLinks(ctx, err.Error())
return
}
ctx.SetFlash("已拒绝友链申请")
ctx.Redirect("/admin/friend-links")
}
// AdminFriendLinkRecheckPost 重新检测回链
func (d Deps) AdminFriendLinkRecheckPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminFriendLinks(ctx, "无效请求,请重试")
return
}
if d.FriendLink == nil {
d.renderAdminFriendLinks(ctx, "友链服务未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if _, err := d.FriendLink.RecheckReciprocal(uint(id64), d.publicBaseURL(c)); err != nil {
d.renderAdminFriendLinks(ctx, err.Error())
return
}
ctx.SetFlash("已开始重新检测回链")
ctx.Redirect("/admin/friend-links")
}

255
routers/web/admin_media.go Normal file
View File

@@ -0,0 +1,255 @@
package web
import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type adminMediaRow struct {
ID uint
Category string
CategoryLabel string
Name string
URL string
ThumbURL string
SizeLabel string
Modified string
StorageType string
}
type adminMediaData struct {
AdminChrome
Files []adminMediaRow
Keyword string
Category string
Page int
Total int
HasPrev bool
HasMore bool
PrevPage int
NextPage int
QuerySuffix string
StorageType string
CountAll int
CountAvatars int
CountPosts int
CountSite int
}
// AdminMediaGet 媒体库列表
func (d Deps) AdminMediaGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminMedia(ctx, "")
}
func (d Deps) renderAdminMedia(ctx *webctx.Context, errMsg string) {
chrome := d.adminChrome(ctx, "媒体", "media")
chrome.Error = errMsg
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
keyword := strings.TrimSpace(ctx.C.Query("q"))
category := strings.TrimSpace(ctx.C.DefaultQuery("category", "all"))
if category == "" {
category = "all"
}
size := 24
data := adminMediaData{
AdminChrome: chrome,
Keyword: keyword,
Category: category,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
Files: []adminMediaRow{},
}
q := url.Values{}
if keyword != "" {
q.Set("q", keyword)
}
if category != "" && category != "all" {
q.Set("category", category)
}
data.QuerySuffix = q.Encode()
if d.Store == nil {
chrome.Error = "上传存储未就绪"
data.AdminChrome = chrome
ctx.HTML(http.StatusOK, "admin/media", data)
return
}
result, err := d.Store.ListMedia(category, keyword, page, size)
if err != nil {
chrome.Error = err.Error()
data.AdminChrome = chrome
ctx.HTML(http.StatusOK, "admin/media", data)
return
}
data.Total = result.Total
data.Page = result.Page
data.HasPrev = result.Page > 1
data.HasMore = result.Page < result.TotalPages
data.PrevPage = result.Page - 1
data.NextPage = result.Page + 1
data.StorageType = result.StorageType
data.CountAvatars = result.CategoryCounts[services.UploadCategoryAvatars]
data.CountPosts = result.CategoryCounts[services.UploadCategoryPosts]
data.CountSite = result.CategoryCounts[services.UploadCategorySite]
data.CountAll = data.CountAvatars + data.CountPosts + data.CountSite
data.Files = make([]adminMediaRow, 0, len(result.Files))
for _, f := range result.Files {
thumb := services.ThumbURLFromUpload(f.URL)
if thumb == "" {
thumb = f.URL
}
data.Files = append(data.Files, adminMediaRow{
ID: f.ID, Category: f.Category, CategoryLabel: adminMediaCategoryLabel(f.Category),
Name: f.Name, URL: f.URL, ThumbURL: thumb,
SizeLabel: formatByteSize(f.Size), Modified: f.ModifiedAt.Local().Format("2006-01-02 15:04"),
StorageType: f.StorageType,
})
}
ctx.HTML(http.StatusOK, "admin/media", data)
}
func adminMediaCategoryLabel(cat string) string {
switch cat {
case services.UploadCategoryAvatars:
return "头像"
case services.UploadCategoryPosts:
return "帖图"
case services.UploadCategorySite:
return "站点"
default:
return cat
}
}
func formatByteSize(n int64) string {
if n < 1024 {
return fmt.Sprintf("%d B", n)
}
if n < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(n)/1024)
}
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
func (d Deps) mediaRedirectQuery(c *gin.Context) string {
q := url.Values{}
if v := strings.TrimSpace(c.Query("q")); v != "" {
q.Set("q", v)
}
if v := strings.TrimSpace(c.Query("category")); v != "" && v != "all" {
q.Set("category", v)
}
if v := strings.TrimSpace(c.Query("page")); v != "" && v != "1" {
q.Set("page", v)
}
// POST 表单也可带回筛选
if v := strings.TrimSpace(c.PostForm("q")); v != "" {
q.Set("q", v)
}
if v := strings.TrimSpace(c.PostForm("category")); v != "" && v != "all" {
q.Set("category", v)
}
if v := strings.TrimSpace(c.PostForm("page")); v != "" && v != "1" {
q.Set("page", v)
}
s := q.Encode()
if s == "" {
return "/admin/media"
}
return "/admin/media?" + s
}
// AdminMediaDeletePost 按 ID 删除单条
func (d Deps) AdminMediaDeletePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminMedia(ctx, "无效请求,请重试")
return
}
if d.Store == nil {
d.renderAdminMedia(ctx, "上传存储未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
n, err := d.Store.DeleteMediaByIDs([]uint{uint(id64)})
if err != nil {
d.renderAdminMedia(ctx, err.Error())
return
}
if n == 0 {
d.renderAdminMedia(ctx, "未找到可删除的媒体")
return
}
ctx.SetFlash("已删除 1 项媒体")
ctx.Redirect(d.mediaRedirectQuery(c))
}
// AdminMediaBatchDeletePost 批量删除
func (d Deps) AdminMediaBatchDeletePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminMedia(ctx, "无效请求,请重试")
return
}
if d.Store == nil {
d.renderAdminMedia(ctx, "上传存储未就绪")
return
}
raw := c.PostFormArray("ids")
ids := make([]uint, 0, len(raw))
for _, s := range raw {
id64, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
if err != nil || id64 == 0 {
continue
}
ids = append(ids, uint(id64))
}
if len(ids) == 0 {
d.renderAdminMedia(ctx, "请选择要删除的文件")
return
}
if len(ids) > 100 {
d.renderAdminMedia(ctx, "单次最多删除 100 个文件")
return
}
n, err := d.Store.DeleteMediaByIDs(ids)
if err != nil {
d.renderAdminMedia(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("已删除 %d 项媒体", n))
ctx.Redirect(d.mediaRedirectQuery(c))
}
// AdminMediaSyncPost 扫盘回填索引
func (d Deps) AdminMediaSyncPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminMedia(ctx, "无效请求,请重试")
return
}
if d.Store == nil {
d.renderAdminMedia(ctx, "上传存储未就绪")
return
}
n, err := d.Store.SyncMediaIndex()
if err != nil {
d.renderAdminMedia(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("索引已同步(处理 %d 条)", n))
ctx.Redirect("/admin/media")
}

198
routers/web/admin_pages.go Normal file
View File

@@ -0,0 +1,198 @@
package web
import (
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type adminPageRow struct {
ID uint
Title string
Slug string
Published bool
SortOrder int
ShowInNav bool
ShowInFooter bool
}
type adminPageForm struct {
ID uint
Title string
Slug string
Content string
Published bool
SortOrder int
ShowInNav bool
ShowInFooter bool
IsEdit bool
}
type adminPagesData struct {
AdminChrome
Pages []adminPageRow
Form adminPageForm
}
// AdminPagesGet 站点单页列表
func (d Deps) AdminPagesGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminPages(ctx, "", adminPageForm{})
}
func (d Deps) renderAdminPages(ctx *webctx.Context, errMsg string, form adminPageForm) {
chrome := d.adminChrome(ctx, "站点单页", "pages")
chrome.Error = errMsg
data := adminPagesData{AdminChrome: chrome, Form: form}
if d.SitePage != nil {
list, _ := d.SitePage.ListAll()
data.Pages = make([]adminPageRow, 0, len(list))
for _, p := range list {
data.Pages = append(data.Pages, adminPageRow{
ID: p.ID, Title: p.Title, Slug: p.Slug, Published: p.Published,
SortOrder: p.SortOrder, ShowInNav: p.ShowInNav, ShowInFooter: p.ShowInFooter,
})
}
}
ctx.HTML(http.StatusOK, "admin/pages", data)
}
// AdminPageCreate 新建单页
func (d Deps) AdminPageCreate(c *gin.Context) {
ctx := d.ctx(c)
form := adminPageFormFrom(c)
if !ctx.CheckCSRF() {
d.renderAdminPages(ctx, "无效请求,请重试", form)
return
}
if d.SitePage == nil {
d.renderAdminPages(ctx, "单页服务未就绪", form)
return
}
if _, err := d.SitePage.Create(adminPageInputFrom(form)); err != nil {
d.renderAdminPages(ctx, err.Error(), form)
return
}
ctx.SetFlash("单页已创建")
ctx.Redirect("/admin/pages")
}
// AdminPageEditGet 编辑表单
func (d Deps) AdminPageEditGet(c *gin.Context) {
ctx := d.ctx(c)
if d.SitePage == nil {
d.renderAdminPages(ctx, "单页服务未就绪", adminPageForm{})
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
page, err := d.SitePage.GetByID(uint(id))
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/pages")
return
}
d.renderAdminPages(ctx, "", adminPageForm{
ID: page.ID, Title: page.Title, Slug: page.Slug, Content: page.Content,
Published: page.Published, SortOrder: page.SortOrder,
ShowInNav: page.ShowInNav, ShowInFooter: page.ShowInFooter, IsEdit: true,
})
}
// AdminPageUpdate 更新单页
func (d Deps) AdminPageUpdate(c *gin.Context) {
ctx := d.ctx(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
form := adminPageFormFrom(c)
form.ID = uint(id)
form.IsEdit = true
if !ctx.CheckCSRF() {
d.renderAdminPages(ctx, "无效请求,请重试", form)
return
}
if d.SitePage == nil {
d.renderAdminPages(ctx, "单页服务未就绪", form)
return
}
if err := d.SitePage.Update(uint(id), adminPageInputFrom(form)); err != nil {
d.renderAdminPages(ctx, err.Error(), form)
return
}
ctx.SetFlash("单页已保存")
ctx.Redirect("/admin/pages")
}
// AdminPageDelete 删除
func (d Deps) AdminPageDelete(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/pages")
return
}
if d.SitePage == nil {
ctx.SetFlash("单页服务未就绪")
ctx.Redirect("/admin/pages")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.SitePage.Delete(uint(id)); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/pages")
return
}
ctx.SetFlash("单页已删除")
ctx.Redirect("/admin/pages")
}
// AdminPagePublishPost 快捷发布/下架
func (d Deps) AdminPagePublishPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/pages")
return
}
if d.SitePage == nil {
ctx.SetFlash("单页服务未就绪")
ctx.Redirect("/admin/pages")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
published := c.PostForm("published") == "1" || c.PostForm("published") == "on"
if err := d.SitePage.SetPublished(uint(id), published); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/pages")
return
}
if published {
ctx.SetFlash("已发布")
} else {
ctx.SetFlash("已下架")
}
ctx.Redirect("/admin/pages")
}
func adminPageFormFrom(c *gin.Context) adminPageForm {
sort, _ := strconv.Atoi(c.PostForm("sort_order"))
return adminPageForm{
Title: strings.TrimSpace(c.PostForm("title")),
Slug: strings.TrimSpace(c.PostForm("slug")),
Content: c.PostForm("content"),
Published: c.PostForm("published") == "1" || c.PostForm("published") == "on",
SortOrder: sort,
ShowInNav: c.PostForm("show_in_nav") == "1" || c.PostForm("show_in_nav") == "on",
ShowInFooter: c.PostForm("show_in_footer") == "1" || c.PostForm("show_in_footer") == "on",
}
}
func adminPageInputFrom(form adminPageForm) services.SitePageInput {
return services.SitePageInput{
Title: form.Title, Slug: form.Slug, Content: form.Content,
Published: form.Published, SortOrder: form.SortOrder,
ShowInNav: form.ShowInNav, ShowInFooter: form.ShowInFooter,
}
}

View File

@@ -0,0 +1,285 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"github.com/gin-gonic/gin"
)
func (d Deps) adminPostRedirect(postID uint) string {
return fmt.Sprintf("/post/%d", postID)
}
func (d Deps) parseAdminPostID(c *gin.Context) (uint, string, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
return 0, "/", false
}
return uint(id), d.adminPostRedirect(uint(id)), true
}
func (d Deps) requireAdminPostCSRF(ctx *webctx.Context, redir string) bool {
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect(redir)
return false
}
return true
}
func formBoolEnable(c *gin.Context) bool {
v := strings.TrimSpace(c.PostForm("enable"))
return v == "1" || strings.EqualFold(v, "true") || v == "on"
}
// AdminPostPinPost 全局置顶切换
func (d Deps) AdminPostPinPost(c *gin.Context) {
ctx := d.ctx(c)
id, redir, ok := d.parseAdminPostID(c)
if !ok {
ctx.Redirect("/")
return
}
if !d.requireAdminPostCSRF(ctx, redir) {
return
}
enable := formBoolEnable(c)
if err := d.Post.SetPinned(id, enable); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
if enable {
ctx.SetFlash("已全局置顶")
} else {
ctx.SetFlash("已取消全局置顶")
}
ctx.Redirect(redir)
}
// AdminPostBoardPinPost 版内置顶切换
func (d Deps) AdminPostBoardPinPost(c *gin.Context) {
ctx := d.ctx(c)
id, redir, ok := d.parseAdminPostID(c)
if !ok {
ctx.Redirect("/")
return
}
if !d.requireAdminPostCSRF(ctx, redir) {
return
}
enable := formBoolEnable(c)
if err := d.Post.SetBoardPinned(id, enable); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
if enable {
ctx.SetFlash("已版内置顶")
} else {
ctx.SetFlash("已取消版内置顶")
}
ctx.Redirect(redir)
}
// AdminPostFeaturePost 精华切换
func (d Deps) AdminPostFeaturePost(c *gin.Context) {
ctx := d.ctx(c)
id, redir, ok := d.parseAdminPostID(c)
if !ok {
ctx.Redirect("/")
return
}
if !d.requireAdminPostCSRF(ctx, redir) {
return
}
enable := formBoolEnable(c)
if err := d.Post.SetFeatured(id, enable); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
if enable {
ctx.SetFlash("已设为精华")
} else {
ctx.SetFlash("已取消精华")
}
ctx.Redirect(redir)
}
// AdminPostEditLockPost 禁止编辑切换
func (d Deps) AdminPostEditLockPost(c *gin.Context) {
ctx := d.ctx(c)
id, redir, ok := d.parseAdminPostID(c)
if !ok {
ctx.Redirect("/")
return
}
if !d.requireAdminPostCSRF(ctx, redir) {
return
}
enable := formBoolEnable(c)
if err := d.Post.SetEditLocked(id, enable); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
if enable {
ctx.SetFlash("已禁止编辑")
} else {
ctx.SetFlash("已允许编辑")
}
ctx.Redirect(redir)
}
// AdminPostCommentsLockPost 禁止评论切换
func (d Deps) AdminPostCommentsLockPost(c *gin.Context) {
ctx := d.ctx(c)
id, redir, ok := d.parseAdminPostID(c)
if !ok {
ctx.Redirect("/")
return
}
if !d.requireAdminPostCSRF(ctx, redir) {
return
}
enable := formBoolEnable(c)
if err := d.Post.SetCommentsLocked(id, enable); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
if enable {
ctx.SetFlash("已锁定评论")
} else {
ctx.SetFlash("已解锁评论")
}
ctx.Redirect(redir)
}
// AdminPostDeletePost 软删进回收站
func (d Deps) AdminPostDeletePost(c *gin.Context) {
ctx := d.ctx(c)
id, redir, ok := d.parseAdminPostID(c)
if !ok {
ctx.Redirect("/")
return
}
if !d.requireAdminPostCSRF(ctx, redir) {
return
}
if err := d.Post.Delete(ctx.UserID(), id, true); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
ctx.SetFlash("帖子已移入回收站")
ctx.Redirect("/admin/trash")
}
type adminTrashRow struct {
ID uint
Title string
AuthorName string
BoardName string
DeletedLabel string
}
type adminTrashData struct {
AdminChrome
Items []adminTrashRow
Page int
HasPrev bool
HasMore bool
PrevPage int
NextPage int
Keyword string
}
// AdminTrashGet 回收站
func (d Deps) AdminTrashGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminTrash(ctx, "")
}
func (d Deps) renderAdminTrash(ctx *webctx.Context, errMsg string) {
chrome := d.adminChrome(ctx, "回收站", "trash")
chrome.Error = errMsg
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
keyword := strings.TrimSpace(ctx.C.Query("keyword"))
size := d.Settings.PageSizeDefault()
data := adminTrashData{
AdminChrome: chrome,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
Keyword: keyword,
}
list, total, err := d.Post.ListTrash(page, size, keyword)
if err != nil {
data.Error = err.Error()
ctx.HTML(http.StatusOK, "admin/trash", data)
return
}
data.HasMore = int64(page*size) < total
data.Items = make([]adminTrashRow, 0, len(list))
for _, it := range list {
author := strings.TrimSpace(it.User.Nickname)
if author == "" {
author = it.User.Username
}
bname := ""
if it.Board.ID > 0 {
bname = it.Board.Name
}
data.Items = append(data.Items, adminTrashRow{
ID: it.ID, Title: it.Title, AuthorName: author, BoardName: bname,
DeletedLabel: it.DeletedAt.Local().Format("2006-01-02 15:04"),
})
}
ctx.HTML(http.StatusOK, "admin/trash", data)
}
// AdminTrashRestorePost 恢复
func (d Deps) AdminTrashRestorePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/trash")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Post.Restore(uint(id)); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/trash")
return
}
ctx.SetFlash("已恢复")
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
// AdminTrashPurgePost 彻底删除
func (d Deps) AdminTrashPurgePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/trash")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.Post.Purge(uint(id)); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/admin/trash")
return
}
ctx.SetFlash("已彻底删除")
ctx.Redirect("/admin/trash")
}

View File

@@ -0,0 +1,143 @@
package web
import (
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type adminReportRow struct {
ID uint
CreatedAt string
Status string
StatusLabel string
ReasonLabel string
Detail string
ReporterName string
TargetLabel string
PostID uint
IsComment bool
Pending bool
}
type adminReportsData struct {
AdminChrome
Reports []adminReportRow
PendingCount int64
StatusFilter string
Page int
HasPrev bool
HasMore bool
PrevPage int
NextPage int
}
// AdminReportsGet 举报列表
func (d Deps) AdminReportsGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminReports(ctx, "")
}
func (d Deps) renderAdminReports(ctx *webctx.Context, errMsg string) {
chrome := d.adminChrome(ctx, "举报", "reports")
chrome.Error = errMsg
status := strings.TrimSpace(ctx.C.DefaultQuery("status", "pending"))
if status == "" {
status = "pending"
}
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
data := adminReportsData{
AdminChrome: chrome,
StatusFilter: status,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
}
if d.Report != nil {
data.PendingCount, _ = d.Report.PendingCount()
list, total, _ := d.Report.ListAdmin(services.ReportListQuery{
Status: status, Page: page, Size: d.Settings.PageSizeDefault(),
})
data.HasMore = int64(page*d.Settings.PageSizeDefault()) < total
data.Reports = make([]adminReportRow, 0, len(list))
for _, r := range list {
row := adminReportRow{
ID: r.ID, CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
Status: r.Status, StatusLabel: reportStatusLabel(r.Status),
ReasonLabel: services.ReportReasonLabel(r.Reason), Detail: r.Detail,
PostID: r.PostID, Pending: r.Status == models.ReportStatusPending,
}
if r.Reporter.ID > 0 {
row.ReporterName = r.Reporter.Username
}
isComment := r.CommentID != nil && *r.CommentID > 0
row.IsComment = isComment
title := ""
if r.Post.ID > 0 {
title = r.Post.Title
}
if isComment {
floor := 0
if r.Comment != nil {
floor = r.Comment.Floor
}
row.TargetLabel = "评论 #" + strconv.Itoa(floor) + " · 《" + title + "》"
} else {
row.TargetLabel = "帖子 · 《" + title + "》"
}
data.Reports = append(data.Reports, row)
}
}
ctx.HTML(http.StatusOK, "admin/reports", data)
}
// AdminReportHandlePost 处理举报
func (d Deps) AdminReportHandlePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/admin/reports")
return
}
if d.Report == nil {
ctx.SetFlash("举报服务未就绪")
ctx.Redirect("/admin/reports")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
action := strings.TrimSpace(c.PostForm("action"))
if _, err := d.Report.Handle(services.HandleReportInput{
ReportID: uint(id),
HandlerID: ctx.UserID(),
Action: action,
HandleNote: strings.TrimSpace(c.PostForm("handle_note")),
RejectReason: strings.TrimSpace(c.PostForm("reject_reason")),
}); err != nil {
d.renderAdminReports(ctx, err.Error())
return
}
ctx.SetFlash("举报已处理")
ctx.Redirect("/admin/reports?status=pending")
}
func reportStatusLabel(s string) string {
switch s {
case models.ReportStatusPending:
return "待处理"
case models.ReportStatusResolved:
return "已处理"
case models.ReportStatusDismissed:
return "已驳回"
default:
return s
}
}

226
routers/web/admin_users.go Normal file
View File

@@ -0,0 +1,226 @@
package web
import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type adminUserRow struct {
ID uint
Username string
Nickname string
Email string
Role string
RoleLabel string
Points int
Level int
Verified bool
Banned bool
IsAdmin bool
Created string
}
type adminUsersData struct {
AdminChrome
Users []adminUserRow
Keyword string
Filter string
Page int
HasPrev bool
HasMore bool
PrevPage int
NextPage int
QuerySuffix string
LevelOptions []int
}
// AdminUsersGet 用户列表
func (d Deps) AdminUsersGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminUsers(ctx, "")
}
func (d Deps) renderAdminUsers(ctx *webctx.Context, errMsg string) {
chrome := d.adminChrome(ctx, "用户", "users")
chrome.Error = errMsg
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
keyword := strings.TrimSpace(ctx.C.Query("q"))
filter := strings.TrimSpace(ctx.C.DefaultQuery("filter", "all"))
if filter == "" {
filter = "all"
}
size := d.Settings.PageSizeDefault()
if size < 1 {
size = 20
}
data := adminUsersData{
AdminChrome: chrome,
Keyword: keyword,
Filter: filter,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
LevelOptions: adminLevelOptions(),
}
q := url.Values{}
if keyword != "" {
q.Set("q", keyword)
}
if filter != "" && filter != "all" {
q.Set("filter", filter)
}
data.QuerySuffix = q.Encode()
if d.User != nil {
list, total, err := d.User.ListUsers(services.UserListQuery{
Page: page, Size: size, Keyword: keyword, Filter: filter,
})
if err != nil {
chrome.Error = err.Error()
data.AdminChrome = chrome
ctx.HTML(http.StatusOK, "admin/users", data)
return
}
data.HasMore = int64(page*size) < total
data.Users = make([]adminUserRow, 0, len(list))
for _, u := range list {
nick := strings.TrimSpace(u.Nickname)
if nick == "" {
nick = u.Username
}
data.Users = append(data.Users, adminUserRow{
ID: u.ID, Username: u.Username, Nickname: nick, Email: u.Email,
Role: string(u.Role), RoleLabel: adminUserRoleLabel(u.Role),
Points: u.Points, Level: models.LevelFromExp(u.Exp),
Verified: u.Verified, Banned: u.Banned,
IsAdmin: u.Role == models.RoleAdmin,
Created: u.CreatedAt.Format("2006-01-02"),
})
}
}
ctx.HTML(http.StatusOK, "admin/users", data)
}
func adminUserRoleLabel(role models.Role) string {
switch role {
case models.RoleAdmin:
return "管理员"
default:
return "用户"
}
}
func adminLevelOptions() []int {
n := models.MaxLevel()
out := make([]int, n)
for i := 0; i < n; i++ {
out[i] = i + 1
}
return out
}
// AdminUserBanPost 禁言 / 解禁
func (d Deps) AdminUserBanPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminUsers(ctx, "无效请求,请重试")
return
}
if d.User == nil {
d.renderAdminUsers(ctx, "用户服务未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
banned := c.PostForm("banned") == "1" || c.PostForm("banned") == "on"
if err := d.User.BanUser(uint(id64), banned); err != nil {
d.renderAdminUsers(ctx, err.Error())
return
}
if banned {
ctx.SetFlash("已禁言该用户")
} else {
ctx.SetFlash("已解除禁言")
}
ctx.Redirect("/admin/users")
}
// AdminUserVerifyPost 认证开关
func (d Deps) AdminUserVerifyPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminUsers(ctx, "无效请求,请重试")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
verified := c.PostForm("verified") == "1" || c.PostForm("verified") == "on"
if err := services.SetVerified(uint(id64), verified); err != nil {
d.renderAdminUsers(ctx, err.Error())
return
}
if verified {
ctx.SetFlash("已设为认证用户")
} else {
ctx.SetFlash("已取消认证")
}
ctx.Redirect("/admin/users")
}
// AdminUserPointsPost 调整积分
func (d Deps) AdminUserPointsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminUsers(ctx, "无效请求,请重试")
return
}
if d.Points == nil {
d.renderAdminUsers(ctx, "积分服务未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
delta, err := strconv.Atoi(strings.TrimSpace(c.PostForm("delta")))
if err != nil || delta == 0 {
d.renderAdminUsers(ctx, "请填写非零整数增减额")
return
}
note := strings.TrimSpace(c.PostForm("note"))
bal, err := d.Points.AdminAdjust(uint(id64), delta, note)
if err != nil {
d.renderAdminUsers(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("积分已调整,当前余额 %d", bal))
ctx.Redirect("/admin/users")
}
// AdminUserLevelPost 设定等级(同步 Exp 到门槛)
func (d Deps) AdminUserLevelPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminUsers(ctx, "无效请求,请重试")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
level, err := strconv.Atoi(strings.TrimSpace(c.PostForm("level")))
if err != nil {
d.renderAdminUsers(ctx, "请选择有效等级")
return
}
if err := services.SetUserLevel(uint(id64), level); err != nil {
d.renderAdminUsers(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("已设为 Lv%d(经验门槛 %d)", level, models.ExpForLevel(level)))
ctx.Redirect("/admin/users")
}

View File

@@ -0,0 +1,31 @@
package web
import (
"testing"
"git.iioio.com/freefire/jiang13-forum/services"
)
func TestMoveAsideWidget(t *testing.T) {
base := []services.AsideWidget{
{ID: services.AsideWidgetTagCloud, Enabled: true},
{ID: services.AsideWidgetRecentComments, Enabled: false},
{ID: services.AsideWidgetRecentUsers, Enabled: false},
{ID: services.AsideWidgetFriendLinks, Enabled: true},
}
up := moveAsideWidget(append([]services.AsideWidget(nil), base...), "up:"+services.AsideWidgetRecentComments)
if up[0].ID != services.AsideWidgetRecentComments || up[1].ID != services.AsideWidgetTagCloud {
t.Fatalf("up move failed: %+v", up)
}
down := moveAsideWidget(append([]services.AsideWidget(nil), base...), "down:"+services.AsideWidgetTagCloud)
if down[0].ID != services.AsideWidgetRecentComments || down[1].ID != services.AsideWidgetTagCloud {
t.Fatalf("down move failed: %+v", down)
}
noop := moveAsideWidget(append([]services.AsideWidget(nil), base...), "up:"+services.AsideWidgetTagCloud)
if noop[0].ID != services.AsideWidgetTagCloud {
t.Fatalf("top up should noop: %+v", noop)
}
}

322
routers/web/auth.go Normal file
View File

@@ -0,0 +1,322 @@
package web
import (
"html/template"
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type loginData struct {
PageChrome
Username string
Redirect string
}
type registerData struct {
PageChrome
Username string
Nickname string
Email string
MailReady bool
RequireEmailCode bool
CaptchaID string
CaptchaSVG template.HTML
}
type registerForm struct {
Username string
Nickname string
Email string
}
// LoginGet 登录页
func (d Deps) LoginGet(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
if c.Query("banned") == "1" {
chrome.Error = "账号已被禁言"
}
ctx.HTML(http.StatusOK, "auth/login", loginData{
PageChrome: chrome,
Redirect: c.Query("redirect"),
})
}
// LoginPost 登录提交
func (d Deps) LoginPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderLogin(ctx, "无效请求,请重试", c.PostForm("username"), c.PostForm("redirect"))
return
}
if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
d.renderLogin(ctx, "操作过于频繁,请稍后再试", c.PostForm("username"), c.PostForm("redirect"))
return
}
user := strings.TrimSpace(c.PostForm("username"))
pass := c.PostForm("password")
redir := strings.TrimSpace(c.PostForm("redirect"))
if redir == "" || !strings.HasPrefix(redir, "/") || strings.HasPrefix(redir, "//") {
redir = "/"
}
token, _, err := d.Auth.Login(user, pass, c.ClientIP(), c.Request.UserAgent())
if err != nil {
msg := "用户名或密码错误"
if err == services.ErrUserBanned {
msg = "账号已被禁言"
}
d.renderLogin(ctx, msg, user, redir)
return
}
ctx.SetLoginCookie(token)
ctx.Redirect(redir)
}
func (d Deps) renderLogin(ctx *webctx.Context, errMsg, username, redir string) {
chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
ctx.HTML(http.StatusOK, "auth/login", loginData{
PageChrome: chrome,
Username: username,
Redirect: redir,
})
}
// LogoutPost 退出
func (d Deps) LogoutPost(c *gin.Context) {
ctx := d.ctx(c)
if ctx.CheckCSRF() {
ctx.ClearLoginCookie()
}
ctx.Redirect("/")
}
// RegisterGet 注册页
func (d Deps) RegisterGet(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
d.renderRegister(ctx, "", registerForm{})
}
// RegisterSendCode POST 发送注册邮箱验证码
func (d Deps) RegisterSendCode(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
form := registerFormFrom(c)
if !ctx.CheckCSRF() {
d.renderRegister(ctx, "无效请求,请重试", form)
return
}
if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
return
}
if d.EmailCode == nil || !d.Settings.MailReady() {
d.renderRegister(ctx, "邮件服务未配置,无需验证码即可注册", form)
return
}
if err := d.EmailCode.SendRegisterCode(form.Email); err != nil {
d.renderRegister(ctx, err.Error(), form)
return
}
chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Flash = "验证码已发送,请查收邮箱"
d.renderRegisterWithChrome(ctx, chrome, "", form)
}
// RegisterPost 注册提交
func (d Deps) RegisterPost(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
form := registerFormFrom(c)
if !ctx.CheckCSRF() {
d.renderRegister(ctx, "无效请求,请重试", form)
return
}
if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
return
}
captchaID := strings.TrimSpace(c.PostForm("captcha_id"))
captchaAns := strings.TrimSpace(c.PostForm("captcha"))
if d.Captcha == nil || !d.Captcha.Verify(captchaID, captchaAns) {
d.renderRegister(ctx, "验证码错误或已过期", form)
return
}
mailReady := d.Settings.MailReady()
if mailReady {
code := strings.TrimSpace(c.PostForm("email_code"))
if d.EmailCode == nil || !d.EmailCode.Verify(form.Email, code) {
d.renderRegister(ctx, services.ErrEmailCodeInvalid.Error(), form)
return
}
}
pass := c.PostForm("password")
pass2 := c.PostForm("password2")
if pass != pass2 {
d.renderRegister(ctx, "两次密码不一致", form)
return
}
user, err := d.Auth.Register(form.Username, pass, form.Nickname, form.Email)
if err != nil {
d.renderRegister(ctx, err.Error(), form)
return
}
sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent())
if err != nil {
ctx.SetFlash("注册成功,请登录")
ctx.Redirect("/login")
return
}
ctx.SetLoginCookie(sid)
ctx.SetFlash("注册成功,欢迎加入")
ctx.Redirect("/")
}
func registerFormFrom(c *gin.Context) registerForm {
return registerForm{
Username: strings.TrimSpace(c.PostForm("username")),
Nickname: strings.TrimSpace(c.PostForm("nickname")),
Email: strings.TrimSpace(c.PostForm("email")),
}
}
func (d Deps) renderRegister(ctx *webctx.Context, errMsg string, form registerForm) {
chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
d.renderRegisterWithChrome(ctx, chrome, errMsg, form)
}
func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg string, form registerForm) {
chrome.Error = errMsg
mailReady := d.Settings.MailReady()
data := registerData{
PageChrome: chrome,
Username: form.Username,
Nickname: form.Nickname,
Email: form.Email,
MailReady: mailReady,
RequireEmailCode: mailReady,
}
if d.Captcha != nil {
if id, svg, err := d.Captcha.Generate(); err == nil {
data.CaptchaID = id
data.CaptchaSVG = template.HTML(svg)
}
}
ctx.HTML(http.StatusOK, "auth/register", data)
}
type forgotPasswordData struct {
PageChrome
Email string
MailReady bool
}
// ForgotPasswordGet 忘记密码页
func (d Deps) ForgotPasswordGet(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
d.renderForgotPassword(ctx, "", "")
}
// ForgotPasswordSendCode 发送重置验证码
func (d Deps) ForgotPasswordSendCode(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
email := strings.TrimSpace(c.PostForm("email"))
if !ctx.CheckCSRF() {
d.renderForgotPassword(ctx, "无效请求,请重试", email)
return
}
if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
d.renderForgotPassword(ctx, "操作过于频繁,请稍后再试", email)
return
}
if d.EmailCode == nil || !d.Settings.MailReady() {
d.renderForgotPassword(ctx, services.ErrMailNotConfigured.Error(), email)
return
}
if err := d.EmailCode.SendResetCode(email); err != nil {
d.renderForgotPassword(ctx, err.Error(), email)
return
}
chrome := d.chrome(ctx, "忘记密码 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Flash = "若该邮箱已注册,验证码将发送到邮箱"
d.renderForgotPasswordWithChrome(ctx, chrome, "", email)
}
// ForgotPasswordPost 提交重置
func (d Deps) ForgotPasswordPost(c *gin.Context) {
ctx := d.ctx(c)
if ctx.IsSigned() {
ctx.Redirect("/")
return
}
email := strings.TrimSpace(c.PostForm("email"))
if !ctx.CheckCSRF() {
d.renderForgotPassword(ctx, "无效请求,请重试", email)
return
}
if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
d.renderForgotPassword(ctx, "操作过于频繁,请稍后再试", email)
return
}
if !d.Settings.MailReady() || d.EmailCode == nil {
d.renderForgotPassword(ctx, services.ErrMailNotConfigured.Error(), email)
return
}
code := strings.TrimSpace(c.PostForm("email_code"))
if !d.EmailCode.VerifyPurpose(services.EmailCodePurposeReset, email, code) {
d.renderForgotPassword(ctx, services.ErrEmailCodeInvalid.Error(), email)
return
}
pass := c.PostForm("new_password")
pass2 := c.PostForm("new_password2")
if pass != pass2 {
d.renderForgotPassword(ctx, "两次输入的新密码不一致", email)
return
}
if err := d.User.ResetPasswordByEmail(email, pass); err != nil {
d.renderForgotPassword(ctx, err.Error(), email)
return
}
ctx.SetFlash("密码已重置,请使用新密码登录")
ctx.Redirect("/login")
}
func (d Deps) renderForgotPassword(ctx *webctx.Context, errMsg, email string) {
chrome := d.chrome(ctx, "忘记密码 · "+d.Settings.SiteBranding().Name, "", "")
d.renderForgotPasswordWithChrome(ctx, chrome, errMsg, email)
}
func (d Deps) renderForgotPasswordWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg, email string) {
chrome.Error = errMsg
ctx.HTML(http.StatusOK, "auth/forgot_password", forgotPasswordData{
PageChrome: chrome,
Email: email,
MailReady: d.Settings.MailReady(),
})
}

56
routers/web/boards.go Normal file
View File

@@ -0,0 +1,56 @@
package web
import (
"net/http"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"github.com/gin-gonic/gin"
)
type boardsItemView struct {
ID uint
Name string
Href string
Description string
Icon string
ColorIndex int
PostCount int
}
type boardsPageData struct {
PageChrome
Items []boardsItemView
}
// BoardsGet 公开板块索引
func (d Deps) BoardsGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderBoards(ctx)
}
func (d Deps) renderBoards(ctx *webctx.Context) {
brand := d.Settings.SiteBranding()
chrome := d.chrome(ctx, "板块 · "+brand.Name, "", "")
list, _ := d.Board.ListWithStats()
items := make([]boardsItemView, 0, len(list))
pl := d.permalink()
for _, b := range list {
color := b.ColorIndex
if color < 0 {
color = int(b.ID % 8)
}
items = append(items, boardsItemView{
ID: b.ID,
Name: b.Name,
Href: pl.BoardPath(b.ID),
Description: b.Description,
Icon: b.Icon,
ColorIndex: color,
PostCount: b.PostCount,
})
}
ctx.HTML(http.StatusOK, "boards/list", boardsPageData{
PageChrome: chrome,
Items: items,
})
}

386
routers/web/chrome.go Normal file
View File

@@ -0,0 +1,386 @@
package web
import (
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// Deps 页面依赖
type Deps struct {
DataDir string
JWTSecret string
AssetVersion string // 静态资源 ?v=;来自构建 ldflags
Settings *services.ForumSettingsService
Auth *services.AuthService
User *services.UserService
Board *services.BoardService
Post *services.PostService
Comment *services.CommentService
Message *services.MessageService
Filter *services.SensitiveFilter
Limiter *services.RateLimiter
EmailCode *services.EmailCodeService
Store *services.UploadStore
Points *services.PointsService
Badge *services.BadgeService
FriendLink *services.FriendLinkApplyService
Mail *services.MailService
SitePage *services.SitePageService
Report *services.ReportService
Captcha *services.CaptchaService
Notify *services.NotifyService
Backup *services.BackupService
}
// SitePageLink 导航/页脚站点单页链接
type SitePageLink struct {
Title string
Slug string
}
// BoardView 侧栏
type BoardView struct {
ID uint
Name string
Href string
PostCount int
ColorSlot int // 0–7 板块色槽
}
// RightAsideHotPost 右栏热门帖
type RightAsideHotPost struct {
ID uint
Title string
Href string
}
// RightAsideTag 右栏标签
type RightAsideTag struct {
Name string
Count int
}
// RightAsideComment 右栏最新评论
type RightAsideComment struct {
PostID uint
PostHref string
Author string
Excerpt string
PostTitle string
}
// RightAsideUser 右栏最新用户
type RightAsideUser struct {
ID uint
Nickname string
Href string
}
// RightAsideFriendLink 右栏友链
type RightAsideFriendLink struct {
Name string
URL string
Logo string
}
// RightAsideWidget 按 aside_widgets 顺序的一块(仅已开启)
type RightAsideWidget struct {
Kind string // tag_cloud | recent_comments | recent_users | friend_links
Tags []RightAsideTag
Comments []RightAsideComment
Users []RightAsideUser
FriendLinks []RightAsideFriendLink
}
// RightAsideData 右栏:签到条 + 固定热门 + 可配置 widgets
type RightAsideData struct {
ShowCheckIn bool
CheckedIn bool
CheckInStreak int
CheckInPoints int // 已签实得或预计可得
LotteryDrawn bool
LotteryPoints int
LotteryCost int
HotPosts []RightAsideHotPost
Widgets []RightAsideWidget
}
// PageChrome 布局公共字段
type PageChrome struct {
Title string
Description string
SiteName string
Slogan string
LogoMark string
LogoURL string
FaviconURL string
OGImageURL string
PermalinkSuffix string // 如 ".html";未启用为空
OpenPostsInNewTab bool
LoggedIn bool
IsAdmin bool
ViewerName string
ViewerAvatar string
ViewerMark string
Boards []BoardView
ActiveBoard uint
Path string // 当前请求路径,供侧栏高亮
Inner string // 保留字段;入口模板已固定组合,不再动态 template
CSRF string
Flash string
Error string
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
ViewerPoints int // 登录用户当前积分;未登录为 0
ShowFriendLinksNav bool
ShowFriendLinksFooter bool
NavPages []SitePageLink
FooterPages []SitePageLink
RightAside RightAsideData
AssetVersion string
}
func (d Deps) ctx(c *gin.Context) *webctx.Context {
return webctx.New(c, d.JWTSecret)
}
func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome {
brand := d.Settings.SiteBranding()
if title == "" {
title = brand.DocumentTitle()
}
if desc == "" {
desc = brand.MetaDescription()
}
name := "我的"
if ctx.IsSigned() {
name = strings.TrimSpace(ctx.Doer.Nickname)
if name == "" {
name = ctx.Doer.Username
}
}
boards, _ := d.Board.ListWithStats()
bv := make([]BoardView, 0, len(boards))
pl := d.permalink()
for _, b := range boards {
slot := b.ColorIndex
if slot < 0 {
slot = int(b.ID % 8)
}
bv = append(bv, BoardView{
ID: b.ID, Name: b.Name, Href: pl.BoardPath(b.ID),
PostCount: b.PostCount, ColorSlot: slot % 8,
})
}
var unread int64
if ctx.IsSigned() && d.Message != nil {
unread, _ = d.Message.UnreadCount(ctx.UserID())
}
viewerPoints := 0
viewerAvatar := ""
viewerMark := "我"
if ctx.IsSigned() && ctx.Doer != nil {
viewerPoints = ctx.Doer.Points
viewerAvatar = strings.TrimSpace(ctx.Doer.Avatar)
viewerMark = firstRuneOr(name, "我")
}
path := ""
if ctx.C != nil && ctx.C.Request != nil && ctx.C.Request.URL != nil {
path = ctx.C.Request.URL.Path
}
navPages, footerPages := d.sitePageLinks()
assetVer := strings.TrimSpace(d.AssetVersion)
if assetVer == "" {
assetVer = "dev"
}
return PageChrome{
Title: title,
Description: desc,
SiteName: brand.Name,
Slogan: brand.Slogan,
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
LogoURL: strings.TrimSpace(brand.Logo),
FaviconURL: strings.TrimSpace(brand.Favicon),
OGImageURL: strings.TrimSpace(brand.DefaultShareImage()),
PermalinkSuffix: d.Settings.Permalink().Suffix(),
OpenPostsInNewTab: d.Settings.OpenPostsInNewTab(),
LoggedIn: ctx.IsSigned(),
IsAdmin: ctx.IsAdmin(),
ViewerName: name,
ViewerAvatar: viewerAvatar,
ViewerMark: viewerMark,
Boards: bv,
Path: path,
Inner: inner,
CSRF: ctx.EnsureCSRF(),
Flash: ctx.TakeFlash(),
UnreadCount: unread,
ViewerPoints: viewerPoints,
ShowFriendLinksNav: d.Settings.NavShowFriendLinks(),
ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(),
NavPages: navPages,
FooterPages: footerPages,
RightAside: d.loadRightAside(ctx, brand),
AssetVersion: assetVer,
}
}
func (d Deps) sitePageLinks() (nav, footer []SitePageLink) {
nav, footer = []SitePageLink{}, []SitePageLink{}
if d.SitePage == nil {
return
}
list, err := d.SitePage.ListPublished()
if err != nil {
return
}
for _, p := range list {
link := SitePageLink{Title: p.Title, Slug: p.Slug}
if p.ShowInNav {
nav = append(nav, link)
}
if p.ShowInFooter {
footer = append(footer, link)
}
}
return
}
const (
rightAsideHotLimit = 5
rightAsideTagLimit = 24
rightAsideCommentLimit = 8
rightAsideUserLimit = 8
)
func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) RightAsideData {
out := RightAsideData{
HotPosts: []RightAsideHotPost{},
Widgets: []RightAsideWidget{},
}
if ctx.IsSigned() && d.Points != nil {
out.ShowCheckIn = true
if st, err := d.Points.GetCheckInStatus(ctx.UserID()); err == nil {
out.CheckedIn = st.CheckedIn
out.CheckInStreak = st.Streak
out.CheckInPoints = st.TodayPoints
}
if st, err := d.Points.GetLotteryStatus(ctx.UserID()); err == nil {
out.LotteryDrawn = st.Drawn
out.LotteryPoints = st.Points
out.LotteryCost = st.Cost
}
}
if d.Post != nil {
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
pl := d.permalink()
for _, it := range items {
out.HotPosts = append(out.HotPosts, RightAsideHotPost{ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID)})
}
}
}
for _, w := range d.Settings.AsideWidgets() {
if !w.Enabled {
continue
}
block := RightAsideWidget{Kind: w.ID}
switch w.ID {
case services.AsideWidgetTagCloud:
if d.Post != nil {
if tags, err := d.Post.PopularTags(rightAsideTagLimit); err == nil {
for _, t := range tags {
block.Tags = append(block.Tags, RightAsideTag{Name: t.Name, Count: t.Count})
}
}
}
case services.AsideWidgetRecentComments:
if d.Comment != nil {
if list, err := d.Comment.ListRecentPublic(rightAsideCommentLimit); err == nil {
pl := d.permalink()
for _, c := range list {
block.Comments = append(block.Comments, RightAsideComment{
PostID: c.PostID, PostHref: pl.PostPath(c.PostID), Author: c.Author, Excerpt: c.Excerpt, PostTitle: c.PostTitle,
})
}
}
}
case services.AsideWidgetRecentUsers:
if d.User != nil {
if list, err := d.User.ListRecentRegistered(rightAsideUserLimit); err == nil {
pl := d.permalink()
for _, u := range list {
block.Users = append(block.Users, RightAsideUser{ID: u.ID, Nickname: u.Nickname, Href: pl.UserPath(u.ID)})
}
}
}
case services.AsideWidgetFriendLinks:
for _, l := range brand.FriendLinks {
block.FriendLinks = append(block.FriendLinks, RightAsideFriendLink{Name: l.Name, URL: l.URL, Logo: l.Logo})
}
default:
continue
}
out.Widgets = append(out.Widgets, block)
}
return out
}
func firstRuneOr(s, fallback string) string {
s = strings.TrimSpace(s)
if s == "" {
return fallback
}
for _, r := range s {
return string(r)
}
return fallback
}
func stripIDParam(raw, permalinkExt string) string {
raw = strings.TrimSpace(raw)
if permalinkExt != "" {
raw = strings.TrimSuffix(raw, "."+permalinkExt)
}
raw = strings.TrimSuffix(raw, ".html")
raw = strings.TrimSuffix(raw, ".htm")
return raw
}
func (d Deps) permalink() services.PermalinkConfig {
if d.Settings == nil {
return services.PermalinkConfig{}
}
return d.Settings.Permalink()
}
func (d Deps) postHref(id uint) string {
return d.permalink().PostPath(id)
}
func (d Deps) boardHref(id uint) string {
return d.permalink().BoardPath(id)
}
func (d Deps) userHref(id uint) string {
return d.permalink().UserPath(id)
}
// redirectIfNotCanonical 伪静态开启时把无后缀/错误后缀 301 到规范路径
func (d Deps) redirectIfNotCanonical(c *gin.Context, match services.PermalinkMatch) bool {
if !match.OK || !d.permalink().Enabled {
return false
}
path := c.Request.URL.Path
if !match.NeedsCanonicalRedirect(path) {
return false
}
loc := match.Canonical
if q := c.Request.URL.RawQuery; q != "" {
loc = loc + "?" + q
}
c.Redirect(http.StatusMovedPermanently, loc)
return true
}

337
routers/web/compose.go Normal file
View File

@@ -0,0 +1,337 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type composeData struct {
PageChrome
IsEdit bool
PostID uint
FormAction string
BoardID uint
Title string
Tags string
Content string
PostType string
PollMulti bool
PollMaxChoices int
PollEndsAt string
PollOptions string
BountyPoints int
LotteryWinners int
Boards []BoardView
TitleMax int
TagsMax int
ContentMax int
}
// ComposeGet 发帖页
func (d Deps) ComposeGet(c *gin.Context) {
ctx := d.ctx(c)
if err := d.ensureCanWrite(ctx); err != "" {
ctx.SetFlash(err)
ctx.Redirect("/")
return
}
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
d.renderCompose(ctx, "", composeForm{
BoardID: uint(boardID),
PostType: models.PostTypeNormal,
PollMaxChoices: 1,
BountyPoints: 1,
LotteryWinners: 1,
}, false, 0)
}
// ComposePost 发帖提交
func (d Deps) ComposePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), false, 0)
return
}
if msg := d.ensureCanWrite(ctx); msg != "" {
ctx.SetFlash(msg)
ctx.Redirect("/")
return
}
if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
d.renderCompose(ctx, "发帖过于频繁,请稍后再试", composeFormFrom(c), false, 0)
return
}
form := composeFormFrom(c)
htmlBody := services.ComposeBodyToHTML(form.Content)
postType := normalizeComposePostType(form.PostType)
post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, postType, ctx.SkipsModeration())
if err != nil {
d.renderCompose(ctx, err.Error(), form, false, 0)
return
}
if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty || post.PostType == models.PostTypeLottery {
var extras services.PostCreateExtras
switch post.PostType {
case models.PostTypePoll:
pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions)
if err != nil {
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
d.renderCompose(ctx, err.Error(), form, false, 0)
return
}
extras = services.ParsePostExtrasFromForm(pollJSON, "", "")
case models.PostTypeBounty:
extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "")
case models.PostTypeLottery:
extras = services.ParsePostExtrasFromForm("", "", strconv.Itoa(form.LotteryWinners))
}
if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil {
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
d.renderCompose(ctx, err.Error(), form, false, 0)
return
}
}
if post.Status == models.ContentStatusPending {
ctx.SetFlash("帖子已提交,等待审核")
if d.Notify != nil {
d.Notify.AsyncNotifyPendingPost(post)
}
} else {
ctx.SetFlash("发帖成功")
}
ctx.Redirect(d.postHref(post.ID))
}
// PostEditGet 编辑帖
func (d Deps) PostEditGet(c *gin.Context) {
ctx := d.ctx(c)
post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
if errMsg != "" {
ctx.SetFlash(errMsg)
ctx.Redirect("/")
return
}
d.renderCompose(ctx, "", composeForm{
BoardID: post.BoardID,
Title: post.Title,
Tags: post.Tags,
Content: services.HTMLToComposePlain(post.Content),
PostType: post.PostType,
}, true, post.ID)
}
// PostEditPost 编辑提交
func (d Deps) PostEditPost(c *gin.Context) {
ctx := d.ctx(c)
post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
if errMsg != "" {
ctx.SetFlash(errMsg)
ctx.Redirect("/")
return
}
if !ctx.CheckCSRF() {
d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), true, post.ID)
return
}
if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
d.renderCompose(ctx, "操作过于频繁,请稍后再试", composeFormFrom(c), true, post.ID)
return
}
form := composeFormFrom(c)
form.PostType = post.PostType // 编辑不可改类型
htmlBody := services.ComposeBodyToHTML(form.Content)
if err := d.Post.Update(ctx.UserID(), post.ID, ctx.IsAdmin(), ctx.SkipsModeration(), form.Title, htmlBody, form.Tags, post.PostType, form.BoardID); err != nil {
d.renderCompose(ctx, err.Error(), form, true, post.ID)
return
}
ctx.SetFlash("已保存")
ctx.Redirect(d.postHref(post.ID))
}
// ComposeUpload 帖图上传(JSON,供 compose 页 fetch)
func (d Deps) ComposeUpload(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.IsSigned() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
if !ctx.CheckCSRF() {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
return
}
if ctx.Doer != nil && ctx.Doer.Banned {
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
return
}
if d.Store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"})
return
}
file, err := c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
return
}
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategoryPosts, fmt.Sprintf("%d", ctx.UserID()))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
// ComposePreview 正文预览(与发帖同一套 ComposeBodyToHTML + 消毒)
func (d Deps) ComposePreview(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.IsSigned() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
if !ctx.CheckCSRF() {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
return
}
content := c.PostForm("content")
if content == "" {
var body struct {
Content string `json:"content"`
}
_ = c.ShouldBindJSON(&body)
content = body.Content
}
htmlBody := services.SanitizePostHTML(services.ComposeBodyToHTML(content))
c.JSON(http.StatusOK, gin.H{"html": htmlBody})
}
type composeForm struct {
BoardID uint
Title string
Tags string
Content string
PostType string
PollMulti bool
PollMaxChoices int
PollEndsAt string
PollOptions string
BountyPoints int
LotteryWinners int
}
func normalizeComposePostType(t string) string {
switch t {
case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty, models.PostTypeLottery:
return t
default:
return models.PostTypeNormal
}
}
func composeFormFrom(c *gin.Context) composeForm {
bid, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
maxChoices, _ := strconv.Atoi(c.PostForm("poll_max_choices"))
if maxChoices < 1 {
maxChoices = 1
}
bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points"))
lotteryWinners, _ := strconv.Atoi(c.PostForm("lottery_winner_count"))
if lotteryWinners < 1 {
lotteryWinners = 1
}
return composeForm{
BoardID: uint(bid),
Title: strings.TrimSpace(c.PostForm("title")),
Tags: strings.TrimSpace(c.PostForm("tags")),
Content: c.PostForm("content"),
PostType: normalizeComposePostType(strings.TrimSpace(c.PostForm("post_type"))),
PollMulti: c.PostForm("poll_multi") == "1" || c.PostForm("poll_multi") == "on",
PollMaxChoices: maxChoices,
PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")),
PollOptions: c.PostForm("poll_options"),
BountyPoints: bountyPoints,
LotteryWinners: lotteryWinners,
}
}
func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm, isEdit bool, postID uint) {
title := "发帖"
action := "/compose"
if isEdit {
title = "编辑帖子"
action = fmt.Sprintf("/post/%d/edit", postID)
}
if form.PostType == "" {
form.PostType = models.PostTypeNormal
}
if form.PollMaxChoices < 1 {
form.PollMaxChoices = 1
}
if form.BountyPoints < 1 {
form.BountyPoints = 1
}
if form.LotteryWinners < 1 {
form.LotteryWinners = 1
}
chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
chrome.ActiveBoard = form.BoardID
ctx.HTML(http.StatusOK, "compose", composeData{
PageChrome: chrome,
IsEdit: isEdit,
PostID: postID,
FormAction: action,
BoardID: form.BoardID,
Title: form.Title,
Tags: form.Tags,
Content: form.Content,
PostType: form.PostType,
PollMulti: form.PollMulti,
PollMaxChoices: form.PollMaxChoices,
PollEndsAt: form.PollEndsAt,
PollOptions: form.PollOptions,
BountyPoints: form.BountyPoints,
LotteryWinners: form.LotteryWinners,
Boards: chrome.Boards,
TitleMax: d.Settings.PostTitleMax(),
TagsMax: d.Settings.PostTagsMax(),
ContentMax: d.Settings.PostContentMax(),
})
}
func (d Deps) ensureCanWrite(ctx *webctx.Context) string {
if !ctx.IsSigned() {
return "请先登录"
}
if ctx.Doer != nil && ctx.Doer.Banned {
return "账号已被禁言,无法发帖"
}
return ""
}
func (d Deps) loadEditablePost(ctx *webctx.Context, idParam string) (*models.Post, string) {
if msg := d.ensureCanWrite(ctx); msg != "" {
return nil, msg
}
idStr := stripIDParam(idParam, d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
return nil, "帖子不存在"
}
post, err := d.Post.FindByID(uint(id))
if err != nil {
return nil, "帖子不存在"
}
if !ctx.IsAdmin() && post.UserID != ctx.UserID() {
return nil, "无权编辑此帖"
}
if reason := d.Post.EditBlockReason(post, ctx.IsAdmin()); reason != "" {
return nil, reason
}
return post, ""
}

71
routers/web/favorites.go Normal file
View File

@@ -0,0 +1,71 @@
package web
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
type favItem struct {
PostID uint
Title string
Href string
AuthorName string
BoardName string
CreatedLabel string
}
type favoritesData struct {
PageChrome
Items []favItem
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
Total int64
}
// FavoritesGet 我的收藏
func (d Deps) FavoritesGet(c *gin.Context) {
ctx := d.ctx(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := d.Settings.PageSizeDefault()
favs, total, err := d.Post.ListFavorites(ctx.UserID(), page, size)
if err != nil {
chrome := d.chrome(ctx, "收藏 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = err.Error()
ctx.HTML(http.StatusOK, "favorites/list", favoritesData{PageChrome: chrome})
return
}
items := make([]favItem, 0, len(favs))
pl := d.permalink()
for _, f := range favs {
title := f.Post.Title
author := strings.TrimSpace(f.Post.User.Nickname)
if author == "" {
author = f.Post.User.Username
}
board := ""
if f.Post.Board.ID > 0 {
board = f.Post.Board.Name
}
items = append(items, favItem{
PostID: f.PostID, Title: title, Href: pl.PostPath(f.PostID), AuthorName: author, BoardName: board,
CreatedLabel: formatTime(f.CreatedAt),
})
}
chrome := d.chrome(ctx, "收藏 · "+d.Settings.SiteBranding().Name, "", "")
hasMore := int64(page*size) < total
ctx.HTML(http.StatusOK, "favorites/list", favoritesData{
PageChrome: chrome,
Items: items,
Page: page, PrevPage: page - 1, NextPage: page + 1,
HasPrev: page > 1, HasMore: hasMore, Total: total,
})
}

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

@@ -0,0 +1,466 @@
package web
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// Register 注册已安装后的 web 路由
func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g := r.Group("/", authMW.OptionalAuth())
g.GET("/", deps.Home)
g.GET("/board/:id", deps.Home)
g.GET("/post/:id", deps.PostView)
g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet)
g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost)
g.GET("/post/:id/revisions", authMW.RequireAuth(), deps.PostRevisionsGet)
g.GET("/post/:id/revisions/:rid", authMW.RequireAuth(), deps.PostRevisionDetailGet)
g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
g.GET("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditGet)
g.POST("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditPost)
g.POST("/post/:id/comments/:cid/delete", authMW.RequireAuth(), deps.CommentDeletePost)
g.POST("/post/:id/comments/:cid/like", authMW.RequireAuth(), deps.PostCommentLike)
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
g.POST("/post/:id/poll/vote", authMW.RequireAuth(), deps.PostPollVote)
g.POST("/post/:id/poll/close", authMW.RequireAuth(), deps.PostPollClose)
g.POST("/post/:id/question/resolve", authMW.RequireAuth(), deps.PostQuestionResolvePost)
g.POST("/post/:id/bounty/award", authMW.RequireAuth(), deps.PostBountyAwardPost)
g.POST("/post/:id/bounty/refund", authMW.RequireAuth(), deps.PostBountyRefundPost)
g.POST("/post/:id/lottery/draw", authMW.RequireAuth(), deps.PostLotteryDrawPost)
g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock)
g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost)
g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost)
g.POST("/post/:id/admin/pin", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostPinPost)
g.POST("/post/:id/admin/board-pin", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostBoardPinPost)
g.POST("/post/:id/admin/feature", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostFeaturePost)
g.POST("/post/:id/admin/edit-lock", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostEditLockPost)
g.POST("/post/:id/admin/comments-lock", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostCommentsLockPost)
g.POST("/post/:id/admin/delete", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostDeletePost)
g.GET("/login", deps.LoginGet)
g.POST("/login", deps.LoginPost)
g.POST("/logout", deps.LogoutPost)
g.GET("/register", deps.RegisterGet)
g.POST("/register", deps.RegisterPost)
g.POST("/register/send-code", deps.RegisterSendCode)
g.GET("/forgot-password", deps.ForgotPasswordGet)
g.POST("/forgot-password", deps.ForgotPasswordPost)
g.POST("/forgot-password/send-code", deps.ForgotPasswordSendCode)
g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet)
g.POST("/compose", authMW.RequireAuth(), deps.ComposePost)
g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload)
g.POST("/compose/preview", authMW.RequireAuth(), deps.ComposePreview)
g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") })
admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin())
{
admin.GET("", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
admin.GET("/dashboard", deps.AdminDashboard)
admin.GET("/boards", deps.AdminBoardsGet)
admin.POST("/boards", deps.AdminBoardCreate)
admin.POST("/boards/:id", deps.AdminBoardUpdate)
admin.POST("/boards/:id/delete", deps.AdminBoardDelete)
admin.GET("/moderation", deps.AdminModerationGet)
admin.POST("/posts/:id/approve", deps.AdminPostApprove)
admin.POST("/posts/:id/reject", deps.AdminPostReject)
admin.POST("/comments/:id/approve", deps.AdminCommentApprove)
admin.POST("/comments/:id/reject", deps.AdminCommentReject)
admin.GET("/settings", deps.AdminSettingsGet)
admin.POST("/settings/brand", deps.AdminSettingsBrandPost)
admin.POST("/settings/brand/upload", deps.AdminSettingsBrandUploadPost)
admin.POST("/settings/brand/clear", deps.AdminSettingsBrandClearPost)
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
admin.POST("/settings/content-limits", deps.AdminSettingsContentLimitsPost)
admin.POST("/settings/permalink", deps.AdminSettingsPermalinkPost)
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
admin.POST("/settings/mail", deps.AdminSettingsMailPost)
admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost)
admin.POST("/settings/aside-widgets", deps.AdminSettingsAsideWidgetsPost)
admin.POST("/settings/backup", deps.AdminSettingsBackupPost)
admin.GET("/settings/backup/download/:name", deps.AdminSettingsBackupDownload)
admin.GET("/friend-links", deps.AdminFriendLinksGet)
admin.POST("/friend-links/settings", deps.AdminFriendLinksSettingsPost)
admin.POST("/friend-links/brand", deps.AdminFriendLinksBrandAddPost)
admin.POST("/friend-links/brand/delete", deps.AdminFriendLinksBrandDeletePost)
admin.POST("/friend-links/applies/:id/approve", deps.AdminFriendLinkApprovePost)
admin.POST("/friend-links/applies/:id/reject", deps.AdminFriendLinkRejectPost)
admin.POST("/friend-links/applies/:id/recheck", deps.AdminFriendLinkRecheckPost)
admin.GET("/pages", deps.AdminPagesGet)
admin.POST("/pages", deps.AdminPageCreate)
admin.GET("/pages/:id/edit", deps.AdminPageEditGet)
admin.POST("/pages/:id", deps.AdminPageUpdate)
admin.POST("/pages/:id/delete", deps.AdminPageDelete)
admin.POST("/pages/:id/publish", deps.AdminPagePublishPost)
admin.GET("/reports", deps.AdminReportsGet)
admin.POST("/reports/:id/handle", deps.AdminReportHandlePost)
admin.GET("/users", deps.AdminUsersGet)
admin.POST("/users/:id/ban", deps.AdminUserBanPost)
admin.POST("/users/:id/verify", deps.AdminUserVerifyPost)
admin.POST("/users/:id/points", deps.AdminUserPointsPost)
admin.POST("/users/:id/level", deps.AdminUserLevelPost)
admin.GET("/badges", deps.AdminBadgesGet)
admin.POST("/badges", deps.AdminBadgeCreate)
admin.POST("/badges/award", deps.AdminBadgeAwardPost)
admin.POST("/badges/revoke", deps.AdminBadgeRevokePost)
admin.GET("/badges/:id/edit", deps.AdminBadgeEditGet)
admin.POST("/badges/:id", deps.AdminBadgeUpdate)
admin.POST("/badges/:id/delete", deps.AdminBadgeDelete)
admin.GET("/media", deps.AdminMediaGet)
admin.POST("/media/delete", deps.AdminMediaBatchDeletePost)
admin.POST("/media/sync", deps.AdminMediaSyncPost)
admin.POST("/media/:id/delete", deps.AdminMediaDeletePost)
admin.GET("/trash", deps.AdminTrashGet)
admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost)
admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost)
}
g.GET("/user/:id", deps.UserPublic)
g.GET("/profile", authMW.RequireAuth(), deps.ProfileGet)
g.POST("/profile/nickname", authMW.RequireAuth(), deps.ProfileNicknamePost)
g.POST("/profile/signature", authMW.RequireAuth(), deps.ProfileSignaturePost)
g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost)
g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost)
g.POST("/profile/checkin", authMW.RequireAuth(), deps.ProfileCheckInPost)
g.POST("/profile/lottery", authMW.RequireAuth(), deps.ProfileLotteryPost)
g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet)
g.GET("/messages", authMW.RequireAuth(), deps.MessagesList)
g.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread)
g.POST("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesSend)
g.POST("/messages/read-all", authMW.RequireAuth(), deps.MessagesReadAll)
g.GET("/links", deps.LinksGet)
g.POST("/links/apply", authMW.RequireAuth(), deps.LinksApplyPost)
g.POST("/links/apply/:id/cancel", authMW.RequireAuth(), deps.LinksApplyCancelPost)
g.POST("/links/logo", authMW.RequireAuth(), deps.LinksLogoUpload)
g.GET("/projects", deps.PendingPage)
g.GET("/boards", deps.BoardsGet)
g.GET("/page/:slug", deps.SitePageGet)
}
// HomePageData Feed
type HomePageData struct {
PageChrome
BoardName string
Sort string
Keyword string
Tag string
Author string
TitleOnly bool
HasSearch bool
SearchError string
FeedListStyle string // title|excerpt|thumbnail
ShowExcerpt bool
Posts []PostListItem
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
// PostListItem 列表项(对齐 main SPA post-row--v2)
type PostListItem struct {
ID uint
Title string
Href string
AuthorID uint
AuthorName string
AuthorHref string
AvatarURL string
AuthorMark string
BoardID uint
BoardName string
BoardHref string
BoardColorSlot int
ShowBoardBadge bool
Excerpt string
ThumbURL string
Pinned bool
BoardPinned bool
Featured bool
CommentCount int
LikeCount int
ViewCount int
CreatedLabel string
LastReplyLabel string
LastReplyName string
LastReplyHref string
ShowLastReply bool
}
// FormAction 搜索表单提交路径(当前 Feed)
func (d HomePageData) FormAction() string {
if d.ActiveBoard > 0 {
return fmt.Sprintf("/board/%d%s", d.ActiveBoard, d.PermalinkSuffix)
}
return "/"
}
// SortHref 排序链接,保留搜索参数
func (d HomePageData) SortHref(sort string) string {
return buildFeedURL(d.ActiveBoard, d.PermalinkSuffix, sort, 0, d.Keyword, d.Tag, d.Author, d.TitleOnly)
}
// PageHref 分页链接,保留搜索与排序
func (d HomePageData) PageHref(page int) string {
return buildFeedURL(d.ActiveBoard, d.PermalinkSuffix, d.Sort, page, d.Keyword, d.Tag, d.Author, d.TitleOnly)
}
func buildFeedURL(boardID uint, permalinkSuffix, sort string, page int, keyword, tag, author string, titleOnly bool) string {
q := url.Values{}
if sort != "" && sort != "latest" {
q.Set("sort", sort)
}
if page > 1 {
q.Set("page", strconv.Itoa(page))
}
if kw := strings.TrimSpace(keyword); kw != "" {
q.Set("keyword", kw)
}
if t := strings.TrimSpace(tag); t != "" {
q.Set("tag", t)
}
if a := strings.TrimSpace(author); a != "" {
q.Set("author", a)
}
if titleOnly {
q.Set("title_only", "1")
}
path := "/"
if boardID > 0 {
path = fmt.Sprintf("/board/%d%s", boardID, permalinkSuffix)
}
if enc := q.Encode(); enc != "" {
return path + "?" + enc
}
return path
}
// Home 首页 / 板块
func (d Deps) Home(c *gin.Context) {
ctx := d.ctx(c)
sort := normalizeSort(c.DefaultQuery("sort", "latest"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := d.Settings.PageSizeDefault()
keyword := strings.TrimSpace(c.Query("keyword"))
tag := strings.TrimSpace(c.Query("tag"))
author := strings.TrimSpace(c.Query("author"))
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
hasSearch := keyword != "" || tag != "" || author != ""
var boardID uint
var boardName string
if idStr := c.Param("id"); idStr != "" {
idStr = stripIDParam(idStr, d.Settings.Permalink().Ext)
if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
boardID = uint(n)
}
if boardID > 0 {
match := d.permalink().MatchBoardPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
}
}
chrome := d.chrome(ctx, "", "", "home/feed")
chrome.ActiveBoard = boardID
for _, b := range chrome.Boards {
if b.ID == boardID {
boardName = b.Name
break
}
}
if boardName != "" {
chrome.Title = boardName + " · " + chrome.SiteName
}
feedStyle := d.Settings.FeedListStyle()
showExcerpt := feedStyle == "excerpt" || feedStyle == "thumbnail"
data := HomePageData{
PageChrome: chrome,
BoardName: boardName,
Sort: sort,
Keyword: keyword,
Tag: tag,
Author: author,
TitleOnly: titleOnly,
HasSearch: hasSearch,
FeedListStyle: feedStyle,
ShowExcerpt: showExcerpt,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
}
listKW := keyword
if keyword != "" {
kw, err := d.Settings.NormalizeSearchKeyword(keyword)
if err != nil {
data.SearchError = err.Error()
data.Posts = []PostListItem{}
ctx.HTML(http.StatusOK, "home", data)
return
}
listKW = kw
data.Keyword = kw
}
items, total, err := d.Post.ListItems(services.PostListQuery{
BoardID: boardID,
Page: page,
Size: size,
Sort: sort,
Keyword: listKW,
Tag: tag,
Author: author,
TitleOnly: titleOnly,
ViewerID: ctx.UserID(),
ViewerIsAdmin: ctx.IsAdmin(),
})
if err != nil {
if errors.Is(err, services.ErrSearchKeywordTooShort) || errors.Is(err, services.ErrSearchKeywordTooLong) {
data.SearchError = err.Error()
data.Posts = []PostListItem{}
ctx.HTML(http.StatusOK, "home", data)
return
}
c.String(http.StatusInternalServerError, "加载帖子失败")
return
}
posts := make([]PostListItem, 0, len(items))
pl := d.permalink()
for _, it := range items {
authorName := strings.TrimSpace(it.User.Nickname)
if authorName == "" {
authorName = it.User.Username
}
bname := ""
boardHref := ""
boardSlot := 0
if it.Board.ID > 0 {
bname = it.Board.Name
boardHref = pl.BoardPath(it.Board.ID)
boardSlot = it.Board.ColorIndex
if boardSlot < 0 {
boardSlot = int(it.Board.ID % 8)
}
boardSlot = boardSlot % 8
}
excerptSrc := ""
thumbURL := ""
if showExcerpt {
excerptSrc = strings.TrimSpace(it.ContentPlain)
if excerptSrc == "" {
excerptSrc = services.ExcerptFromHTML(it.Content, 60)
} else {
excerptSrc = services.TruncateRunes(excerptSrc, 60)
}
}
if feedStyle == "thumbnail" {
thumbURL = services.FirstImageURL(it.Content)
}
avatar := strings.TrimSpace(it.User.Avatar)
authorID := it.UserID
authorHref := ""
if authorID > 0 {
authorHref = pl.UserPath(authorID)
}
createdLabel := formatRelativeTime(it.CreatedAt)
if sort == "reply" && it.LastReplyAt == nil {
createdLabel = "暂无回复"
}
lastReplyName := ""
lastReplyHref := ""
lastReplyLabel := ""
showLast := false
if it.LastReplyAt != nil {
showLast = true
lastReplyLabel = formatRelativeTime(*it.LastReplyAt)
if it.LastReplyUser != nil {
lastReplyName = strings.TrimSpace(it.LastReplyUser.Nickname)
if lastReplyName == "" {
lastReplyName = it.LastReplyUser.Username
}
if it.LastReplyUser.ID > 0 {
lastReplyHref = pl.UserPath(it.LastReplyUser.ID)
}
} else {
lastReplyName = strings.TrimSpace(it.LastReplyGuestNick)
}
if lastReplyName == "" {
showLast = false
}
}
posts = append(posts, PostListItem{
ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID),
AuthorID: authorID, AuthorName: authorName, AuthorHref: authorHref,
AvatarURL: avatar, AuthorMark: firstRuneOr(authorName, "姜"),
BoardID: it.BoardID, BoardName: bname, BoardHref: boardHref,
BoardColorSlot: boardSlot, ShowBoardBadge: boardID == 0 && it.Board.ID > 0,
Excerpt: excerptSrc, ThumbURL: thumbURL,
Pinned: it.Pinned, BoardPinned: it.BoardPinned, Featured: it.Featured,
CommentCount: it.CommentCount, LikeCount: it.LikeCount, ViewCount: it.ViewCount,
CreatedLabel: createdLabel,
LastReplyLabel: lastReplyLabel, LastReplyName: lastReplyName,
LastReplyHref: lastReplyHref, ShowLastReply: showLast,
})
}
data.Posts = posts
data.HasMore = int64(page*size) < total
ctx.HTML(http.StatusOK, "home", data)
}
func normalizeSort(s string) string {
switch s {
case "reply", "hot":
return s
default:
return "latest"
}
}
func formatTime(t time.Time) string {
return t.Local().Format("2006-01-02 15:04")
}
// formatRelativeTime 列表用相对时间
func formatRelativeTime(t time.Time) string {
if t.IsZero() {
return ""
}
now := time.Now()
d := now.Sub(t)
if d < 0 {
d = 0
}
switch {
case d < time.Minute:
return "刚刚"
case d < time.Hour:
return fmt.Sprintf("%d 分钟前", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d 小时前", int(d.Hours()))
case d < 48*time.Hour:
return "昨天"
case d < 30*24*time.Hour:
return fmt.Sprintf("%d 天前", int(d.Hours()/24))
default:
return t.Local().Format("2006-01-02")
}
}

255
routers/web/links.go Normal file
View File

@@ -0,0 +1,255 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type linksLinkView struct {
Name string
URL string
Logo string
}
type linksApplyView struct {
ID uint
Name string
URL string
Logo string
Status string
StatusLabel string
ReviewNote string
CreatedAt string
CanCancel bool
}
type linksPageData struct {
PageChrome
Links []linksLinkView
MyApplies []linksApplyView
FormName string
FormURL string
FormLogo string
FormRecip string
FormOnHome bool
AvatarMaxMB int
}
func friendLinkApplyStatusLabel(status string) string {
switch status {
case models.FriendLinkApplyStatusPending:
return "待审核"
case models.FriendLinkApplyStatusApproved:
return "已通过"
case models.FriendLinkApplyStatusRejected:
return "已拒绝"
default:
return status
}
}
func (d Deps) publicBaseURL(c *gin.Context) string {
proto := c.GetHeader("X-Forwarded-Proto")
if proto == "" {
if c.Request.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
host := c.GetHeader("X-Forwarded-Host")
if host == "" {
host = c.Request.Host
}
origin := ""
if host != "" {
origin = proto + "://" + host
}
return d.Settings.SitePublicBaseURL(origin)
}
// LinksGet 友链公开页
func (d Deps) LinksGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderLinks(ctx, c, "")
}
func (d Deps) renderLinks(ctx *webctx.Context, c *gin.Context, errMsg string) {
brand := d.Settings.SiteBranding()
chrome := d.chrome(ctx, "友情链接 · "+brand.Name, "", "")
chrome.Error = errMsg
data := linksPageData{
PageChrome: chrome,
FormOnHome: true,
AvatarMaxMB: d.Settings.AvatarMaxMB(),
}
for _, l := range brand.FriendLinks {
data.Links = append(data.Links, linksLinkView{Name: l.Name, URL: l.URL, Logo: l.Logo})
}
if ctx.IsSigned() && d.FriendLink != nil {
rows, _ := d.FriendLink.ListMine(ctx.UserID())
data.MyApplies = make([]linksApplyView, 0, len(rows))
for _, a := range rows {
data.MyApplies = append(data.MyApplies, linksApplyView{
ID: a.ID,
Name: a.Name,
URL: a.URL,
Logo: a.Logo,
Status: a.Status,
StatusLabel: friendLinkApplyStatusLabel(a.Status),
ReviewNote: a.ReviewNote,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"),
CanCancel: a.Status == models.FriendLinkApplyStatusPending,
})
}
}
ctx.HTML(http.StatusOK, "links/list", data)
}
func (d Deps) renderLinksForm(ctx *webctx.Context, c *gin.Context, errMsg string, form linksPageData) {
brand := d.Settings.SiteBranding()
chrome := d.chrome(ctx, "友情链接 · "+brand.Name, "", "")
chrome.Error = errMsg
form.PageChrome = chrome
form.AvatarMaxMB = d.Settings.AvatarMaxMB()
for _, l := range brand.FriendLinks {
form.Links = append(form.Links, linksLinkView{Name: l.Name, URL: l.URL, Logo: l.Logo})
}
if ctx.IsSigned() && d.FriendLink != nil {
rows, _ := d.FriendLink.ListMine(ctx.UserID())
form.MyApplies = make([]linksApplyView, 0, len(rows))
for _, a := range rows {
form.MyApplies = append(form.MyApplies, linksApplyView{
ID: a.ID,
Name: a.Name,
URL: a.URL,
Logo: a.Logo,
Status: a.Status,
StatusLabel: friendLinkApplyStatusLabel(a.Status),
ReviewNote: a.ReviewNote,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"),
CanCancel: a.Status == models.FriendLinkApplyStatusPending,
})
}
}
ctx.HTML(http.StatusOK, "links/list", form)
}
// LinksApplyPost 提交友链申请
func (d Deps) LinksApplyPost(c *gin.Context) {
ctx := d.ctx(c)
form := linksPageData{
FormName: strings.TrimSpace(c.PostForm("name")),
FormURL: strings.TrimSpace(c.PostForm("url")),
FormLogo: strings.TrimSpace(c.PostForm("logo")),
FormRecip: strings.TrimSpace(c.PostForm("reciprocal_page_url")),
FormOnHome: c.PostForm("link_on_homepage") == "1" || c.PostForm("link_on_homepage") == "on",
}
if !ctx.CheckCSRF() {
d.renderLinksForm(ctx, c, "无效请求,请重试", form)
return
}
if d.FriendLink == nil {
d.renderLinksForm(ctx, c, "友链服务未就绪", form)
return
}
if d.Limiter != nil && !d.Limiter.Allow("friend_link", fmt.Sprintf("%d", ctx.UserID())) {
d.renderLinksForm(ctx, c, "申请过于频繁,请稍后再试", form)
return
}
logo := form.FormLogo
if file, err := c.FormFile("logo_file"); err == nil && file != nil {
if d.Store == nil {
d.renderLinksForm(ctx, c, "上传存储未就绪", form)
return
}
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, fmt.Sprintf("fl_%d", ctx.UserID()))
if err != nil {
d.renderLinksForm(ctx, c, err.Error(), form)
return
}
logo = url
form.FormLogo = url
}
_, err := d.FriendLink.Create(services.FriendLinkApplyInput{
UserID: ctx.UserID(),
Name: form.FormName,
URL: form.FormURL,
Logo: logo,
LinkOnHomepage: form.FormOnHome,
ReciprocalPageURL: form.FormRecip,
OurSiteURL: d.publicBaseURL(c),
})
if err != nil {
d.renderLinksForm(ctx, c, err.Error(), form)
return
}
ctx.SetFlash("友链申请已提交,请等待审核")
ctx.Redirect("/links")
}
// LinksApplyCancelPost 取消待审申请
func (d Deps) LinksApplyCancelPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderLinks(ctx, c, "无效请求,请重试")
return
}
if d.FriendLink == nil {
d.renderLinks(ctx, c, "友链服务未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := d.FriendLink.Cancel(ctx.UserID(), uint(id64)); err != nil {
if errors.Is(err, services.ErrFriendLinkApplyNotFound) || errors.Is(err, services.ErrFriendLinkApplyHandled) {
ctx.SetFlash(err.Error())
ctx.Redirect("/links")
return
}
d.renderLinks(ctx, c, err.Error())
return
}
ctx.SetFlash("已取消申请")
ctx.Redirect("/links")
}
// LinksLogoUpload Logo 上传(JSON,供表单渐进增强)
func (d Deps) LinksLogoUpload(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.IsSigned() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
if !ctx.CheckCSRF() {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
return
}
if d.Store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"})
return
}
file, err := c.FormFile("logo")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 LOGO 图片"})
return
}
maxBytes := int64(d.Settings.AvatarMaxMB()) * 1024 * 1024
if file.Size > maxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大"})
return
}
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, fmt.Sprintf("fl_%d", ctx.UserID()))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "LOGO 已上传", "url": url})
}

313
routers/web/messages.go Normal file
View File

@@ -0,0 +1,313 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type msgConvRow struct {
PeerID uint
PeerName string
IsSystem bool
Preview string
Unread int64
UpdatedLabel string
}
type messagesListData struct {
PageChrome
Conversations []msgConvRow
UnreadTotal int64
UnreadDM int64
UnreadNotify int64
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
type msgBubble struct {
ID uint
FromSelf bool
IsSystem bool
Subject string
Content string
CreatedLabel string
Kind string
}
type messagesThreadData struct {
PageChrome
PeerID uint
PeerName string
IsSystem bool
CanReply bool
Messages []msgBubble
OlderBefore uint
HasOlder bool
Draft string
KindFilter string
KindLinks []msgKindLink
}
type msgKindLink struct {
Kind string
Label string
URL string
Active bool
}
// MessagesList GET /messages
func (d Deps) MessagesList(c *gin.Context) {
ctx := d.ctx(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := 30
list, total, err := d.Message.ListConversations(services.ConversationListQuery{
UserID: ctx.UserID(), Page: page, Size: size,
})
if err != nil {
chrome := d.chrome(ctx, "私信 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = err.Error()
ctx.HTML(http.StatusOK, "messages/list", messagesListData{PageChrome: chrome})
return
}
rows := make([]msgConvRow, 0, len(list))
for _, cv := range list {
name := "系统通知"
if !cv.IsSystem {
name = "用户"
if cv.PeerUser != nil {
name = displayName(cv.PeerUser)
}
}
preview := ""
if cv.LastMessage != nil {
preview = strings.TrimSpace(cv.LastMessage.Subject)
if preview == "" {
preview = truncateRunes(cv.LastMessage.Content, 48)
}
}
rows = append(rows, msgConvRow{
PeerID: cv.PeerUserID, PeerName: name, IsSystem: cv.IsSystem,
Preview: preview, Unread: cv.UnreadCount,
UpdatedLabel: formatTime(cv.UpdatedAt),
})
}
totalUnread, dm, notify, _ := d.Message.UnreadCounts(ctx.UserID())
chrome := d.chrome(ctx, "私信 · "+d.Settings.SiteBranding().Name, "", "")
hasMore := int64(page*size) < total
ctx.HTML(http.StatusOK, "messages/list", messagesListData{
PageChrome: chrome, Conversations: rows,
UnreadTotal: totalUnread, UnreadDM: dm, UnreadNotify: notify,
Page: page, PrevPage: page - 1, NextPage: page + 1,
HasPrev: page > 1, HasMore: hasMore,
})
}
// MessagesThread GET /messages/with/:peerId
func (d Deps) MessagesThread(c *gin.Context) {
peerID, err := parsePeerID(c.Param("peerId"))
if err != nil {
ctx := d.ctx(c)
ctx.SetFlash("无效会话")
ctx.Redirect("/messages")
return
}
d.renderMessagesThread(c, peerID, "", c.Query("before"), "")
}
func (d Deps) renderMessagesThread(c *gin.Context, peerID uint, errMsg, beforeRaw, draft string) {
ctx := d.ctx(c)
uid := ctx.UserID()
before, _ := strconv.ParseUint(beforeRaw, 10, 64)
size := 40
kindFilter := ""
if peerID == 0 {
kindFilter = normalizeNotifyKind(c.Query("kind"))
}
var list []models.PrivateMessage
var total int64
var err error
if peerID == 0 && kindFilter != "" && kindFilter != "all" {
list, total, err = d.Message.ListNotifications(uid, 1, size, kindFilter)
_ = before // kind 筛选暂不分页 before
} else {
list, total, err = d.Message.ListConversationMessages(services.ConversationMessagesQuery{
UserID: uid, PeerID: peerID, Page: 1, Size: size, Before: uint(before),
})
}
if err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect("/messages")
return
}
if before == 0 {
_ = d.Message.MarkConversationRead(uid, peerID)
}
peerName := "系统通知"
canReply := peerID > 0
if peerID > 0 {
if u, e := d.User.GetByID(peerID); e == nil {
peerName = displayName(u)
} else {
peerName = fmt.Sprintf("用户 #%d", peerID)
}
}
bubbles := make([]msgBubble, 0, len(list))
var oldest uint
for _, m := range list {
if oldest == 0 || m.ID < oldest {
oldest = m.ID
}
bubbles = append(bubbles, msgBubble{
ID: m.ID, FromSelf: m.FromUserID == uid && m.FromUserID > 0,
IsSystem: m.FromUserID == 0, Subject: m.Subject, Content: m.Content,
CreatedLabel: formatTime(m.CreatedAt), Kind: m.Kind,
})
}
hasOlder := false
if peerID != 0 || kindFilter == "" || kindFilter == "all" {
if oldest > 0 {
if before == 0 {
hasOlder = total > int64(len(list))
} else {
hasOlder = len(list) >= size
}
}
} else {
hasOlder = false
}
chrome := d.chrome(ctx, peerName+" · 私信 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
data := messagesThreadData{
PageChrome: chrome, PeerID: peerID, PeerName: peerName,
IsSystem: peerID == 0, CanReply: canReply, Messages: bubbles,
OlderBefore: oldest, HasOlder: hasOlder, Draft: draft,
}
if peerID == 0 {
data.KindFilter = kindFilter
data.KindLinks = notifyKindLinks(kindFilter)
}
ctx.HTML(http.StatusOK, "messages/thread", data)
}
func normalizeNotifyKind(raw string) string {
k := strings.TrimSpace(strings.ToLower(raw))
switch k {
case "", "all":
return "all"
case models.MessageKindReply, models.MessageKindMention, models.MessageKindSystem,
models.MessageKindReject, models.MessageKindReportResult, models.MessageKindModeration:
return k
default:
return "all"
}
}
func notifyKindLinks(active string) []msgKindLink {
if active == "" {
active = "all"
}
items := []struct{ kind, label string }{
{"all", "全部"},
{models.MessageKindReply, "回复"},
{models.MessageKindMention, "提及"},
{models.MessageKindSystem, "系统"},
{models.MessageKindReject, "拒绝"},
{models.MessageKindReportResult, "举报结果"},
{models.MessageKindModeration, "审核"},
}
out := make([]msgKindLink, 0, len(items))
for _, it := range items {
url := "/messages/with/0"
if it.kind != "all" {
url += "?kind=" + it.kind
}
out = append(out, msgKindLink{
Kind: it.kind, Label: it.label, URL: url, Active: active == it.kind,
})
}
return out
}
// MessagesSend POST /messages/with/:peerId
func (d Deps) MessagesSend(c *gin.Context) {
ctx := d.ctx(c)
peerID, err := parsePeerID(c.Param("peerId"))
if err != nil || peerID == 0 {
ctx.SetFlash("无法向系统通知会话发送私信")
ctx.Redirect("/messages")
return
}
content := strings.TrimSpace(c.PostForm("content"))
if !ctx.CheckCSRF() {
d.renderMessagesThread(c, peerID, "无效请求,请重试", "", content)
return
}
if d.Limiter != nil && !d.Limiter.Allow("message", fmt.Sprintf("%d", ctx.UserID())) {
d.renderMessagesThread(c, peerID, "发送过于频繁,请稍后再试", "", content)
return
}
if ctx.Doer != nil && ctx.Doer.Banned {
d.renderMessagesThread(c, peerID, "账号已被禁言,无法发私信", "", content)
return
}
_, err = d.Message.Send(services.MessageSendInput{
FromUserID: ctx.UserID(),
ToUserID: peerID,
Content: content,
})
if err != nil {
d.renderMessagesThread(c, peerID, err.Error(), "", content)
return
}
ctx.SetFlash("已发送")
ctx.Redirect(fmt.Sprintf("/messages/with/%d", peerID))
}
// MessagesReadAll POST /messages/read-all
func (d Deps) MessagesReadAll(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect("/messages")
return
}
_ = d.Message.MarkAllRead(ctx.UserID())
ctx.SetFlash("已全部标为已读")
ctx.Redirect("/messages")
}
func parsePeerID(raw string) (uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "system" {
return 0, nil
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}
func truncateRunes(s string, max int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= max {
return string(r)
}
return string(r[:max]) + "…"
}

44
routers/web/page.go Normal file
View File

@@ -0,0 +1,44 @@
package web
import (
"errors"
"html/template"
"net/http"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type sitePageViewData struct {
PageChrome
PageTitle string
Slug string
BodyHTML template.HTML
}
// SitePageGet 公开站点单页
func (d Deps) SitePageGet(c *gin.Context) {
ctx := d.ctx(c)
if d.SitePage == nil {
d.render404(ctx)
return
}
slug := c.Param("slug")
page, err := d.SitePage.GetBySlug(slug, false)
if err != nil {
if errors.Is(err, services.ErrSitePageNotFound) {
d.render404(ctx)
return
}
c.String(http.StatusInternalServerError, "加载失败")
return
}
html := services.SanitizePostHTML(page.Content)
chrome := d.chrome(ctx, page.Title+" · "+d.Settings.SiteBranding().Name, "", "")
ctx.HTML(http.StatusOK, "page/view", sitePageViewData{
PageChrome: chrome,
PageTitle: page.Title,
Slug: page.Slug,
BodyHTML: template.HTML(html),
})
}

35
routers/web/pending.go Normal file
View File

@@ -0,0 +1,35 @@
package web
import (
"net/http"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"github.com/gin-gonic/gin"
)
type pendingData struct {
PageChrome
Heading string
Message string
}
// PendingPage 未迁移页
func (d Deps) PendingPage(c *gin.Context) {
ctx := d.ctx(c)
chrome := d.chrome(ctx, "页面准备中 · "+d.Settings.SiteBranding().Name, "", "")
ctx.HTML(http.StatusOK, "status/pending", pendingData{
PageChrome: chrome,
Heading: "页面准备中",
Message: "该功能尚未用模板实现,请稍后再来。",
})
}
func (d Deps) render404(ctx *webctx.Context) {
chrome := d.chrome(ctx, "页面不存在 · "+d.Settings.SiteBranding().Name, "", "")
ctx.HTML(http.StatusNotFound, "status/404", chrome)
}
// NotFound NoRoute
func (d Deps) NotFound(c *gin.Context) {
d.render404(d.ctx(c))
}

1039
routers/web/post.go Normal file

File diff suppressed because it is too large Load Diff

337
routers/web/profile.go Normal file
View File

@@ -0,0 +1,337 @@
package web
import (
"errors"
"fmt"
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
const profileLedgerLimit = 15
type profileLedgerItem struct {
Delta int
Balance int
ReasonLabel string
Note string
CreatedAt string
}
type profileData struct {
PageChrome
UserID uint
Username string
Nickname string
Signature string
Avatar string
Email string
Level int
Exp int
Points int
Badges []userPublicBadge
PostCount int64
CommentCount int64
FavoriteCount int64
LikeReceived int64
PublicURL string
AvatarMaxMB int
SignatureMax int
CheckIn services.CheckInStatus
Lottery services.LotteryStatus
Ledger []profileLedgerItem
}
func pointReasonLabel(reason string) string {
switch reason {
case models.PointReasonCheckIn:
return "签到"
case models.PointReasonLottery:
return "每日抽奖"
case models.PointReasonUnlockSpend:
return "解锁内容"
case models.PointReasonCreatorIncome:
return "创作分成"
case models.PointReasonAdminAdjust:
return "站长调整"
case models.PointReasonBountyEscrow:
return "悬赏托管"
case models.PointReasonBountyAward:
return "悬赏奖励"
case models.PointReasonBountyRefund:
return "悬赏退款"
default:
if reason == "" {
return "其他"
}
return reason
}
}
// ProfileGet 个人中心
func (d Deps) ProfileGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderProfile(ctx, "")
}
func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) {
uid := ctx.UserID()
user, err := d.User.GetByID(uid)
if err != nil {
ctx.SetFlash("用户不存在")
ctx.Redirect("/")
return
}
st, _ := d.User.ActivityStats(uid)
chrome := d.chrome(ctx, "个人中心 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
chrome.ViewerPoints = user.Points
nick := strings.TrimSpace(user.Nickname)
if nick == "" {
nick = user.Username
}
data := profileData{
PageChrome: chrome,
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Signature: user.Signature,
Avatar: user.Avatar,
Email: user.Email,
Level: models.LevelFromExp(user.Exp),
Exp: user.Exp,
Points: user.Points,
PostCount: st.PostCount,
CommentCount: st.CommentCount,
FavoriteCount: st.FavoriteCount,
LikeReceived: st.LikeReceived,
PublicURL: fmt.Sprintf("/user/%d", user.ID),
AvatarMaxMB: d.Settings.AvatarMaxMB(),
SignatureMax: d.Settings.SignatureMax(),
}
if d.Badge != nil {
_ = d.Badge.EvaluateAuto(uid)
if rows, err := d.Badge.ListUserBadges(uid); err == nil {
for _, v := range services.BadgeViews(rows, 0) {
data.Badges = append(data.Badges, userPublicBadge{Name: v.Name, Description: v.Description})
}
}
}
if d.Points != nil {
data.CheckIn, _ = d.Points.GetCheckInStatus(uid)
data.Lottery, _ = d.Points.GetLotteryStatus(uid)
rows, _, _ := d.Points.ListLedger(uid, 1, profileLedgerLimit)
data.Ledger = make([]profileLedgerItem, 0, len(rows))
for _, row := range rows {
data.Ledger = append(data.Ledger, profileLedgerItem{
Delta: row.Delta,
Balance: row.Balance,
ReasonLabel: pointReasonLabel(row.Reason),
Note: row.Note,
CreatedAt: row.CreatedAt.Format("2006-01-02 15:04"),
})
}
}
// 导航显示最新昵称
data.ViewerName = nick
ctx.HTML(http.StatusOK, "profile/view", data)
}
// ProfileNicknamePost 改昵称
func (d Deps) ProfileNicknamePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
if err := d.User.UpdateNickname(ctx.UserID(), strings.TrimSpace(c.PostForm("nickname"))); err != nil {
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash("昵称已更新")
ctx.Redirect("/profile")
}
// ProfileSignaturePost 改签名
func (d Deps) ProfileSignaturePost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
if err := d.User.UpdateSignature(ctx.UserID(), c.PostForm("signature")); err != nil {
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash("签名已更新")
ctx.Redirect("/profile")
}
// ProfilePasswordPost 改密码:吊销全部 session 后为本端重建
func (d Deps) ProfilePasswordPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
oldPass := c.PostForm("old_password")
newPass := c.PostForm("new_password")
confirm := c.PostForm("new_password2")
if newPass != confirm {
d.renderProfile(ctx, "两次输入的新密码不一致")
return
}
uid := ctx.UserID()
if err := d.User.UpdatePassword(uid, oldPass, newPass); err != nil {
d.renderProfile(ctx, err.Error())
return
}
services.RevokeUserSessions(uid)
user, err := d.User.GetByID(uid)
if err != nil {
ctx.SetFlash("密码已更新,请重新登录")
ctx.Redirect("/login?redirect=/profile")
return
}
sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent())
if err != nil {
ctx.SetFlash("密码已更新,请重新登录")
ctx.Redirect("/login?redirect=/profile")
return
}
ctx.SetLoginCookie(sid)
ctx.SetFlash("密码已更新,其它设备已登出")
ctx.Redirect("/profile")
}
// ProfileAvatarPost 上传头像
func (d Deps) ProfileAvatarPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
file, err := c.FormFile("avatar")
if err != nil {
d.renderProfile(ctx, "请选择头像文件")
return
}
if d.Store == nil {
d.renderProfile(ctx, "上传存储未就绪")
return
}
if _, err := d.User.UploadAvatar(ctx.UserID(), file, d.Store); err != nil {
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash("头像已更新")
ctx.Redirect("/profile")
}
// ProfileCheckInPost 每日签到(PRG)
func (d Deps) ProfileCheckInPost(c *gin.Context) {
ctx := d.ctx(c)
redir := safePointsRedirect(c.PostForm("redirect"))
if !ctx.CheckCSRF() {
if redir != "/profile" {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect(redir)
return
}
d.renderProfile(ctx, "无效请求,请重试")
return
}
if d.Points == nil {
if redir != "/profile" {
ctx.SetFlash("积分服务未就绪")
ctx.Redirect(redir)
return
}
d.renderProfile(ctx, "积分服务未就绪")
return
}
st, err := d.Points.CheckIn(ctx.UserID())
if err != nil {
if errors.Is(err, services.ErrAlreadyCheckedIn) {
ctx.SetFlash("今日已签到")
ctx.Redirect(redir)
return
}
if redir != "/profile" {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("签到成功:连续 %d 天,获得 %d 积分", st.Streak, st.TodayPoints))
ctx.Redirect(redir)
}
// ProfileLotteryPost 每日抽奖(PRG)
func (d Deps) ProfileLotteryPost(c *gin.Context) {
ctx := d.ctx(c)
redir := safePointsRedirect(c.PostForm("redirect"))
if !ctx.CheckCSRF() {
if redir != "/profile" {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect(redir)
return
}
d.renderProfile(ctx, "无效请求,请重试")
return
}
if d.Points == nil {
if redir != "/profile" {
ctx.SetFlash("积分服务未就绪")
ctx.Redirect(redir)
return
}
d.renderProfile(ctx, "积分服务未就绪")
return
}
st, err := d.Points.DrawLottery(ctx.UserID())
if err != nil {
if errors.Is(err, services.ErrAlreadyLottery) {
ctx.SetFlash("今日已抽奖")
ctx.Redirect(redir)
return
}
if redir != "/profile" {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
d.renderProfile(ctx, err.Error())
return
}
if st.Points > 0 {
ctx.SetFlash(fmt.Sprintf("抽奖结果:获得 %d 积分", st.Points))
} else {
ctx.SetFlash("抽奖结果:未中奖,明天再来")
}
ctx.Redirect(redir)
}
// safePointsRedirect 签到/抽奖 PRG 回跳:仅允许 /、/board/…、/post/…;空或非法则 /profile
func safePointsRedirect(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.Contains(raw, "://") || strings.HasPrefix(raw, "//") || strings.Contains(raw, "..") {
return "/profile"
}
if raw == "/" {
return raw
}
if strings.HasPrefix(raw, "/board/") || strings.HasPrefix(raw, "/post/") {
if strings.ContainsAny(raw, "?#") {
return "/profile"
}
return raw
}
return "/profile"
}

72
routers/web/report.go Normal file
View File

@@ -0,0 +1,72 @@
package web
import (
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// PostReportPost 举报帖子
func (d Deps) PostReportPost(c *gin.Context) {
ctx := d.ctx(c)
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
redir := fmt.Sprintf("/post/%d", postID)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect(redir)
return
}
if d.Report == nil {
ctx.SetFlash("举报服务未就绪")
ctx.Redirect(redir)
return
}
if d.Limiter != nil && !d.Limiter.Allow("report", fmt.Sprintf("%d", ctx.UserID())) {
ctx.SetFlash("举报过于频繁,请稍后再试")
ctx.Redirect(redir)
return
}
reason := strings.TrimSpace(c.PostForm("reason"))
detail := strings.TrimSpace(c.PostForm("detail"))
if _, err := d.Report.Create(ctx.UserID(), uint(postID), reason, detail); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
ctx.SetFlash("举报已提交,感谢反馈")
ctx.Redirect(redir)
}
// CommentReportPost 举报评论
func (d Deps) CommentReportPost(c *gin.Context) {
ctx := d.ctx(c)
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
cid, _ := strconv.ParseUint(c.Param("cid"), 10, 64)
redir := fmt.Sprintf("/post/%d", postID)
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect(redir)
return
}
if d.Report == nil {
ctx.SetFlash("举报服务未就绪")
ctx.Redirect(redir)
return
}
if d.Limiter != nil && !d.Limiter.Allow("report", fmt.Sprintf("%d", ctx.UserID())) {
ctx.SetFlash("举报过于频繁,请稍后再试")
ctx.Redirect(redir)
return
}
reason := strings.TrimSpace(c.PostForm("reason"))
detail := strings.TrimSpace(c.PostForm("detail"))
if _, err := d.Report.CreateCommentReport(ctx.UserID(), uint(cid), reason, detail); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(redir)
return
}
ctx.SetFlash("举报已提交,感谢反馈")
ctx.Redirect(redir)
}

128
routers/web/user.go Normal file
View File

@@ -0,0 +1,128 @@
package web
import (
"net/http"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type userPublicBadge struct {
Name string
Description string
}
type userPublicData struct {
PageChrome
UserID uint
Username string
Nickname string
Signature string
Avatar string
Level int
Exp int
Points int
Badges []userPublicBadge
PostCount int64
CommentCount int64
FavoriteCount int64
LikeReceived int64
IsSelf bool
Posts []PostListItem
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
// UserPublic 公开用户主页(不含邮箱)
func (d Deps) UserPublic(c *gin.Context) {
ctx := d.ctx(c)
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
d.render404(ctx)
return
}
user, err := d.User.GetByID(uint(id))
if err != nil {
d.render404(ctx)
return
}
match := d.permalink().MatchUserPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
st, _ := d.User.ActivityStats(user.ID)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := d.Settings.PageSizeDefault()
posts, total, _ := d.Post.List(services.PostListQuery{
UserID: user.ID,
Page: page,
Size: size,
Sort: "latest",
ViewerID: ctx.UserID(),
ViewerIsAdmin: ctx.IsAdmin(),
})
items := make([]PostListItem, 0, len(posts))
pl := d.permalink()
for _, p := range posts {
board := ""
if p.Board.ID > 0 {
board = p.Board.Name
}
items = append(items, PostListItem{
ID: p.ID, Title: p.Title, Href: pl.PostPath(p.ID), AuthorName: displayName(user),
BoardName: board, Pinned: p.Pinned || p.BoardPinned, Featured: p.Featured,
CreatedLabel: formatTime(p.CreatedAt),
})
}
nick := displayName(user)
chrome := d.chrome(ctx, nick+" · "+d.Settings.SiteBranding().Name, strings.TrimSpace(user.Signature), "")
hasMore := int64(page*size) < total
badges := []userPublicBadge{}
if d.Badge != nil {
_ = d.Badge.EvaluateAuto(user.ID)
if rows, err := d.Badge.ListUserBadges(user.ID); err == nil {
for _, v := range services.BadgeViews(rows, 0) {
badges = append(badges, userPublicBadge{Name: v.Name, Description: v.Description})
}
}
}
ctx.HTML(http.StatusOK, "user/view", userPublicData{
PageChrome: chrome,
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Signature: user.Signature,
Avatar: user.Avatar,
Level: models.LevelFromExp(user.Exp),
Exp: user.Exp,
Points: user.Points,
Badges: badges,
PostCount: st.PostCount, CommentCount: st.CommentCount,
FavoriteCount: st.FavoriteCount, LikeReceived: st.LikeReceived,
IsSelf: ctx.UserID() == user.ID,
Posts: items,
Page: page, PrevPage: page - 1, NextPage: page + 1,
HasPrev: page > 1, HasMore: hasMore,
})
}
func displayName(u *models.User) string {
if u == nil {
return ""
}
n := strings.TrimSpace(u.Nickname)
if n == "" {
return u.Username
}
return n
}

View File

@@ -0,0 +1,37 @@
package services
import "testing"
func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
in := []AsideWidget{
{ID: AsideWidgetFriendLinks, Enabled: true},
{ID: AsideWidgetTagCloud, Enabled: true},
{ID: AsideWidgetRecentComments, Enabled: false},
}
out := NormalizeAsideWidgets(in)
if len(out) != 4 {
t.Fatalf("want 4 widgets, got %d", len(out))
}
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers}
for i, id := range want {
if out[i].ID != id {
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
}
}
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled {
t.Fatalf("enabled flags mismatch: %+v", out)
}
}
func TestAsideBoolsFromWidgets(t *testing.T) {
widgets := []AsideWidget{
{ID: AsideWidgetRecentComments, Enabled: true},
{ID: AsideWidgetFriendLinks, Enabled: false},
{ID: AsideWidgetTagCloud, Enabled: true},
{ID: AsideWidgetRecentUsers, Enabled: true},
}
bools := asideBoolsFromWidgets(widgets)
if !bools.tagCloud || !bools.recentComments || bools.friendLinks {
t.Fatalf("unexpected bools: %+v", bools)
}
}

174
services/auth.go Normal file
View File

@@ -0,0 +1,174 @@
package services
import (
"sync"
"time"
"git.iioio.com/freefire/jiang13-forum/models"
)
// 最近访问写入节流,避免每次请求都打库
const lastAccessTouchInterval = 5 * time.Minute
var lastAccessTouchCache sync.Map // userID(uint) -> time.Time
// SessionTTL 与浏览器会话对齐(兼容旧常量名)
const TokenExpire = SessionTTL
type AuthService struct {
hmacSecret string // CSRF 等 HMAC;不再用于浏览器登录 JWT
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewAuthService(hmacSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
return &AuthService{hmacSecret: hmacSecret, filter: filter, settings: settings}
}
// HMACSecret 供 CSRF 等使用
func (s *AuthService) HMACSecret() string { return s.hmacSecret }
// UserCount 当前用户数
func (s *AuthService) UserCount() int64 {
var n int64
models.DB.Model(&models.User{}).Count(&n)
return n
}
// Register 用户注册
func (s *AuthService) Register(username, password, nickname, email string) (*models.User, error) {
if err := ValidateUsername(username); err != nil {
return nil, err
}
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
return nil, err
}
email = NormalizeEmail(email)
if err := ValidateEmail(email); err != nil {
return nil, err
}
var exist models.User
if err := models.DB.Where("username = ?", username).First(&exist).Error; err == nil {
return nil, ErrUserExists
}
if err := models.DB.Where("email = ?", email).First(&exist).Error; err == nil {
return nil, ErrEmailExists
}
hash, err := HashPassword(password)
if err != nil {
return nil, err
}
if nickname == "" {
nickname = username
}
nickname = s.filter.Filter(nickname)
user := &models.User{
Username: username,
Email: email,
Password: hash,
Nickname: nickname,
Role: models.RoleUser,
}
if err := models.DB.Create(user).Error; err != nil {
return nil, err
}
return user, nil
}
// CreateAdmin 安装向导创建管理员(仅应在未安装时调用)
func (s *AuthService) CreateAdmin(username, password, nickname, email string) (*models.User, error) {
if err := ValidateUsername(username); err != nil {
return nil, err
}
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
return nil, err
}
email = NormalizeEmail(email)
if err := ValidateEmail(email); err != nil {
return nil, err
}
if nickname == "" {
nickname = username
}
nickname = s.filter.Filter(nickname)
hash, err := HashPassword(password)
if err != nil {
return nil, err
}
user := &models.User{
Username: username,
Email: email,
Password: hash,
Nickname: nickname,
Role: models.RoleAdmin,
Verified: true,
}
if err := models.DB.Create(user).Error; err != nil {
return nil, err
}
return user, nil
}
// Login 校验密码并创建会话,返回 session id
func (s *AuthService) Login(username, password, clientIP, userAgent string) (sessionID string, user *models.User, err error) {
var u models.User
if err := models.DB.Where("username = ?", username).First(&u).Error; err != nil {
return "", nil, ErrInvalidCred
}
if u.Banned {
return "", nil, ErrUserBanned
}
if !CheckPassword(u.Password, password) {
return "", nil, ErrInvalidCred
}
s.recordLogin(&u, clientIP)
sid, err := CreateSession(u.ID, clientIP, userAgent)
if err != nil {
return "", nil, err
}
return sid, &u, nil
}
// CreateSessionForUser 已认证用户直接建会话(注册后自动登录)
func (s *AuthService) CreateSessionForUser(user *models.User, clientIP, userAgent string) (string, error) {
if user == nil {
return "", ErrInvalidCred
}
s.recordLogin(user, clientIP)
return CreateSession(user.ID, clientIP, userAgent)
}
func (s *AuthService) recordLogin(user *models.User, clientIP string) {
now := time.Now()
ip := clientIP
if len(ip) > 45 {
ip = ip[:45]
}
_ = models.DB.Model(user).Updates(map[string]interface{}{
"last_login_at": now,
"last_login_ip": ip,
"last_access_at": now,
}).Error
user.LastLoginAt = &now
user.LastLoginIP = ip
user.LastAccessAt = &now
lastAccessTouchCache.Store(user.ID, now)
}
// TouchLastAccess 记录最近访问时间(节流写入,失败忽略)
func (s *AuthService) TouchLastAccess(userID uint) {
if userID == 0 {
return
}
now := time.Now()
if v, ok := lastAccessTouchCache.Load(userID); ok {
if t, ok := v.(time.Time); ok && now.Sub(t) < lastAccessTouchInterval {
return
}
}
lastAccessTouchCache.Store(userID, now)
_ = models.DB.Model(&models.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
}

45
services/backup.go Normal file
View File

@@ -0,0 +1,45 @@
package services
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
"git.iioio.com/freefire/jiang13-forum/models"
)
type BackupService struct {
dbPath string
dataDir string
}
func NewBackupService(dbPath, dataDir string) *BackupService {
return &BackupService{dbPath: dbPath, dataDir: dataDir}
}
// ExportSQLite 导出 SQLite 备份文件到 data 目录(仅 sqlite)
func (s *BackupService) ExportSQLite() (string, error) {
if models.DialectorName() != "sqlite" {
return "", fmt.Errorf("一键文件备份仅支持 SQLite;当前为 %s,请使用数据库自带备份工具", models.DialectorName())
}
src, err := os.Open(s.dbPath)
if err != nil {
return "", fmt.Errorf("打开数据库失败: %w", err)
}
defer src.Close()
filename := fmt.Sprintf("jiang13_backup_%s.db", time.Now().Format("20060102_150405"))
destPath := filepath.Join(s.dataDir, filename)
dst, err := os.Create(destPath)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", err
}
return destPath, nil
}

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