Compare commits

160 Commits
v0.1.0 ... main

Author SHA1 Message Date
d7ba7799fe docs: 添加项目迁移声明,指向新仓库 MCE 2026-06-21 16:59:51 +08:00
8bf6ac9e51 feat: @提及发送通知 + 通知点击定位高亮
- 新增 NotifyCommentMention 通知类型,归属 mention 分类 TAB
- parseMentions 中向被@用户发送通知(排除自己和重复通知)
- 消息中心 mention 通知可点击跳转至文章页面
- 通过 mention 通知进入时,来源评论置顶并持久半透明高亮
- 普通评论链接保持原有短暂高亮行为不变
2026-06-03 13:21:27 +08:00
2ea9788ec7 fix: 关注状态 API 添加 Optional 认证中间件
- /api/users/:uid/follow-status 等公开 GET 接口缺少 Optional 认证
- 导致 GetGinUser 无法获取当前用户 uid,始终返回 i_follow=false
- 关注按钮加载时无法显示正确初始状态(始终显示"关注")
- 三条 GET 接口统一加入 authMdw.Optional() 组
- 不影响未登录用户(未登录依然返回 false)
2026-06-03 13:11:56 +08:00
be729c29a1 fix: 关注通知添加用户空间链接跳转
- 消息中心关注通知卡片 now 可点击跳转至关注者空间 (/space/:uid)
2026-06-03 13:06:44 +08:00
584d7fd146 fix: 修复 /space 关注按钮在默认标签页无法点击
- 将顶部关注按钮 JS 从 following/followers 标签页限定块中提取到全局脚本
- 按钮在所有标签页均可点击(之前仅在 following/followers 标签页有效)
- 本人点击关注时 toast 提示"不能关注自己",与赋能按钮行为一致
- 未登录用户点击关注跳转登录页
- 移除标签页限定块中的重复顶部按钮代码
2026-06-03 13:00:12 +08:00
6c9a4414cb refactor: 全局移除浏览器 alert 弹窗,统一使用页面内 showToast 通知
- 替换全部 11 个文件中 36 处 alert() 为 showToast()
- API 错误/异常使用 showToast(msg, 'error'),3秒自动消失
- 表单校验提示使用 showToast(msg, 'warning'),3秒自动消失
- 成功确认使用 showToast(msg, 'success'),3秒自动消失
- 移除 post-energize.js 中与全局 showToast 冲突的本地实现
- 删除 posts.css 中已失去引用的 .energize-toast 样式
- 保留 showConfirm() 自定义确认弹窗(需用户确认的操作)
2026-06-03 01:25:12 +08:00
864a488ee0 feat: @搜索优先展示已关注用户,结果限制最多5条
- SearchUsersByLevel SQL新增LEFT JOIN user_follows,按已关注优先+exp降序
- SearchUsers/followerUID参数贯穿repo→service→controller
- 继续输入时前端自动重新搜索新关键词
2026-06-03 01:15:38 +08:00
fb9724faed fix: space关注按钮移除隐藏逻辑,自关注改为toast提示
- 自己的space也显示关注按钮,不再隐藏
- 点击关注自己时toast'不能关注自己',不发请求
- 自己的space下,发消息/更多按钮保持隐藏
2026-06-03 01:13:01 +08:00
58aabb9bf0 fix: 临时登录cookie改用maxAge=IdleTimeout替代session模式
- rememberMe=false时maxAge从0改为IdleTimeout秒数(默认2h),与Redis TTL一致
- 解决服务器重启后部分浏览器(尤其隐私窗口)将session cookie视为失效的问题
- 关浏览器后依然自动清除(maxAge到期 + Redis TTL双保险)
2026-06-03 01:07:47 +08:00
ae15025b1d feat: @提及支持改名后链接跟随UID + 显示名自动更新
- comment_mentions新增original_username列,创建时记录原始@用户名
- Mentions map改为{original_username: {uid, name}}结构,name为当前显示名
- 用户改名后:旧@文本仍能匹配original_username,链接跟随uid不变,显示名自动更新
- 前端renderMentions适配新结构
2026-06-03 01:00:44 +08:00
be8ce04334 fix: 修复@提及uid始终为0的BUG + 空@提示优化
- SearchUsersByLevel SQL id列别名改为uid,修复GORM字段名映射导致uid=0
- @空输入时悬浮窗显示'输入以查找用户'而非空白
- 清理历史脏数据(uid=0的mention记录)
2026-06-03 00:55:51 +08:00
4ac4f64f33 feat: @提及功能优化 + 默认头像改用SVG图标
- 评论区有效@提及渲染为蓝色链接跳转/space/{uid},无效@保持纯文本
- 输入@触发悬浮窗,根据输入实时搜索用户(含头像+等级)
- 无匹配时显示未搜索到用户,选中后插入为@用户名 格式(尾部空格)
- 悬浮窗定位于输入光标上方,不遮挡输入内容
- 后端Comment新增Mentions字段,批量加载提及映射供前端判断
- SearchUsers/parseMentions 统一限制LV2+用户
- 所有默认头像由首字符文字改为统一SVG人形图标(nav/home/space/mention等7处)
2026-06-03 00:49:24 +08:00
2d05da6709 fix: 赋能弹窗已满时不再弹出 + API 补上 post_energize_total 字段
- 赋能按钮点击时先查已赋能总量,>=20 直接 showToast 不弹窗
- 修复控制响应缺失 post_energize_total 字段导致前端永远走到 showOverlay
- fetch 失败/.catch 改为 showToast 而非 showOverlay
2026-06-03 00:23:35 +08:00
1af9fba291 feat: 赋能溢出截断 + 前端按已赋能量禁用按钮
- 单篇赋能上限由硬拒绝改为溢出截断,类似每日经验处理
- 前端弹窗时查询文章已赋能总量,>=10 禁用重赋,>=20 全部禁用
- GetEnergyInfo 支持 ?post_id= 返回 post_energize_total
- 文案精简为'赋能成功!+N 经验'等短句
2026-06-03 00:13:03 +08:00
4442da31c3 fix: 收藏夹弹窗优化 — 已收藏标识 + 移除确认 + 点击可靠性
- 收藏夹选项中已收藏项显示黄色'已收藏'徽章,一目了然
- 点击判断改用 classList.contains('active') 替代 JS 变量,防 currentFolderId 为空
- addToFolder 兜底处理'已在此收藏夹中'错误,不弹 alert
- 取消收藏确认文案优化为'确定移出该收藏夹吗?'
2026-06-03 00:04:29 +08:00
0af6f7c971 feat: 文章详情页右下角悬浮操作栏 + 作者自赋能拦截
- 赞/踩/赋能/收藏/评论/回顶部移至右下角悬浮操作栏,固定定位
- 回顶部按钮页面顶部隐藏,滚动后显示,平滑滚动
- 评论按钮点击定位到评论区(NAV 下方)
- 赋能按钮作者可见但禁止自赋能,点击弹出 toast 提示
- 未登录用户赋能/收藏点击跳转登录页
- postReactions 引用统一改为 postFloatBar
2026-06-02 23:45:51 +08:00
b74ce2da93 feat: 详情页作者名链接到 /space/:uid 空间页
- 作者名可点击跳转到作者空间页,已注销用户不生成链接
- hover 时变为主题紫色 + 下划线,与 meta 行风格协调
2026-06-02 23:29:16 +08:00
fcff28ab60 feat: Studio 概览/分析补上阅读量 + 详情页 meta 行改版
- StudioOverview 新增 TotalViews 字段,GetOverviewByUserID 查询含 SUM(views_count)
- 新增 ViewTrendStore 接口和 AggregateViewsByAuthor,UNION ALL 合并已登录+访客阅读日志按日期聚合
- 数据分析页面新增阅读量趋势折线图(绿色,Chart.js)
- 概览和数据分析页面新增总阅读量统计卡片
- 详情页 meta 行改为标签格式:作者/阅读/点赞/收藏/评论/发布时间
- 移除详情页公开状态徽章,已锁定+理由仅作者可见
2026-06-02 23:26:48 +08:00
ea2f196ff7 feat: 文章锁定/解锁时触发系统通知
- 新增 NotifyPostLocked/NotifyPostUnlocked 通知类型
- Lock/Unlock 方法执行后通过 notifier 发送通知给作者
- 锁定时通知含锁定理由,解锁时通知已解除锁定
- 新增类型归入稿件审核分类和 system tab
2026-06-02 22:53:46 +08:00
b0a1ff3c3f refactor: 将 common.js 统一移至 footer.html 加载
- 所有页面的 common.js 引用移至 layout/footer.html 统一管理
- 避免新增页面遗漏 common.js 导致退出登录、消息轮询等功能失效
- 修复 messages/index.html 中 inline script 在 common.js 之前调用 getCSRFToken 的顺序问题
2026-06-02 22:47:21 +08:00
a806532d25 fix: 锁定按钮改为随时可用,不联动审核状态
- 前端:锁定按钮仅受 IsLocked 控制,移除 status!=pending 的条件
- 后端:Lock 方法移除 pending 状态的阻止,锁定状态独立
2026-06-02 22:31:34 +08:00
ca21ae7216 fix: 修复头像审核旧图裂开问题 + CSP 配置修复
- 头像提交审核不再删除旧文件,审核通过后才删除旧头像文件
- 头像审核被拒时删除已上传的新头像文件,保留旧头像
- CSP style-src 移除 nonce 以启用 unsafe-inline(JS 动态样式需要)
- CSP script-src 移除 nonce 改用 unsafe-inline + unsafe-eval(Vditor 编辑器和动态样式需要)
2026-06-02 22:26:01 +08:00
7d944cfa35 fix: 修复更名退款逻辑、审核列表显示及更名消耗确认提示
- 首次更名审核被拒不再退款,首次免费未扣费不产生退款
- 审核列表提交者列改用 UID,当前用户名列显示当前用户名
- 更名消耗提示改为确认弹窗,首次/非首次动态显示不同文案
2026-06-02 22:09:41 +08:00
dd6efd0c50 fix: 修复 utils.js 中 showConfirm/showPrompt/showToast 的 CSP style-src 违规
- showToast: style.cssText 替换为逐个 style.property 赋值
- showConfirm: 移除 style.cssText 和 innerHTML 中的 style 属性,改用 createElement + setStyles 辅助函数 + addEventListener
- showPrompt: 同上,移除所有 inline style 和 onclick
- 新增 setStyles 辅助函数,批量设置单个 style 属性以满足 CSP 要求
2026-06-02 21:45:00 +08:00
fa3b20d1bc fix: 修复审核管理页面 CSP 违规 — inline onclick/onerror 移入事件委托
- 审核操作按钮(通过/拒绝)从 onclick 替换为 data-audit-action 属性 + document 级事件委托
- 分页按钮从 onclick 替换为 data-go-page 属性 + 事件委托
- 头像加载失败处理从 inline onerror 替换为 data-fallback 标记 + addEventListener 绑定
2026-06-02 21:42:20 +08:00
543325105c fix: 修复管理后台样式丢失 + CSP inline style 违规
- 评论管理页:创建 comments.css,controller 传递 ExtraCSS/ExtraJS,模板移除所有 inline style,JS 改用 CSS 类
- 修复 comments.js 加载顺序:通过 ExtraJS 在 common.js 之后加载,解决 api is not defined
- base.html:SVG inline style 移至 common.css
- dashboard/index.html:3 处 inline style 替换为 CSS 类
- audit/index.html:modal 移除冗余 style="display:none"
- fund_logs/logs:label inline style 移至 energy.css
- common.css 新增多个 CSS 规则
- energy.css 新增 .filter-bar label 规则
2026-06-02 21:37:52 +08:00
983f539268 fix: 修复管理后台 JS api 变量冲突导致页面加载失败
- common.js 中 const api 改为 var api,避免与 utils.js 的 function api() 重复声明冲突,导致 roleLabel/statusLabel 等未定义
- common.js 新增 api.del() 方法
- comments.js 升级为对象式 api.get/api.del,修复 api('GET') 函数调用失败
- comments.js 修复 csrfToken 未定义导致删除请求 403
- users.js/audit.js catch 块改为输出真实错误信息
2026-06-02 21:13:58 +08:00
ee313675ca fix: 修复审核管理页拒绝弹窗误显示
- audit.css 中 .modal-overlay 移除 display:flex 默认值,避免与 common.css 冲突导致页面加载时弹窗可见
- 弹窗显示由 JS openRejectModal/closeRejectModal 控制
2026-06-02 20:49:54 +08:00
72c08f3689 feat: 社区准则改为数据库存储
- AuthController 注入 SiteSettings 依赖,RegisterPage 优先从 DB 读取准则
- DB 中 site.guidelines 为空时 fallback 到静态 guidelines.html 文件
- 管理后台站点设置页新增「内容管理」分区,textarea 编辑社区准则
- 添加准则编辑区垂直布局样式
2026-06-02 20:17:23 +08:00
c73b131b83 feat: prompt/confirm 替换为自定义弹窗
- shared/utils.js 新增 showConfirm()/showPrompt() 通用函数(inline CSS,无需额外样式)
- admin/common.js 移除重复 showConfirm(),统一使用 utils.js
- admin/posts.js: 3 处 confirm/prompt 替换(审核/退回/锁定)
- admin/comments.js: 1 处 confirm 替换
- settings/index.html: 4 处 confirm + 1 处 prompt 替换
- studio/posts.html + drafts.html: 3 处 confirm 替换
- post-view.js + login.js + post-favorite.js: 3 处 confirm 替换
2026-06-02 20:11:48 +08:00
2e6e7fc057 refactor: posts/show.html 内联 JS 拆分为独立文件
- 拆分 926 行内联 JS 为 5 个独立模块文件
- post-view.js: Vditor 渲染 + 提交审核按钮 (~50 行)
- post-energize.js: 赋能按钮 + 弹窗 (~76 行)
- post-reactions.js: 赞/踩系统 (~71 行)
- post-favorite.js: 收藏按钮 + 收藏夹弹窗 (~218 行)
- post-comments.js: 评论系统 (~456 行)
- show.html 从 1080 行缩减至 159 行
2026-06-02 20:00:06 +08:00
a8a52f5fd0 refactor: 提取公共工具函数到 shared/utils.js,消除 DRY 重复
- escapeHtml 8 处定义合并为 shared/utils.js 1 处:删除 shortcode.js、posts/show.html、settings/index.html、comments.js、energy.js、common.js 中的重复定义
- formatTime 6 处定义合并为 1 处:统一 ISO 时间格式化逻辑
- api() 5 处定义收敛:前台页面使用 shared/utils.js 统一 api(method,url,data),admin 使用 common.js 的 api 对象
- showToast 3 处定义合并为 1 处:自包含内联样式,前后台通用
- admin/posts.js api 调用迁移为 api.post()
2026-06-02 19:50:52 +08:00
746deca754 fix: 消息页各 Tab 显示各自标题
- system Tab 显示"系统消息"
- mention Tab 显示"@艾特我的"
- like Tab 显示"点赞通知"
- follow Tab 显示"关注通知"
- all/默认 Tab 显示"全部消息"
2026-06-02 19:39:58 +08:00
0614883a4f fix: 消息页"系统消息"标题跟随 Tab 切换显示
- 系统消息区域仅在 system Tab 下渲染,其他 Tab(点赞、关注、@我)不再显示系统消息标题
2026-06-02 19:39:01 +08:00
95e1728c6e feat: GetTrends 趋势查询并行化 (errgroup)
- 使用 golang.org/x/sync/errgroup 将 4 个独立 DB 查询并行执行
- Energy/Comments/Likes/Favorites 趋势查询不再串行阻塞
- golang.org/x/sync 由 indirect 改为 direct 依赖
2026-06-02 19:37:43 +08:00
ab26704049 fix: PostsPage GetOverview 错误不再忽略
- studio_page_controller.go PostsPage 中 GetOverview 调用不再用 _ 忽略 error
- 错误时记录日志,模板侧已有 {{if .Overview}} 空值保护,页面正常降级渲染
2026-06-02 19:33:05 +08:00
99d16190a8 fix: UID 类型混用统一(uint ↔ string)
- Comment.ReplyToUID string→uint, 移除 varchar(36) gorm 约束
- CommentMention.UID string→uint, 移除 varchar(36) gorm 约束
- UserSearchResult.UID string→uint
- 移除 SearchUsersByLevel 中 strconv.FormatUint 转换
- 删除 GetUserUIDByID 方法,CreateReply 直接使用 parent.UserID
- fillReplyToNames 使用 map[uint]string 替代 map[string]string
- 更新 commentStore 接口声明
- 修复前端 mention 下拉 show.html 中 u.id→u.uid
2026-06-02 19:26:44 +08:00
5911cee01c refactor: BaseModel 主键列名统一 uid→id
- BaseModel 移除 gorm column:uid 和 json:uid 标签,统一为 id
- 更新 8 个 repository 文件中 28 处 raw SQL 列引用 (uid→id)
- 更新 3 个前端 JS 文件中 14 处 API 响应字段引用 (uid→id)
- 添加数据库迁移 SQL 脚本 (docs/migrations/001_uid_to_id.sql)
2026-06-02 19:17:57 +08:00
7fdea90ded feat: 敏感操作增加IP维度限流
- RateLimiter 新增 AllowSensitive 方法(1分钟5次,超限封禁5分钟)
- 新增 SensitiveRateLimit Gin 中间件
- 改密(PUT /api/settings/password)增加限流保护
- 注销(POST /api/settings/delete-account)增加限流保护
- 签到(POST /api/level/checkin)增加限流保护
2026-06-02 19:03:04 +08:00
6aacbbab1c feat: 密码强度校验增强,要求大写+小写+数字
- validatePassword 从"字母+数字"升级为"大写字母+小写字母+数字"
- 拆分 pwLetter 为 pwLower([a-z]) 和 pwUpper([A-Z])
- 更新 ErrWeakPassword 错误提示
- 同步更新注册页和修改密码页的前端校验及提示文案
2026-06-02 18:56:08 +08:00
abe1388f8c fix: 统一 CSRF token 获取方式,修复 token 刷新后缓存失效
- utils.js getCSRFToken() 改为从 cookie 优先读取,meta 标签作为后备
- show.html 5处移除 csrfToken 缓存变量,改用 getCSRFToken() 动态获取
- studio/posts.html/drafts.html 移除 {{ .CSRFToken }} 模板硬编码
- studio-editor.js 移除本地 getCsrfToken(),改用共享 getCSRFToken()
- settings/index.html messages/index.html 改用 getCSRFToken()
- admin posts.js/comments.js 移除本地/死代码 CSRF 获取,统一用共享方法
2026-06-02 18:47:22 +08:00
a83e0840d7 fix: admin energy.js escapeHtml 补充单引号转义
- escapeHtml 新增 .replace(/'/g, ''') 处理单引号
- 管理后台能量流水中的单引号内容现在能正确转义显示
2026-06-02 18:35:10 +08:00
77d8562916 fix: 添加 HSTS 响应头
- SecurityHeaders 中间件增加 Strict-Transport-Security 头
- 仅在 release 模式下启用,避免开发环境 localhost 证书问题
2026-06-02 18:33:59 +08:00
66a802beb7 fix: 添加 posts 复合索引 idx_posts_user_status
- 为 UserID 和 Status 列添加复合索引 idx_posts_user_status,优化 user_id + status 联合查询性能
2026-06-02 17:24:45 +08:00
593b52096f feat: 首页增加内存缓存(30s TTL)
- 新增 internal/cache/home_cache.go,实现带 TTL 的首页文章列表内存缓存
- frontend.go 首页 handler 优先读取缓存,未命中/过期时查询 DB 并回填
- PostService 注入失效回调,在 Create/Update/Delete/Approve 后自动失效缓存
- 减少首页每次请求都查 DB 的开销
2026-06-02 17:20:59 +08:00
3e0fed80ce refactor: Controller 不直接读 Session Exp,新增 CanCreatePost 方法
- 新增 userExpReader 最小接口(ISP),PostService 通过它读取用户经验值
- PostService 新增 CanCreatePost(userID uint) bool,封装 LV0 投稿权限判断
- StudioController 替换内联 c.Get("exp") 检查为调用 ctrl.postService.CanCreatePost(uid)
- 更新 DI 注入链:NewPostService 增加 userRepo 参数
2026-06-02 16:52:44 +08:00
3df7b2e672 fix: Service 层移除直接持有的 *gorm.DB,引入 Repository Transaction 方法
- ReactionStore/FollowStore/EnergyStore/FavoriteStore 接口新增 Transaction 方法
- EnergyStore.Transaction 支持跨仓库事务(EnergyStore + FundStore)
- ReactionRepo/FollowRepo/FavoriteRepo/EnergyRepo 实现 Transaction 方法
- ReactionRepo 新增 GetPostLikesCount 方法,消除 Service 直查 DB
- ReactionService/FollowService/FavoriteService/EnergyService 移除 db 字段
- EnergyRepo 通过 SetFundStore 注入 FundStore 用于跨仓库事务
- 更新 deps_extra.go 构造函数调用
2026-06-02 16:46:44 +08:00
1cb7a832f1 fix: CSRF Token 日志脱敏
- 移除 CSF 中间件日志中敏感 token 值的打印(移除 %q)
- MISMATCH 日志仅保留路径和长度信息
- OK 日志移除,避免生产环境中泄露 CSRF token
2026-06-02 16:32:20 +08:00
53f18ca6ee fix: CSP 策略移除 unsafe-inline,改用 nonce 机制
- SecurityHeaders 中间件生成随机 nonce,注入 context
- BuildPageData/BuildAdminPageData 将 CSPNonce 传递给模板
- 所有 19 个模板文件 <script> 标签添加 nonce 属性
- 移除所有内联事件处理(onclick/onerror),改用事件监听
- 移除所有 javascript:void(0) 链接,改用 # 号 + preventDefault
- utils.js 添加全局 avatar 图片加载失败回退处理
- common.js 登出按钮添加 preventDefault
- audit.js 关闭弹窗按钮改用事件监听
2026-06-02 16:10:23 +08:00
9477155bcf refactor: 文件行数超标拆解(7个文件→14个文件,全部≤200行)
- 移动本地接口定义到 interfaces.go 和 admin/interfaces.go
- favorite_controller.go → favorite_item_controller.go
- space_controller.go → space_loaders.go
- settings_controller.go → settings_api_audit.go
- admin_energy_controller.go → admin_energy_api_controller.go
- admin_post_controller.go → admin_post_action_controller.go
- comment_repo.go → comment_mention_repo.go
- post_repo.go → post_stats_repo.go
2026-06-02 15:58:52 +08:00
ff9886f08b refactor: 文件行数超标拆解(Service + Controller 9/16 完成)
- Service 层:energy_service → energize/operations/admin/query 四文件
- Service 层:post_service → helpers/audit + 核心 CRUD 保留
- Service 层:favorite_service → items + 核心文件夹操作保留
- Service 层:auth_service → password + 核心注册/登录保留
- Service 层:notification_service → notify + 核心列表/已读保留
- Service 层:audit_service → review + 核心列表/详情保留
- Controller 层:studio_controller → page/write/api/post_api/action 五文件
- Controller 层:post_controller → page/api/upload 三文件
- Controller 层:comment_controller → list/write/action/upload 四文件
- 清理未使用导入(notification_service.go 移除 fmt)
2026-06-02 15:45:54 +08:00
7cdb4d6698 refactor: GetOverview 5 次独立查询合并为单次 GROUP BY
- 新增 PostRepo.GetOverviewByUserID 单次 CASE WHEN + GROUP BY 查询
- Service 层 GetOverview 从 5 次 DB 查询减少为 1 次
- 接口 postStore 新增 GetOverviewByUserID 声明
2026-06-02 15:25:17 +08:00
9b315c11cd fix: 修复访客视图 PostCount 字段引用错误
- 访客视图 .SpaceUser.PostCount -> .PostCount,与 Owner 视图保持一致
- PostCount 由 Controller 作为顶层字段传入,不在 SpaceUser 对象中
2026-06-02 15:22:57 +08:00
166e01c7c3 fix: 实现空间 TotalReads 统计,修复 selectMention 闭包
- P0-2: posts/show.html selectMention 闭包 var→let 修复
- P0-3: GetUserStats 增加 SUM(views_count) 统计,替换硬编码 int64(0)
- 更新 Fix_List.md 标注 P0-1/2/3 已修复
2026-06-02 15:21:37 +08:00
c1ad8a0121 fix: 修复帖子详情页6处 /login 路径错误
- posts/show.html 中所有 /login 跳转改为 /auth/login
- 覆盖评论登录提示、点赞/反对/收藏 401 跳转
2026-06-02 15:17:35 +08:00
b9d90fe7ec feat: 新增文章阅读量计数(已登录去重 + 访客防刷)
- Post 模型新增 ViewsCount 冗余计数字段
- 新建 PostReadLog 表(user_id + post_id 唯一索引),已登录用户每篇文章只计一次
- 新建 PostGuestReadLog 表(visitor_id + post_id 唯一索引),访客基于 Cookie 标识去重
- Repository 层新增 RecordRead / RecordGuestRead / IncrementViewsCount
- Service 层编排去重-计数逻辑,计数失败不影响页面渲染
- Controller 层 ShowPage + ShowAPI 触发计数,仅 approved 状态生效
- 双层防刷:唯一索引去重 + 2 秒冷却间隔
- 访客 Cookie (visitor_id) 基于 crypto/rand 生成,30 天有效期
2026-06-02 00:02:48 +08:00
b869602185 fix: 修复space-tab-bar.owner背景两侧留白问题 2026-06-01 23:49:06 +08:00
256d201bd2 fix: 修复space模板if/else-if链导致闭合div丢失 + 多余{{end}}导致模板解析panic
- 将所有tab分支从if/else-if链改为独立{{if}}块
- 修复关注/粉丝列表内层if/else-if链
- 删除多余的{{end}}避免模板解析panic
2026-06-01 23:47:00 +08:00
727c92b695 fix: 修复空间页模板结构错误 + 移除收藏夹空白占位和排序栏
- 修复 {{if .FoldersResult}} 缺少闭合 {{end}} 导致 if/else 链错误包裹 settings tab,造成 footer 样式错乱
- 移除收藏夹头部的 collection-cover-placeholder 空白图片占位
- 移除收藏夹内容区的 collection-sort-bar(最近收藏/最多播放/最近投稿)整条排序栏
2026-06-01 22:55:41 +08:00
70bd033e6c fix: 修复 space 模板缺少闭合 {{end}} 导致 unexpected EOF 错误
- space/index.html 缺少一个 {{end}} 闭合外层的 {{if .Error}}/{{else}} 结构
- 在 </body> 前补充缺失的 {{end}}
2026-06-01 22:16:37 +08:00
c6da3932f3 fix: 修复 Space 模板缺少 formatCount/str 函数导致的启动 panic
- 在 theme/loader.go FuncMap 中添加 formatCount 和 str 函数
- formatCount 将 int64 格式化为中文计数(如 2.5万)
- 控制器 ActiveFolderID 改为 uint 类型,避免模板中 str 类型比较
- 控制器传递 ActiveFolder 对象,移除模板内查找逻辑
- 清理旧模板替换残留的孤儿 HTML/SVG 代码
2026-06-01 21:51:17 +08:00
d9eff2df08 refactor: 重构 Space 页面为统一多 Tab 布局 + 移除独立关注/粉丝子页面
- 后端:SpaceController 支持 5 个 Tab(文章/关注/粉丝/收藏/设置),统一处理本人与访客视图
- 后端:注入 FavoriteService 支持收藏夹 Tab,新增用户统计数据查询
- 后端:新增 PUT /api/space/privacy 隐私设置 API 端点
- 后端:移除 FollowPageController 及 /space/:uid/followers|following 路由
- 前端:完全重写 space/index.html,顶部信息栏 + Tab 导航 + 左侧栏 + 主内容区
- 前端:重写 space.css 适配新布局(Profile Header/Tab Bar/文章/收藏/设置)
- 清理:删除独立的 follow HTML 模板和 CSS 文件
- 模型:User 表新增 follower_list_public 隐私字段
2026-06-01 21:48:01 +08:00
797e9c15f4 refactor: space页面改为卡片式关注列表布局
- 重写 space/index.html:左右分栏,左侧导航栏 + 右侧卡片网格
- 自己空间:显示全部关注用户卡片(头像/用户名/简介/已关注标签)+ 设置入口
- 他人空间:简化侧栏(Ta的关注/粉丝),隐私关闭时显示空状态插图
- Model UserFollow 新增头像/简介联表字段
- Repository ListFollowing/ListFollowers SELECT 增加 avatar/bio
- Controller ShowSpace 注入 FollowService,获取关注列表数据
- space.css 完全重写为卡片网格布局 + 响应式适配
- follow.css 清理旧 .follow-btn/.follow-links 无用样式
2026-06-01 21:18:04 +08:00
db2f9206be fix: 修复关注/粉丝列表 JOIN users 表列名错误 u.id → u.uid
- ListFollowers/ListFollowing 中 JOIN users 表使用了 u.id,但 users 表主键列名为 uid
- 导致关注/粉丝列表查不到数据,列表为空但与计数器不一致
2026-06-01 20:56:27 +08:00
a0df66587b fix: 修复关注/粉丝列表隐私检查假阳性导致本人也无法访问
- follow_repo.go: GetFollowListPublic 改用显式 WHERE uid=? 避免 GORM 主键解析潜在问题
- follow_service.go: GetFollowListPublic 失败时默认公开并记录日志,而非返回错误
- follow_controller.go: 错误兜底时 Accessible 默认 true,避免误锁用户
2026-06-01 20:22:50 +08:00
bab8e47d63 fix: 修复关注按钮 CSRF 403 + 缺少样式 + IncrCount SQL 错误
- 修复 CSRF 403:移除 space/index.html 中手动设置 X-CSRF-Token 头(与 common.js 自动注入冲突导致 token 重复)
- 修复按钮无样式:header.html 新增 ExtraCSS2 支持,space 页面加载 follow.css
- 修复 IncrCount SQL 错误:follow_repo.go 中 users 表列名修正 id → uid
2026-06-01 20:16:51 +08:00
ae98d33edf fix: 修复收藏夹列表加载失败 + 收藏按钮改为弹窗选择收藏夹
- settings/favorites 收藏夹列表加载失败:escapeHTML 在 energy Tab 闭包内,收藏夹 Tab 无法访问导致 ReferenceError,修复为本地 esc() 函数
- settings 创建/删除收藏夹请求缺少 CSRF Token(403),统一使用 fetch + X-CSRF-Token 头
- API 错误时显示实际错误信息而非吞掉显示"暂无收藏夹"
- 文章详情页收藏按钮:点击弹出选择收藏夹弹窗,列出所有收藏夹,已收藏的文章高亮显示
- 弹窗支持原地新建收藏夹并立即收藏(一键创建+收藏)
- 新增收藏夹弹窗 CSS 样式(.fav-overlay/.fav-modal/.fav-folder-option 等)
2026-06-01 17:58:35 +08:00
4d212a8f8a feat: 收藏夹系统 + Studio 趋势图 + 通知偏好设置
- 新增收藏夹系统 (Folder/FolderItem 模型、CRUD API、前端管理页)
- 新增文章详情页收藏按钮 (书签图标、ToggleFavorite 一键收藏)
- 新增 Studio 趋势图 (赋能值/评论/点赞/收藏 4 线图, 7d/30d/90d)
- 新增通知偏好设置页 (评论/回复/点赞/关注 4 类开关)
- 后端通知列表支持按偏好过滤 (排除已关闭的通知类型)
- 下载 Chart.js 到本地静态资源 (static/js/chart.umd.min.js)
- 补充收藏夹卡片、开关滑块、趋势图网格等 CSS 样式
2026-06-01 17:33:59 +08:00
680df7371a feat: 实现关注系统 + 通知侧边栏分类 TAB + 点赞聚合通知
- 新增关注系统:UserFollow 模型、FollowService(toggle/status/列表隐私控制)
- User 新增 FollowersCount/FollowingCount/FollowListPublic/NotifyPrefs 字段
- Space 页面增加四态关注按钮(关注/已关注/回关/已互粉)+ 粉丝/关注数链接
- 新增 /space/:uid/followers 和 /space/:uid/following 列表页
- 新增 NotifyFollow/NotifyLikeAggregated 通知类型
- ReactionService 点赞时写入 daily_like_summary,访问时生成聚合通知
- FollowService 关注时触发 NotifyFollow 通知
- 消息中心侧边栏升级为分类 TAB(全部/系统通知/@艾特/点赞/关注)
- NotificationService 新增 ListByCategory 按分类分页查询
2026-06-01 16:30:46 +08:00
2b47380ac5 feat: 实现赞/踩系统(P2)
- 新增 PostLike/PostDislike 模型,复合主键 (user_id, post_id)
- Post 表新增 LikesCount/DislikesCount 冗余计数器
- Repository 层:reaction_repo.go,含 WithTx 事务支持
- Service 层:reaction_service.go,赞踩互斥逻辑(6 态切换),事务包裹
- Controller 层:reaction_controller.go,3 个 API(ToggleLike/ToggleDislike/GetReaction)
- 接口定义:controller/interfaces.go 新增 reactionUseCase,service/repository.go 新增 ReactionStore
- 路由注册 + 依赖注入 + AutoMigrate
- 前端:文章详情页赞踩按钮 + 状态查询 + 401 跳转登录
- 样式:posts.css 新增赞踩按钮样式
2026-06-01 15:48:18 +08:00
4815060839 fix: 修复域能管理操作无响应及公户账务错误
- 修复前端 energy.js api.post/get 回调调用为 Promise 链式调用,解决操作无响应
- 修复 AdminAdjust system_operation 模式错误扣减公户余额,现不碰公户
- 修复 AdminAdjust admin_transfer 模式公户按用户数量扣款(amount * len(userIDs))
- 修复 AdminAdjust 余额检查移入事务内并加行锁(SELECT FOR UPDATE),防 TOCTOU 竞态
- FundStore 接口及 FundRepo 新增 LockAndGetBalance 方法
2026-06-01 15:26:25 +08:00
26e557de8a feat: 实现社区激励金(公户)系统,经济闭环
- 新增 CommunityFund / FundLog 模型(公户余额单行表 + 6种流水类型)
- 新增 FundRepo 仓储(GetBalance / IncrBalance / CreateLog / ListLogs / WithTx)
- 改造 EnergyService 五个方法:Energize 税收入公户、DeductOnRename/DeductOnDeletePost 入公户、RefundOnRenameReject 从公户支出、AdminAdjust 拆分为 admin_transfer/system_operation
- 新增 ErrInsufficientFund 错误哨兵
- 管理后台新增公户余额展示 + 公户流水页面 + 域能调整资金来源选择
- 新增 energy.css / energy.js 前端交互
- 侧边栏新增公户流水入口
2026-06-01 15:10:47 +08:00
683c9e02d8 fix: 修复文章赋能死锁导致请求卡住的问题
- 事务内操作 users 表(扣域能)后,调用 LevelService.AddExp 也操作同一 users 表行,不同 DB 连接间产生行锁死锁
- 将 AddExp 和 UpsertDailyExp 移出事务,等事务提交释放行锁后再执行
- 经验计算仍在事务内读取以保证一致性
2026-06-01 14:18:28 +08:00
43c35ddaf2 fix: 评论系统全量检查修复(7 项 Gap 全部修复)
- Delete 权限:新增 requesterID/isAdmin 参数,Service 层鉴权(仅本人/管理员可删)
- 通知内容:改用 GetUsernameByID 显示真实用户名,非数字 UID
- SearchUsers:Repository 返回 exp,Service 调用 GetLevelByExp 计算等级
- 通知高亮:Notification 新增 CommentID 字段,消息链接支持 #comment-{id}
- 已删除提示:前端 hash 检测 + 3 秒超时 Toast "该评论已不存在"
- Admin CSS:移除不存在的 comments.css 引用
- 错误哨兵:改用 common.ErrCommentNotFound/ErrCommentEmpty/PermissionDenied
2026-06-01 14:05:47 +08:00
36c571b6bb feat: 评论系统补充功能完成——图片上传、后台管理、通知跳转高亮
- 评论图片上传(LV4+,经验≥1500),JPEG/PNG 自动转 WebP,二进制搜索质量优化
- 后台评论管理页面,支持关键词搜索、分页、删除,侧边栏新增入口
- 通知跳转高亮:评论通知 RelatedID 改为 postID,消息页链接到 #comments,前端自动滚动
- 新增 adminCommentUseCase/commentStore 接口(遵循 ISP),AdminCommentRow 移至 model 层
- 前端 @mention 联想/图片上传光标插入/评论展开收起/内联回复交互完善
2026-06-01 13:35:41 +08:00
b7acc91f8c feat: 实现评论系统(后端 + 前端)
- 新增 Comment/CommentMention 模型,Post 新增 CommentsCount 冗余计数器
- 新增 CommentRepo(CRUD + @搜索 LV3+ + 回复统计 + 文章作者查询)
- 新增 CommentService(发表/回复/删除/@解析/通知 NotifyComment/NotifyCommentReply)
- 新增 CommentController(6 个 API 端点),commentUseCase ISP 接口
- 新增评论路由(公开读取 + 需登录写入),依赖注入,错误哨兵,数据库迁移
- 前端评论区 HTML/CSS/JS:分页加载、回复折叠展开、内联回复表单、@提及自动补全
2026-06-01 13:24:16 +08:00
b44d7ca5c3 fix: 删稿扣域能增加一致性保障(事务包裹 + 有限重试)
- DeductOnDeletePost 使用 DB 事务包裹 AddEnergy + CreateEnergyLog,确保扣域能写流水原子性
- Controller 层增加 3 次指数退避重试(100ms/200ms/400ms),全部失败后 log.Printf 记录
- 保持原行为:扣减无视负数,失败不阻塞删稿主流程
2026-06-01 13:01:45 +08:00
5248e91a3d fix: First 改 Find 消除 record not found 日志噪音
- GetDailyEnergizeExp 用 Find + Limit(1) 替代 First,空结果不触发 GORM ErrRecordNotFound
- 移除不再需要的 errors 导入
- 数据库检查确认三张表均存在,daily_exp_summaries 为空属正常状态
2026-06-01 12:49:26 +08:00
610277c8c7 fix: 域能流水时间格式简化,ISO 8601 转可读格式 2026-06-01 12:46:37 +08:00
52f4e95414 fix: 修复域能相关数据表缺失及首次查询报错
- AutoMigrate 添加 EnergyLog、PostEnergizeLog、DailyExpSummary 三张表
- GetDailyEnergizeExp 无记录时返回 nil, nil 而非 gorm.ErrRecordNotFound
- 调用方增加 summary nil 检查,防止空指针
2026-06-01 12:40:30 +08:00
8cadd19253 fix: 修复封禁用户NAV头像下拉菜单显示错误等级Lv0的问题
- FindByIDForAuth 的 Select 缺少 exp 列,导致 Validate() 中 user.Exp 始终为 0
- 正常用户由 tryAutoCheckIn 事后修正掩盖了该问题
- 封禁用户跳过 tryAutoCheckIn,导致错误值未被修正
2026-06-01 12:28:33 +08:00
8902af420e fix: 登录过期改为跳转登录页而非返回 JSON
- Required 中间件区分页面请求(/api/ 前缀)与 SSR 页面请求,页面请求 302 跳转登录页携带 return URL
- 前端 doRefresh token 刷新失败时跳转登录页,避免静默失败
- 认证页面禁止重定向避免无限循环
2026-06-01 12:19:18 +08:00
6542d1e055 fix: 域能系统全量检查 — 修复 5 个逻辑/交互 Gap
- 修复 Admin 改名路径未扣域能(非审核路径补充 HasCompletedTask + DeductOnRename)
- 赋能按钮改为 Modal 弹窗(轻赋/重赋选择 + 每日上限提示)
- EnergyInfo API 增加 daily_energize_exp/daily_exp_cap_reached 返回值
- "我的域能" TAB 增加经验值和等级展示
- Energize 事务修复:导出 EnergyStore + WithTx 方法确保 DB 操作原子性
- Studio 写文章页新增 LV0 锁止横幅 + 禁用提交按钮
2026-06-01 12:10:16 +08:00
e1d7c318ab feat: 实现域能系统
- 新增域能完整分层:EnergyLog/PostEnergizeLog/DailyExpSummary 模型、EnergyRepo、EnergyService、EnergyController/AdminEnergyController
- 支持签到获取域能(+1)、帖子赋能(-1/-2)/被赋能(+1/+2)、改名扣能(-6)/退款(+6)、删帖扣能(-2)
- 赋能单人单帖上限20域能,每日赋能获得经验上限50
- 管理员支持批量调整域能(owner)和查询域能日志(admin)
- LV0用户(exp<1)禁止签到和发帖,余额为负可签到/被赋能/删帖但不能赋能/改名
- 首次改名免费(通过HasCompletedTask判重),改名扣能在提交审核时执行
- 移除导航栏手动签到按钮,签到融入域能系统(自动签到)
- 新增个人中心域能TAB展示余额和流水日志(近7天)
- 新增帖子详情页赋能按钮(轻赋/重赋)
- 管理后台新增域能管理和域能日志页面
2026-06-01 03:32:50 +08:00
394989b281 fix: 封禁横幅图标修复 — SVG 替换为 ⓘ Unicode 字符 2026-05-31 21:33:59 +08:00
209336b429 feat: 封禁用户 NAV 横幅 + Space 页面隐藏个性签名
- BuildPageData 注入 IsBanned 字段,封禁用户所有页面显示红色横幅
- Space 页面对封禁用户覆写 Bio 为'该账号已被封禁',原文保留
- 新增 banned-banner 样式(浅红底深红字)
2026-05-31 21:27:27 +08:00
bbcd992614 feat: 封禁用户允许登录但禁止写操作
- Session 结构体新增 Status 字段,Create/Validate 同步状态
- AuthService.Login 移除封禁检查,允许封禁用户登录
- 新增 BannedWriteGuard 中间件,拦截 POST/PUT/DELETE 写操作
- 封禁用户自动签到跳过
- 所有需登录的 API 路由组应用 BannedWriteGuard
- AdminService.UpdateUserStatus 保留 DestroyByUID 确保状态刷新
2026-05-31 21:20:04 +08:00
a84fa5da61 fix: 修复等级升级后NAV仍显示旧等级的问题
- CompleteTask等非签到路径触发升级后,session Exp未同步,导致下次页面渲染NAV仍用旧Exp计算等级
- tryAutoCheckIn改为始终对比session Exp与DB Exp并同步,不再仅限签到成功时
- 使用 newExp > s.Exp 确保Exp只增不减,防止异常返回0或主从延迟等场景导致积分回退
2026-05-31 21:08:23 +08:00
fdb636f98f feat: 实现积分与等级系统 + 签到功能 + 下拉菜单优化
- User 模型增加 exp 字段,等级规则 Lv0-Lv6
- 新增签到记录(UserCheckIn)与任务记录(UserTask)模型,唯一索引防重
- 自动签到:AuthMiddleware 验证会话后,基于 Asia/Shanghai 自然日自动签到 +5 经验
- 手动签到:下拉菜单"签到"按钮兜底,已签到显示"今日已签到"并禁用
- 首次任务经验:上传头像/设置用户名/设置个性签名各 +10,审核通过与直接更新均触发
- 经验值变动后自动比对等级,升级时通过通知系统发送"恭喜提升至 LvX"
- 下拉菜单重构:顶部头像+用户名+LvX 等级标签,菜单项增加左侧图标,优化布局样式
- 新增 API:POST /api/level/checkin、GET /api/level/info
2026-05-31 20:58:14 +08:00
91115447e8 fix: 退回理由横幅移至文章顶部显示 2026-05-31 20:18:50 +08:00
0cfbb6911d fix: 已锁定文章禁止提交审核,隐藏提交按钮
- SubmitForAudit 新增 IsLocked 检查,锁定文章不可提交审核
- posts/show.html:已锁定文章不显示"提交审核"按钮
2026-05-31 20:13:34 +08:00
968e8af8a3 feat: 锁定文章需填写锁定理由,编辑器显示锁定理由
- Post 模型新增 LockReason 字段,锁定必填理由,解锁清空
- PostLockRequest DTO:reason 必填,1-500字
- Admin JS:锁定操作弹出 prompt 要求填写理由
- 编辑器锁定横幅显示"锁定理由:XXX",理由为空时不显示该行
2026-05-31 20:10:55 +08:00
b658eeb8e9 feat: 编辑器锁定文章提示 - 禁用编辑,显示明确提示
- /studio/write?id=X 路由:文章被锁定时,页面显示紫色锁定横幅,标题/编辑器只读,提交按钮替换为锁定提示
- studio-editor.js:锁定态下 Vditor 设为 readonly 模式,隐藏工具栏,禁用 Ctrl+S 快捷键
- 新增 .studio-locked-banner / .locked-submit-hint 样式
2026-05-31 20:06:32 +08:00
99d0891413 feat: 创作中心内容管理页 - 锁定文章显示"已锁定"标签,隐藏编辑按钮
- 文章被锁定时,在主状态徽章旁追加"已锁定"标签(如 "已发布" "已锁定")
- 被锁定的帖子隐藏"编辑"按钮,仅显示"查看"和"删除"按钮
- API 层已有保护:Update 方法检查 IsLocked,拒绝越权编辑
2026-05-31 20:02:24 +08:00
d252a746f0 fix: 首页快捷入口'浏览文章'图标替换为文档图标 2026-05-31 19:53:21 +08:00
dae2a38e79 feat: 重构首页模板,双栏现代化布局 + 移动端适配
- 全新 Hero Banner:渐变背景,用户欢迎语
- 左侧最新文章卡片列表,含标题/摘要/作者/日期
- 右侧侧边栏:用户卡片(修复头像显示)、社区统计、快捷入口
- 完全重写 home.css,现代化样式,sticky 侧边栏
- 移动端适配:768px 单列布局,比例缩放
- 后端路由注入 PostService,获取最新 6 篇 approved 文章
2026-05-31 19:51:47 +08:00
b75e1867f6 fix: 头像上传成功后同步更新NAV头像
- 上传成功回调中查找NAV .nav-avatar元素并更新src
- 首次上传(原为文字占位)时创建img元素替换
- 带缓存破坏时间戳,与settings页头像更新逻辑一致
2026-05-31 19:29:33 +08:00
6f74643227 fix: NAV头像session未同步导致不显示 + 图片加载失败回退首字符
- Validate中同步DB最新头像/用户名/角色到session,解决上传头像后NAV仍不显示的问题
- NAV模板img增加onerror回退,图片加载失败时自动显示用户名首字符
2026-05-31 17:08:00 +08:00
9fe87a7003 refactor: 重写导航栏布局与交互
- 导航项重新排序:首页、投稿、信封图标、头像下拉菜单
- 铃铛图标替换为信封图标
- 用户名/设置/退出移入头像 hover 下拉菜单
- 设置改名为个人中心,退出改名为退出登录
- 头像点击跳转 /space,hover 展开下拉菜单
- 无头像时显示用户名首字彩色圆形占位
- Avatar 数据链路打通:Session→Middleware→BuildPageData→模板
- FindByIDForAuth 查询增加 avatar 列
- 新增 substr 模板函数
2026-05-31 16:17:35 +08:00
a3f787ddaf feat: NAV 添加投稿入口,笔图标+投稿
- nav.html: 帖子后添加笔图标+投稿链接(仅登录可见),跳转 /studio/write
- posts/index.html: 移除独立的撰写帖子按钮
- common.css: 添加 .nav-contribute 样式,inline-flex + vertical-align: middle + line-height: 1
2026-05-31 15:53:30 +08:00
3f4a079c78 refactor: 彻底移除 /posts/new 旧编辑器,统一迁移至 /studio/write
- 删除 /posts/new 路由,不再保留任何兼容层
- /posts/:id/edit 改为 301 跳转 /studio/write?id=:id
- 删除 posts/new.html 模板和 editor.js
- 移除 PostController.EditPage/Create/Update/Delete 孤儿方法
- 移除 /api/posts POST/PUT/DELETE 孤儿 API(保留 submit 和 upload-image)
- posts/show.html 编辑链接指向 /studio/write
2026-05-31 15:35:20 +08:00
7e50c892ad refactor: 移除 /posts/new 路由,301 重定向至 /studio/write
- /posts/new 路由改为 301 重定向至 /studio/write
- 删除孤儿 NewPage handler
- 帖子列表撰写链接指向 /studio/write
- 保留 posts/new.html 及 editor.js(EditPage 仍在使用)
2026-05-31 15:28:28 +08:00
e98bfe193b fix: 修复发帖后链接404 + 通知卡片跳转错误
- post_repo.go: FindByIDWithAuthor/Restore 中 posts.uid 改为 posts.id(posts 表主键是 id,不存在 uid 列)
- theme/loader.go: 新增 derefUint 模板函数,显式解引用 *uint 指针(Go 1.26 模板引擎传参给 printf 时不自动解引用)
- messages/index.html: 通知卡片链接使用 derefUint 包裹 RelatedID 避免打印指针地址
2026-05-31 13:58:14 +08:00
59af6b2635 fix: 修复通知系统链接错误、标记已读失败及帖子404页渲染异常
- 修复 post_rejected 通知链接到帖子详情页(原来链接到 /studio/posts)
- 为 audit_approved/audit_rejected 通知添加链接到 /settings
- 通知已读标记改用 fetch keepalive 代替 sendBeacon,附带 CSRF token,避免页面跳转时请求丢失
- 修复 repository 层 id 列名应为 uid(BaseModel 定义 primarykey column:uid)
- 帖子404页添加 ExtraCSS 引入 posts.css,修复 SVG 图标无尺寸约束全屏渲染异常
2026-05-31 13:33:12 +08:00
f0bf450725 feat: 管理首页帖子总数统计(总数/已发布/待审核)
- PostRepo 新增 CountPostsByStatus 和 CountPending 方法
- Service 层定义 postAdminStatStore 接口 (ISP)
- AdminService 注入 postRepo,新增 PostStats 结构体和 GetPostStats 方法
- AdminController.Dashboard 调用 GetPostStats 并传参到模板
- Dashboard 帖子卡片显示总数 + 已发布 + 待审核(橙色徽章)
- DI 层将 AdminController 创建从 buildDepsCore 移至 buildDeps,以便注入 postRepo
2026-05-31 12:46:06 +08:00
891990bb35 fix: 迁移清理改为精确匹配同设备,避免误删其他设备会话
- 用 UA+IP 精确匹配同设备旧会话,而非 DeleteByUID 清除所有会话
- 降级期重新登录的设备:清理旧记录避免重复
- 降级期未操作的设备:保留会话,恢复后无需重新登录
2026-05-31 12:29:31 +08:00
2da24e12ff fix: 迁移时清理旧会话,避免 Redis 恢复后出现重复设备记录
- migrateSessions() 先 DeleteByUID 清理 Redis 同用户旧会话,再写入新会话
- 调整 recoveryLoop/healthLoop 顺序:先迁后切 healthy,消除迁移期间写竞争
- 降级期重新登录产生的会话不会与 Redis 中旧会话并存
2026-05-31 12:27:32 +08:00
a9b6dddf70 fix: 迁移成功后立即清理内存中已迁移的会话
- migrateSessions() 写入 Redis 后删除 MemoryStore 中对应数据
- 避免残留数据等到 TTL 自然过期才清理(最长 30 天)
2026-05-31 12:22:26 +08:00
a57777e7cd fix: Redis 模式下活跃会话数错误显示为 0,应为不显示
- FallbackStore.Metrics() 未设置 ActiveCount 时 Go 默认值为 0,模板误显示 0
- 修复:Redis 健康时显式设置 ActiveCount=-1,与 RedisStore.Metrics() 一致
- 模板 ge .ActiveSessions 0 判断自动隐藏,Redis 模式不统计会话数(代价高)
2026-05-31 12:18:33 +08:00
f036e0bc43 fix: 启动时 Redis 离线后续恢复后自动切回 Redis
- 不再将 redisClient 置 nil,让 FallbackStore 接管全生命周期
- 启动时离线:FallbackStore 首次请求触发操作级降级,recoveryLoop 检测恢复后自动迁移切回
2026-05-31 12:14:55 +08:00
3e26431602 fix: Redis 降级机制优化:启动离线不崩溃 + 操作级即时降级 + 快速恢复
- 启动时 Redis 不可达不再 Fatal 退出,降级为内存模式继续运行
- FallbackStore 改为操作级降级:Redis 操作失败立即切换到内存,不依赖周期健康检查
- 新增 recoveryLoop:降级后每 5s 轮询尝试恢复,恢复后自动迁移内存会话回 Redis
- 健康检查与清理解耦:检查 30s 间隔,恢复轮询 5s 间隔(原 300s)
- 优化 go-redis 连接池参数(DialTimeout 1s, MaxRetries 1,减少故障阻塞时间)
2026-05-31 12:14:10 +08:00
9d6aebbc0d chore: 启用 Redis 会话存储
- 将 redis.enabled 从 false 改为 true,启用 Redis 存储会话
2026-05-31 12:00:21 +08:00
dae248590b fix: 恢复迁移改为先切 Redis 再迁存量,消除 Snapshot 遗漏 gap
- 先 healthy.Store(true) 再 migrateSessions()
- 先切后迁:新会话立即走 Redis,存量通过 Pipeline 补写
- 迁移失败保持 healthy=true,由下个健康检测周期处理
2026-05-31 11:51:12 +08:00
37c83d4c12 feat: FallbackStore 恢复时静默迁移内存会话到 Redis
- MemoryStore 新增 Snapshot() 返回活跃会话副本
- Redis 恢复时自动将降级期间的内存会话 Pipeline 写入 Redis
- 迁移成功后再切换 healthy 标记,用户无需重新登录
- 迁移失败则保持内存模式,避免切到空的 Redis 再被迫降级
2026-05-31 11:46:50 +08:00
12de8be0cf feat: 管理首页新增会话存储后端状态展示
- 仪表盘新增「会话存储后端」卡片,显示 Redis/内存 模式
- 降级时显示「已降级」标签 + 降级说明
- 内存模式下显示活跃会话数
- 新增 stat-red / stat-desc / stat-badge / badge-warn CSS 样式
2026-05-31 11:38:31 +08:00
5a8b0ca79c feat: 实现 FallbackStore 会话存储降级保护
- 新增 session/fallback_store.go,包装 RedisStore + MemoryStore 双后端
- 后台 goroutine 周期性 ping Redis,自动降级/恢复
- Store 接口新增 Metrics() 方法,MemoryStore/RedisStore/FallbackStore 均已实现
- deps_core.go 改造为始终创建 MemoryStore 兜底,Redis 启用时包装为 FallbackStore
- Manager 新增 GetStoreMetrics() 暴露存储状态
2026-05-31 11:38:20 +08:00
d6d03a7c3c feat: 实现 Redis 会话存储,支持内存/Redis 双模式自动切换
- 新增 go-redis/v9 依赖
- 新增 config.yaml redis 配置段 + RedisConfig 结构体 + REDIS_PASSWORD 环境变量覆盖
- 新增 common/redis.go Redis 客户端初始化
- 完整实现 session/redis_store.go,实现 Store 接口全部 7 个方法
- Redis TTL 自动过期,无须后台清理 goroutine
- deps_core.go 根据 cfg.Redis.Enabled 自动选择 RedisStore/MemoryStore
- router.go + main.go 透传 redisClient 参数
2026-05-31 11:28:27 +08:00
55c408d86c fix: 审计问题全量修复 + RateLimiter 原子化重构 + CDN 本地化收紧
- 安全:移除 CSP 中 esm.sh/cdnjs.cloudflare.com,highlight.js 主题已本地化 73 个文件
- 安全:StatusBanned 分支补 dummy hash 防时序攻击
- 安全:手写 constantTimeEq 替换为 crypto/subtle.ConstantTimeCompare
- Bug:锁定操作消息已删除→已锁定
- YAGNI:删除 12 个空预留模板目录
- KISS:删除 common.CheckPassword 薄封装,统一用 bcrypt 调用
- KISS:删除 tokenCtrl 别名字段,api.go 统一用 authCtrl
- RateLimiter:check()+recordFail 闭包模式重构为 try() 原子操作,消除竞态
- RateLimiter:新增 ClearIP() 方法,注册成功时清除 IP 计数
- 文档:修正 audit_service.go 注释编号跳跃(3→5→4)
- 文档:修正 deps_core.go 过清理→过期清理
2026-05-31 11:13:32 +08:00
e1fb28e8fb fix: 修复登录重定向失效 + 404页面友好化
- ShowPage 未登录用户看未发布帖子时引导登录(携带 redirect 参数),替代直接 404
- login.js 无 redirect 参数时回退到同站 Referer 页面
- 404 页面重设计:SVG 图标 + 中文提示 + 返回首页和浏览帖子双按钮
- posts.css 新增 404 页面按钮样式
2026-05-31 03:39:18 +08:00
8b075f7387 perf: WebP 二分压缩下限从 50 改为 1,匹配原图实际质量
- encodeWebPBinary 质量区间从 50~80 扩大为 1~80
- 低质量原图自动匹配低质量 WebP,不再无故回退原格式
- 回退路径中 WebP 不再重编码,避免低质量 WebP 被 quality 80 撑大
- 回退路径仅对 JPEG/PNG 重编码剥离元数据
2026-05-31 03:16:55 +08:00
48186085d8 fix: 回退存储路径也解码重编码,彻底剥离 EXIF/元数据
- fallback 路径不再直接写原始上传字节,而是解码后重编码
- JPEG: decode → jpeg.Encode(quality 92),剥离 EXIF/GPS/注释
- PNG: decode → png.Encode,剥离元数据块
- WebP: decode → webp.Encode(quality 80),剥离元数据
- GIF 例外:重编码会丢失动画帧,仅解码验证后原样存储
- 导入 jpeg/png 从空白导入改为命名导入,以使用 Encode 函数
2026-05-31 03:12:10 +08:00
d8e58b5f8c perf: 文章图片 WebP 转换改为二分查找质量,目标 ≤ 原文件大小
- 不再仅试 quality 80,改为二分查找 (50~80) 找最高质量 ≤ 原文件大小
- 新增 encodeWebPBinary 辅助函数:先试 80,太大则二分降到 50
- 降到 50 仍超限时回退原格式,避免产生马赛克级质量
- 与头像的激进二分(最低 quality 1)区分,文章图片保底 quality 50
2026-05-31 03:08:33 +08:00
6ea4e5365e fix: 文章图片上传增加内容解码验证,防伪扩展名和图片投毒
- 所有格式(JPEG/PNG/GIF/WebP)上传后强制解码验证是否为有效图片
- 解码失败直接拒绝,不再回退原样存储
- 消除 .php 改名为 .jpg 绕过检查的安全漏洞
- JPEG/PNG 解码与 WebP 编码合并为一次解码,避免重复读
2026-05-31 03:04:45 +08:00
5fbd010b71 feat: 文章图片上传 JPEG/PNG 自动转 WebP 压缩存储
- JPEG/PNG 上传后自动转为 WebP quality 80 存储,保留原尺寸不 resize
- 仅当 WebP 比原文件小时才采用,否则回退原格式
- GIF/WebP 保持不变直存
- 移除对 common.SaveUploadedFile 的依赖,改用 io.ReadAll + os.WriteFile
2026-05-31 02:59:35 +08:00
da7560b918 perf: 客户端裁剪直接缩放到 512×512 再上传,进一步节省带宽
- cropOnClient 输出尺寸从 min(size, 2048) 改为固定 512px
- 服务端收到的已是 512×512,解码后 resize 和 WebP 编码近乎瞬时
- 上传体积从数十 KB~数 MB 降至 ~30-60KB
2026-05-31 02:43:58 +08:00
4887093c3a feat: 头像上传选区限制 3840×3840 + 客户端裁剪压缩
- 后端:3840×2160 限制改为 3840×3840(方形限制适应方形裁剪),并移到裁剪结果上检查而非原图
- 前端:原图短边超过 3840 时自动抬高最小缩放,确保选区永不超过 3840×3840
- 前端:裁剪确认后用 Canvas 预裁剪 + JPEG 压缩再上传,大幅节省带宽
- 前端:裁剪框标题栏新增实时像素尺寸提示
- 新增 .crop-info 样式
2026-05-31 02:42:27 +08:00
09a9394c30 fix: 修复静态资源哈希 key 与模板 URL 路径不匹配导致版本号全为 0
- 重构 walkStaticDir 统一处理三个 static 目录
- URL key 基于文件相对于 static 目录的路径,不含主题目录名
2026-05-31 02:18:20 +08:00
eabceecaae refactor: 静态资源哈希算法从 SHA-1 改为 CRC32
- CRC32 更快且语义更匹配缓存破坏场景
- 输出自然为 8 位 hex,无需截取
2026-05-31 02:06:41 +08:00
d3e5cbe18b refactor: 静态资源缓存破坏改为文件级哈希,重启不刷新未修改文件的缓存
- 启动时计算每个 JS/CSS 的 SHA-1 前 8 位作为版本号
- 文件不变则哈希不变,浏览器继续用缓存,重启零压力
- 替换全局 AssetVersion 为 assetV 模板函数,按文件路径查询哈希
- 移除 BuildPageData 中的全局版本号注入
2026-05-31 01:53:17 +08:00
b8658712bb fix: 静态资源加缓存破坏版本号,解决浏览器缓存旧 JS 导致功能不生效
- 启动时生成 AssetVersion(base36 时间戳),注入模板 {{.V}}
- 所有 JS/CSS 引用加 ?v={{.V}},重启即刷新缓存
- 涉及前端、管理后台共 12 个模板文件
2026-05-31 01:46:47 +08:00
0bcaadceb4 fix: 注销恢复流程文案统一为'撤销注销',消除歧义
- '恢复'易误解为故障恢复,改为'撤销注销'明确表达取消注销操作
- 涉及后端错误提示、API 响应、前端弹窗及按钮文案共 9 处
2026-05-31 01:26:04 +08:00
a62039e6bb fix: 登录/注册支持 redirect 参数,未登录跳转自动携带来源页
- login.js/register.js 登录成功后优先跳转 redirect 参数指定页面
- 新增 common.RedirectToLogin 辅助函数,自动携带当前路径
- 替换所有 9 处硬编码 /auth/login 重定向
2026-05-31 01:23:09 +08:00
9d84d6bf25 feat: 临时会话超时从 24 小时缩短为 2 小时
- idle_timeout: 1440 → 120 分钟
- 前端已有 10 分钟心跳续期机制,写文章等长时间操作不会丢失会话
2026-05-31 00:51:44 +08:00
213b2a571e feat: 登录管理设备列表显示会话类型和剩余有效期
- 临时会话显示橙色'临时会话'徽章
- 非当前设备显示剩余有效时间(临时/长期均显示)
- 当前设备不显示剩余时间(持续续期,显示无意义)
- 新增 formatTTL 模板函数,分钟数转人类可读格式
- sessionConfig 接口增加 GetRememberTimeout 方法
2026-05-31 00:36:25 +08:00
e244bccb5c fix: 移除服务端登录会话去重逻辑
- 去重会合并标准模式/无痕模式的独立会话,不符合预期
- 防重复提交由前端按钮禁用保证即可,服务端无需干预
2026-05-31 00:29:47 +08:00
d5d33c3ace fix: 修复登录重复创建会话 + 备注输入框不可交互
- login.js 增加防重复提交锁,请求期间禁用按钮
- Manager.Create 增加幂等检查,同用户同IP同UA 10秒内复用已有会话
- 移除当前设备备注输入框的 readonly 限制,改为 data-current 标记
- 修复 readonly 状态下输入框完全透明无法点击的问题
2026-05-31 00:28:00 +08:00
47ef3f2a99 fix: 重写设置页模板,修复 sessions tab 重复和结构损坏
- 移除 sessions tab 重复3次的冗余内容
- 修复 profile 内容碎片混入 sessions 区块的问题
- 恢复正确的三tab结构(profile / sessions / account)
2026-05-31 00:18:47 +08:00
b63dc9075f fix: 修复 MemoryStore.Set 重复追加 byUID 索引导致会话列表重复 2026-05-31 00:08:08 +08:00
2180c27b16 fix: 修复设置页模板中文引号与 HTML 属性冲突导致渲染报错 2026-05-31 00:06:24 +08:00
d203447eb3 feat: 设置页新增登录管理 TAB
- Session 结构体新增 IP、UserAgent、Remark 字段,登录/注册时记录设备信息
- Store 接口新增 ListByUID、DeleteByUIDExclude 方法,MemoryStore 完整实现
- SessionManager 新增 ListByUID、DestroyOtherByUID、UpdateRemark 方法
- 新增 UA 轻量解析器(session/ua.go),提取浏览器/OS/设备类型
- 新增 settings_api_sessions.go,提供列表/踢出/一键踢出/备注 4 个 API
- 控制器层声明 sessionManager 最小接口(ISP),SettingsController 注入依赖
- Auth 中间件注入 sid 到 gin context,支持识别当前会话
- 设置页左侧导航增加登录管理入口,tab 白名单新增 sessions
- 前端实现设备列表展示(浏览器/OS/设备图标/IP/时间)、当前设备标识、备注输入、踢出按钮、一键踢出
- settings.css 新增会话管理全套样式及响应式适配
2026-05-30 23:58:37 +08:00
e3853071d6 chore: 清理 JWT 向后兼容残留,移除全部死代码
- 删除 5 个空壳占位文件:auth_token.go/auth_parser.go/repository.go(middleware)/token_service.go/provider.go
- 移除 Config.JWT 字段、JWTConfig 结构体、JWT_SECRET 环境变量绑定
- 移除 config.yaml 中的 jwt 配置段
- 移除 model.User.TokenVersion 字段(session DestroyByUID 替代)
- 移除 service/repository.go 中已废弃的 userTokenStore 接口
2026-05-30 23:42:27 +08:00
d9ba1cdf28 refactor: 移除 Session 架构下已无用的 TokenVersion 即时吊销机制
- 删除 UserRepo.IncrementTokenVersion/FindTokenVersion 方法
- 从 userAuthStore/userAdminStore 接口移除 IncrementTokenVersion
- 删除 middleware.tokenVersionStore 接口(中间件不再查询 token_version)
- auth_service: DeleteAccount/InvalidateSessions 仅调用 DestroyByUID
- admin_service: UpdateUserStatus/ResetUserToken 仅调用 DestroyByUID
- FindByIDForAuth/FindByIDUnscoped Select 移除 token_version 列
- 新架构下登出/改密/封禁统一走 sessionManager.DestroyByUID,无需 token_version
2026-05-30 23:38:42 +08:00
daf87f895b refactor: JWT 无状态认证替换为服务端 Session,修复记住我掉线问题
- 新增 internal/session 包:Session 结构体、Store 接口、MemoryStore(内存+后台清理)、RedisStore 预留
- Cookie 从 3 个精简为 1 个 mlb_sid(HttpOnly),移除 JWT access/refresh cookie
- 会话过期采用滑动窗口:每次请求自动续期,记住我 30 天无操作过期
- AuthMiddleware/AuthAdmin/Maintenance 中间件改用 SessionManager.Validate()
- AuthService Login/Register/ConfirmRestore 去除 token 生成,返回 *model.User
- Controller 层在登录/注册后调用 SessionManager.Create 创建会话
- AdminService UpdateUserStatus/ResetUserToken 同步销毁 session 实现即时退登
- 改密/注销时通过 DestroyByUID 删除所有 session(替换 TokenVersion 检查)
- config.yaml 新增 session 配置段(idle_timeout/remember_timeout/cleanup_interval)
- TokenService/auth_parser/auth_token 标记废弃,移除 JWT 到期字段依赖
2026-05-30 23:36:52 +08:00
53f84bfcc4 fix: 修复编辑模式下 Vditor 不加载稿件内容
- 创作中心编辑文章时,after 回调中显式调用 vditor.setValue(initialMD)
- 修复 Vditor 3.x wysiwyg 模式下 value 选项可能不渲染内容的问题
2026-05-30 21:29:29 +08:00
67444434b9 feat: 对接稿件审核通知系统 + 消息页面系统消息区域
- 新增通知类型 post_approved / post_rejected
- PostService.Approve/Reject 完成后通知作者(通知失败不影响主流程)
- NotificationService 新增 NotifyPostApproved/NotifyPostRejected 方法
- 消息页面顶部增加系统消息标识和分隔线,之下为全部消息列表
- 稿件审核通过通知卡片点击跳转到 /posts/{id} 页面
- 稿件审核拒绝通知卡片点击跳转到 /studio/posts 创作中心
- 新增 postNotifier 接口遵循 ISP 原则
2026-05-30 21:24:30 +08:00
fb94ce60ae fix: 维护期间登录一律返回统一提示,不泄露账号状态
- 维护检查提前到 FindByEmail 之后、所有状态检查之前
- 非站长账号(不存在/已封禁/已锁定等)统一走模拟 bcrypt 防时序 → 返回维护提示
- 站长角色正常通过,走完整登录流程
- 旧实现:维护检查在 banned/locked/deleted 之后,会泄露账号状态
2026-05-30 21:10:41 +08:00
c1357cf4af fix: 维护条对站长显示“正处于维护状态”提醒 + BuildPageData 注入 IsOwner
- BuildPageData 新增 IsOwner 变量,所有前端页面可获取站长角色
- Nav 维护条:站长显示“社区正处于维护状态”(防止忘记关闭),非站长显示“仅站长可访问”
- 维护中间件 API 拦截验证:所有非 /api/auth/ 的 API 对非 Owner 返回 403,正确
2026-05-30 21:06:32 +08:00
fe683900bb fix: 维护状态下登录页隐藏“立即注册”链接 2026-05-30 21:01:54 +08:00
1b4a74c23d fix: 维护开关初始状态显示与实际不符
- maintenance.enabled 默认 false,但 JS 用 registration 同逻辑导致首次显示为 ON
- 分离 maintenance 判断:仅当保存过 true 时才显示 ON
- registration 保持原逻辑(默认 true,仅当 !== false 时 ON)
2026-05-30 20:58:05 +08:00
6229c81f6e feat: 新增维护模式 + 修复注册关闭页面标题显示问题
- 修复注册关闭时标题“创建账号”和副标题仍显示的问题,三态互斥(维护中 > 关闭注册 > 正常)
- 新增 maintenance.enabled 站点设置,默认关闭,仅 Owner 可见可调节
- 维护中间件:全局拦截,维护期间仅站长可访问,白名单放行登录相关路径
- 登录服务:维护期间仅 Owner 角色可登录,Register 优先检查维护状态
- Nav 模板新增维护通知条(黄色横幅)
- 管理后台站点设置新增“启用维护模式”开关
- BuildPageData 自动注入 IsMaintenance 到所有页面模板
2026-05-30 20:54:23 +08:00
fb2eff5f23 feat: 注册关闭时页面隐藏表单并显示友好提示,保留登录入口
- RegisterPage 传入 RegistrationEnabled 标志到模板
- 注册关闭时隐藏输入框和按钮,显示“当前暂不开放注册”提示
- 保留“已有账号?立即登录”链接
- API 层已有拦截(AuthService.Register 返回 403),无需修改
- authUseCase 接口新增 IsRegistrationEnabled 方法(ISP)
2026-05-30 20:38:55 +08:00
90d17ed4b1 feat: 管理后台待审稿件置顶优先显示
- FindAdminPageable 新增 pageResultsAdmin,使用 CASE WHEN 排序
- pending 和有 PendingBody 的 approved 帖子排在最前
- 其余帖子按创建时间倒序排在后面
2026-05-30 20:33:14 +08:00
8bb87cd685 fix: 去掉独立修订表格,消除帖子重复显示
- 移除管理后台独立的"修订待审核"表格区域
- 修订标识和按钮已在主列表中内联展示,无需额外表格
- PostsPage 不再额外查询 Revisions 列表
2026-05-30 20:31:19 +08:00
95311704fe fix: 修订状态标识仅对作者可见,不公开显示 2026-05-30 20:28:59 +08:00
eabc1e7d3d feat: 已发布帖子编辑后进修订审核,通过前保持展示旧内容
- Post 模型新增 PendingTitle/PendingBody 字段,分离已发布内容与待审修订
- Update: approved 帖子编辑写入 Pending 字段,不覆盖正文,不改变发布状态
- Approve: 有 PendingBody 时复制到正式字段后清空,无 PendingBody 走首次审核
- Reject: 有 PendingBody 时仅清空待审修订(保持已发布),无 PendingBody 走普通退回
- 新增 ListPendingRevisions 查询,管理后台独立展示"修订待审核"区域
- 详情页展示"修订审核中"标识,编辑页加载待审内容
- 通过修订后直接覆盖,不保留历史版本
2026-05-30 20:24:43 +08:00
89fa50a013 fix: 锁定机制不再改变帖子状态,锁定与退回独立
- Post 模型新增 IsLocked 字段,与 Status 解耦
- Lock 仅设 IsLocked=true(不改 Status),已通过帖子锁定后仍公开可见
- Unlock 仅设 IsLocked=false(不改 Status)
- Update/EditPage 编辑检查改用 IsLocked 而非 Status==locked
- Reject 与 Lock 独立,可单独退回或锁定+退回组合使用
- 新增 ErrPostCannotLock 错误哨兵
- 前端模板编辑按钮/锁定标识基于 IsLocked 渲染
2026-05-30 20:08:41 +08:00
7659aee468 feat: 新增创作中心(/studio),支持数据概览、内容管理、草稿箱、写文章
- 新增 StudioController,依赖 studioUseCase 接口,遵循 ISP 接口隔离
- PostRepo 新增 FindByUserIDAndStatus 方法,按用户+状态分页查询
- PostService 新增 ListByUser、GetOverview 及 StudioOverview 统计
- /studio 页面路由(概览/管理/草稿箱/写文章/数据分析)+ /api/studio API
- 复用 Vditor 编辑器,studio-editor.js 对接 /api/studio/posts 端点
- 新增 studio.css(B站风格三栏布局+统计卡片+状态筛选tabs)
- 模板引擎注册 add/subtract 函数,分页模板中直接使用
2026-05-30 19:55:29 +08:00
bacfd4945d feat: 编辑器与帖子展示布局优化 + 新增个人空间
- 编辑器投稿页布局优化:标题与编辑器高度动态适配,工具栏与内容区视觉对齐
- 稿件展示页布局优化:MD 渲染区与评论区视觉分离,代码块主题微调
- CSRF 中间件:图像上传端点豁免,解决 Vditor 拖拽/粘贴上传 403
- Post 状态映射、GetGinUser、SaveUploadedFile 提取至 common 包,遵循审计建议
- 新增个人空间功能:/space(需登录)重定向到 /space/:uid,/space/:uid 公开访问
- 空间页模板:参考 B 站布局,展示头像、用户名、个性签名、UID、加入时间及稿件列表
- 导航栏:用户名链接指向 /space,新增「设置」入口
- 分层实现:SpaceController → SpaceService → PostRepo.FindByUserID,严格 ISP 接口隔离
2026-05-30 19:06:10 +08:00
40f0529cbf docs: 更新 README,移除暂停开发通知,补充 Vditor 编辑器与帖子功能
- 移除状态 badge(status-hiatus)和暂停说明
- 技术栈新增 Vditor 3.11 编辑器
- 当前功能新增:帖子 CRUD、代码高亮、Shortcode 卡片、草稿、审核工作流
- 计划中去掉已完成的帖子系统,新增评论系统
2026-05-30 15:59:06 +08:00
6ec12010f3 feat: Tiptap替换为Vditor + MD存储重构 + Shortcode卡片扩展
- 编辑器:Tiptap 替换为 Vditor 3.11.2(wysiwyg 模式,本地托管 5.8MB,去 CDN)
- 存储:Body 字段从 HTML 改为 Markdown,去除 BodyHTML,新增 Excerpt 列表摘要
- 安全:去除 bluemonday 依赖(MD 纯文本无 XSS 风险),go.mod 已清理
- Shortcode:新增 [zone:type:params] 扩展语法,支持活动/游戏/投票/资源卡片
- 图片上传:POST /api/posts/upload-image 直接返回 Vditor 原生响应格式
- 草稿:localStorage 键名改为 draft_post_body_md,与旧 HTML 草稿隔离
- 详情页:Vditor.method.min.js 客户端 MD → HTML 渲染,去 highlight.js CDN
- 样式:去 Tiptap 样式 ~190 行,精简工具栏 CSS,新增卡片样式
- 文档:vditor-migration-plan.md 完整记录迁移决策、架构、用法
2026-05-30 15:56:22 +08:00
324 changed files with 28197 additions and 3118 deletions

View File

@ -3,3 +3,4 @@
DATABASE_PASSWORD=your_password_here DATABASE_PASSWORD=your_password_here
JWT_SECRET=generate-a-random-64-char-string-here JWT_SECRET=generate-a-random-64-char-string-here
REDIS_PASSWORD=your_redis_password_here

View File

@ -1,10 +1,16 @@
> [!IMPORTANT]
> **本项目已迁移至 [MetaZone Community Engine (MCE)](https://git.metazone.cc/MetaZone/mce)**
>
> MetaLab 已更名为 MCE 并继续开发。此仓库仅为历史存档,不再接受 Pull Request、Issue 或任何代码更新。
>
> 请前往 👉 **[mce](https://git.metazone.cc/MetaZone/mce)** 获取最新版本。
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go" alt="Go 1.26"> <img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go" alt="Go 1.26">
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12"> <img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
<img src="https://img.shields.io/badge/GORM-1.31-7A6F5D?style=flat-square" alt="GORM 1.31"> <img src="https://img.shields.io/badge/GORM-1.31-7A6F5D?style=flat-square" alt="GORM 1.31">
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL"> <img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL 3.0"> <img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL 3.0">
<img src="https://img.shields.io/badge/status-hiatus-red?style=flat-square" alt="status">
</p> </p>
<h1 align="center">MetaLab</h1> <h1 align="center">MetaLab</h1>
@ -17,17 +23,6 @@
MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者提供一个**平等、纯粹、开放**的技术交流空间。 MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者提供一个**平等、纯粹、开放**的技术交流空间。
> **当前状态:开发已暂停**。
### ⚠️ 暂停说明
本项目自 2026 年 5 月起暂停开发。主要原因:
- **编辑器集成问题未解决**——作为技术社区的核心功能,尝试了多种富文本/Markdown 编辑器方案,在 SSR + 原生 JS 架构下均无法实现满意的集成效果
- **优先保证社区上线**——经评估,社区早日上线运行的价值高于自建所有基础设施,决定先采用 [Flarum](https://flarum.org)MIT 协议)作为社区平台
本仓库代码保留,后续可能继续开发。
**核心理念:** **核心理念:**
- 🎯 **专注技术**——代码、架构、逻辑与可验证的事实 - 🎯 **专注技术**——代码、架构、逻辑与可验证的事实
- 🤝 **平等交流**——没有特权,每个成员权利平等 - 🤝 **平等交流**——没有特权,每个成员权利平等
@ -43,7 +38,7 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
| 数据库 | **PostgreSQL** | 16+ | | 数据库 | **PostgreSQL** | 16+ |
| 认证 | **JWT** (HS256) | v5 | | 认证 | **JWT** (HS256) | v5 |
| 密码加密 | **bcrypt** (cost=12) | — | | 密码加密 | **bcrypt** (cost=12) | — |
| 配置 | **Viper** | 1.21 | | 编辑器 | **Vditor** | 3.11 |
| 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — | | 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — |
| 架构 | 分层六边形架构 | — | | 架构 | 分层六边形架构 | — |
@ -61,12 +56,17 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
-**用户设置页**——个人资料修改(用户名、个性签名、头像上传与裁切) -**用户设置页**——个人资料修改(用户名、个性签名、头像上传与裁切)
-**头像处理**——JPEG/PNG/WebP 上传方形裁切512×512 WebP 编码 ≤100KB -**头像处理**——JPEG/PNG/WebP 上传方形裁切512×512 WebP 编码 ≤100KB
-**消息中心**——审核通知推送SSR 消息页面,单条/全部标为已读未读数角标1/66/99+ -**消息中心**——审核通知推送SSR 消息页面,单条/全部标为已读未读数角标1/66/99+
-**帖子系统**——Markdown 发布/编辑/删除Vditor 所见即所得编辑器,图片粘贴/拖拽上传
-**代码高亮**——highlight.js 60+ 主题,支持 30+ 编程语言
-**Shortcode 卡片**——`[zone:event:ID]` 活动/游戏/投票/资源等可扩展卡片
-**草稿自动保存**——localStorage 草稿,每 2 秒自动保存
-**审核工作流**——帖子 draft → pending → approved/rejected管理员审核面板
-**多主题支持**——可切换的前端主题系统 -**多主题支持**——可切换的前端主题系统
## 计划中 ## 计划中
- 🔲 帖子系统CRUD + Markdown
- 🔲 话题/分类导航 - 🔲 话题/分类导航
- 🔲 评论系统
- 🔲 邮箱验证 - 🔲 邮箱验证
- 🔲 全文搜索 - 🔲 全文搜索
- 🔲 回收站(用户数据彻底清理) - 🔲 回收站(用户数据彻底清理)

View File

@ -3,12 +3,14 @@ package main
import ( import (
"log" "log"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/router" "metazone.cc/metalab/internal/router"
"metazone.cc/metalab/internal/theme" "metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -25,10 +27,15 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("连接数据库失败: %v", err) log.Fatalf("连接数据库失败: %v", err)
} }
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}); err != nil { if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil {
log.Fatalf("数据库迁移失败: %v", err) log.Fatalf("数据库迁移失败: %v", err)
} }
// 初始化公户行id=1, balance=0如已存在则忽略
if err := db.FirstOrCreate(&model.CommunityFund{ID: 1, Balance: 0}).Error; err != nil {
log.Fatalf("初始化公户失败: %v", err)
}
// 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行) // 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行)
// 支持软删除用户邮箱复用 // 支持软删除用户邮箱复用
migrations := []string{ migrations := []string{
@ -48,6 +55,9 @@ func main() {
r := gin.Default() r := gin.Default()
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变)
common.ComputeAssetHashes("templates/MetaLab-2026")
// 模板加载(前端主题 + 管理后台) // 模板加载(前端主题 + 管理后台)
tmpl, err := theme.LoadTemplates( tmpl, err := theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""}, theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
@ -65,6 +75,20 @@ func main() {
r.Static("/uploads/avatars", "./storage/uploads/avatars") r.Static("/uploads/avatars", "./storage/uploads/avatars")
r.Static("/uploads/posts", "./storage/uploads/posts") r.Static("/uploads/posts", "./storage/uploads/posts")
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
var redisClient *redis.Client
if cfg.Redis.Enabled {
redisClient, err = common.NewRedisClient(cfg.Redis)
if err != nil {
log.Printf("Redis 不可达,启动后首次请求将自动降级,后续恢复自动切回: %v", err)
// 不置 nilgo-redis 自带重连FallbackStore 负责健康检测与恢复
} else {
log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
}
} else {
log.Println("Redis 未启用,使用内存存储")
}
// 站点设置管理器DB 持久化 + 内存缓存) // 站点设置管理器DB 持久化 + 内存缓存)
siteSettings, err := config.NewSiteSettings(db) siteSettings, err := config.NewSiteSettings(db)
if err != nil { if err != nil {
@ -72,7 +96,7 @@ func main() {
} }
// 路由注册 // 路由注册
router.Setup(r, db, cfg, siteSettings) router.Setup(r, db, cfg, siteSettings, redisClient)
// 启动 // 启动
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port) log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)

View File

@ -13,11 +13,20 @@ database:
dbname: metalab_dev dbname: metalab_dev
sslmode: disable sslmode: disable
jwt: # 会话配置(服务端 Session滑动窗口续期
secret: "" # 生产环境通过 JWT_SECRET 环境变量或 .env 注入 # 每次请求自动续期,解决"记住我"掉线问题
access_expire: 15 # 分钟 session:
refresh_expire: 168 # 小时 (7 天) idle_timeout: 120 # 不记住我空闲超时分钟120 = 2 小时
remember_expire: 720 # 小时 (30 天) remember_timeout: 43200 # 记住我:空闲超时(分钟),默认 43200 = 30 天
cleanup_interval: 300 # 后台清理过期会话间隔(秒),默认 300仅内存模式使用
# Redis 配置可选enabled: false 时使用内存存储)
redis:
enabled: true # 是否启用 Redis 存储会话
host: 127.0.0.1
port: 6379
password: "" # 敏感值由 .env 注入
db: 0
bcrypt: bcrypt:
cost: 12 cost: 12

View File

@ -0,0 +1,426 @@
# MetaLab 项目全量代码审计报告
**审计日期**: 2025-05-31
**审计范围**: `lab.metazone.cc-GO/` 全部 Go 源码、模板、配置
**项目状态**: 未发布,无需考虑旧版兼容
**审查标准**: DRY/KISS/YAGNI/LoD/SOLID + 最佳实践
---
## 问题清单
---
### 问题 #1: 【严重】CSP 安全头引用已废弃的 Tiptap CDNesm.sh
**类型**: 死代码 / 残留配置
**位置**: `internal/middleware/security.go:12-21`
**违反原则**: KISS引用了不存在的依赖
```go
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js)
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
"connect-src 'self' https://esm.sh"
```
**问题**: 项目已迁移到 Vditor 编辑器,但 CSP 头仍保留 Tiptap 时代的 esm.sh CDN 白名单。三个指令script-src、style-src、connect-src都包含未使用的 `https://esm.sh`
**风险**:
- 扩大了不必要的 CSP 白名单,引入额外信任域
- connect-src 允许到 esm.sh 的连接,可能泄露页面信息
**建议**: 移除所有 `https://esm.sh` 引用highlight.js 主题已本地化为 73 个静态文件(`static/vditor/dist/js/highlight.js/styles/`CSP 可完全收紧为 `'self'`
---
### 问题 #2: 【严重】Login() 中封禁状态检查缺少防时序攻击保护
**类型**: 安全缺陷
**位置**: `internal/service/auth_service.go:151-154`
**违反原则**: 最佳安全实践
```go
// 封禁
if user.Status == model.StatusBanned {
return nil, common.ErrUserBanned // ← 没有 bcrypt dummy hash 比对
}
```
**问题**: 其他分支维护模式、用户不存在、密码错误、StatusLocked都有 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)` 防时序攻击,唯独 StatusBanned 分支缺失。攻击者可以通过响应时间差异判断被封禁的账号是否存在。
**建议**: 在返回 `ErrUserBanned` 前增加 dummy hash 比对:
```go
if user.Status == model.StatusBanned {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrUserBanned
}
```
---
### 问题 #3: 【严重】自定义 constantTimeEq 不如 crypto/subtle 安全
**类型**: 安全缺陷 / 最佳实践
**位置**: `internal/middleware/csrf_token.go:44-53`(被 `csrf.go:47` 调用)
**违反原则**: 安全最佳实践
```go
// constantTimeEq 恒定时间字符串比较(防时序攻击)
func constantTimeEq(a, b string) bool {
if len(a) != len(b) { // ← 长度不等时提前返回,泄露长度信息
return false
}
var result byte
for i := 0; i < len(a); i++ {
result |= a[i] ^ b[i]
}
return result == 0
}
```
**问题**:
1. `constantTimeEq` 在同包内被 `csrf.go:47` 调用,**非死代码**
2. 但其手写实现存在两个安全隐患:
- **长度提前返回泄露信息**`len(a) != len(b)` 时立即返回 false攻击者可通过响应时间判断 CSRF token 长度
- **无编译器优化防护**`crypto/subtle.ConstantTimeCompare` 内部使用了特殊的编译器屏障防止被优化,手写版本可能被 Go 编译器优化掉 XOR 结果检查
3. 函数定义在 `csrf_token.go`token 生成文件)而非 `csrf.go`(校验文件),逻辑归属不当
**建议**: 删除 `constantTimeEq`,改用 `crypto/subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) == 1`。这也是 Go 官方推荐的做法。
---
### 问题 #4: 【高】Admin 控制器锁定操作消息显示错误
**类型**: Bug
**位置**: `internal/controller/admin/admin_controller.go:93-96`
**违反原则**: 无(纯 bug
```go
action := "封禁"
if req.Status == model.StatusActive {
action = "解封"
} else if req.Status == model.StatusLocked {
action = "已删除" // ← BUG: 应该是 "已锁定"
}
```
**问题**: 当管理员执行锁定操作时,成功提示消息显示"已删除成功",而不是"已锁定成功"。Locked 与 Deleted 是两个完全不同的状态。
**建议**: 改为 `action = "已锁定"`
---
### 问题 #5: 【高】Login() 中密码验证方式不一致
**类型**: 代码一致性问题
**位置**: `internal/service/auth_service.go:138,152`
**违反原则**: KISS同一逻辑用了两种实现
```go
// StatusDeleted 分支:
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
// StatusBanned 之后的主路径:
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
```
`CheckPassword` 内部只是封装了单行:
```go
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
```
**问题**: 虽然功能上没有 bug但在同一函数中既有 `common.CheckPassword()` 调用,也有其他分支使用 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)`(直接调用 bcrypt。风格不统一`CheckPassword` 的封装价值极低(仅包装一行标准库调用,不如直接使用 bcrypt 调用更直观)。
**建议**:
- 选项A: 删除 `common.CheckPassword`,统一使用 `bcrypt.CompareHashAndPassword`
- 选项B: 在 `CheckPassword` 中增加防时序的一致性包装,统一入口
---
### 问题 #6: 【中】大量空模板目录YAGNI 违规)
**类型**: YAGNI 违规
**位置**:
- `templates/MetaLab-2026/html/post/`(空)
- `templates/MetaLab-2026/html/comment/`(空)
- `templates/MetaLab-2026/html/partials/`(空)
- `templates/MetaLab-2026/html/search/`(空)
- `templates/MetaLab-2026/html/error/`(空)
- `templates/MetaLab-2026/html/admin/`(空)
- `templates/system/email/`(空)
**问题**: 7 个空目录,标注为"预留"。项目尚未发布,这些目录的创建时机应该和实际功能开发同步,而非提前占位。
**建议**: 删除所有空目录。需要时随功能一起创建。
---
### 问题 #7: 【中】Shortcode 预留类型poll/resource无后端实现
**类型**: YAGNI 违规
**位置**: `internal/model/shortcode.go:37-38`, `internal/service/shortcode_service.go:126-137`
**违反原则**: YAGNI
```go
ShortcodePoll ShortcodeType = "poll" // ※预留后端API未实现
ShortcodeResource ShortcodeType = "resource" // ※预留后端API未实现
```
**问题**: poll 和 resource 两个 shortcode 类型的后端 API 未实现前端也未实现shortcode.js 中可能也未实现对应渲染),但代码中已注册了完整的解析和占位 HTML 生成逻辑。用户实际上可以使用 `[zone:poll:xxx]` 语法,但会得到一个永远"加载中..."的卡片。
**状态**: 已排期开发,保留(不删除)。首次审计时误删,已恢复。前端渲染逻辑将随 API 同步实现。
---
### 问题 #8: 【中】redis_store.go 全注释的"预留实现"
**类型**: YAGNI / 死代码
**位置**: `internal/session/redis_store.go`
**违反原则**: YAGNI
**问题**: 整个文件是注释掉的代码,没有实际可执行逻辑。如果未来需要 Redis 支持,到时再创建即可。
**状态**: 已排期开发保留不删除。首次审计时误删已恢复。Redis 支持将在后续迭代中实现。
---
### 问题 #9: 【中】tokenCtrl 是 authCtrl 的无意义别名
**类型**: 不必要的字段重复
**位置**: `internal/router/deps_core.go:30`, `internal/router/deps_extra.go:54`
**违反原则**: KISS
```go
// deps_core.go
type dependencies struct {
// ...
tokenCtrl *controller.AuthController // ← 与 authCtrl 类型完全相同
}
// deps_extra.go
return &dependencies{
// ...
tokenCtrl: authCtrl, // ← 赋的是同一个对象
}
```
**问题**: `tokenCtrl``authCtrl` 指向同一个 `*controller.AuthController` 实例,`tokenCtrl` 仅在 `api.go` 的路由中使用(`d.tokenCtrl.CheckEmail/Register/Login/...`)。这个别名不带来任何好处,反而增加理解成本。
**建议**: 删除 `tokenCtrl` 字段,`api.go` 中直接使用 `d.authCtrl`
---
### 问题 #10: 【中】RateLimiter.check() 存在竞态条件
**类型**: 并发缺陷
**位置**: `internal/middleware/ratelimit_core.go:34-81`
```go
func (rl *RateLimiter) check(...) (RateLimitResult, func()) {
rl.mu.Lock()
defer rl.mu.Unlock()
// ... 读取 state ...
recordFail := func() {
rl.mu.Lock() // ← 重新获取锁
defer rl.mu.Unlock()
// ... 修改 state ...
}
return RateLimitResult{Blocked: false}, recordFail
}
```
**问题**: `check()` 持锁检查后释放锁,返回的 `recordFail` 闭包在**锁外**执行,重新获取锁后再修改状态。
**修复方案**: 将 `check()` + `recordFail` 闭包模式重构为 `try()` 原子操作模式——在持锁状态下一次性完成检查+递增消除竞态窗口。API 改为 `AllowAccount() RateLimitResult` / `AllowIP() RateLimitResult`(不再返回闭包)。调用方在失败时不再需要显式调用 `recordFail()`,成功时仍调用 `Clear()` 清除计数。
---
### 问题 #11: 【低】audit_service.go review() 注释编号跳跃
**类型**: 文档瑕疵
**位置**: `internal/service/audit_service.go:152-166`
```go
// 3. 查审核人信息
reviewer, err := s.userRepo.FindByID(reviewerID)
// ...
// 5. 标记审核结果 ← 跳过了 4
submission.ReviewedBy = &reviewerID
```
**问题**: 注释编号从"3."直接跳到"5.",缺少"4."。
**建议**: 修正编号为连续递增。
---
### 问题 #12: 【低】编译产物未纳入 .gitignore误报实际不存在
**类型**: 仓库整洁性
**位置**: `server`(根目录), `cmd/server/server`
**状态**: 误报。经核实,`.gitignore` 已配置 `server` 规则且从未被 git 跟踪,`git rm --cached` 实际为空操作。此项已从修复表中移除。
---
### 问题 #13: 【低】项目零测试覆盖
**类型**: 质量保障缺失
**位置**: 整个项目(`*_test.go` 搜索结果: 0
**问题**: 项目没有任何单元测试或集成测试。对于包含认证、权限、审核、数据持久化等复杂逻辑的系统,零测试意味着每次重构和修改都有回归风险。
**建议**: 至少为核心模块添加测试:
1. `model/user.go` - HasMinRole/CanOperateRole纯函数易测
2. `service/auth_service.go` - 注册/登录/密码验证逻辑
3. `service/admin_service.go` - checkAndOperate 权限矩阵
4. `middleware/ratelimit_core.go` - 限流逻辑
---
### 问题 #14: 【低】全项目使用 log.Printf 无结构化日志
**类型**: 可维护性 / 可观测性
**位置**: 10 个文件,约 17 处 `log.Printf` 调用
**问题**: 项目大量使用标准库 `log.Printf`,无日志级别、无结构化字段、无上下文追踪。在生产环境中排查问题困难,无法按级别过滤日志。
**建议**: 引入轻量结构化日志库(如 `slog`Go 1.21+ 标准库),按级别区分 Info/Warn/Error关键路径添加 trace/request ID。
---
### 问题 #15: 【低】err != nil 返回时上下文信息丢失
**类型**: 可调试性
**位置**: 多处,例如 `internal/repository/user_repo.go`
```go
func (r *UserRepo) Create(user *model.User) error {
return r.db.Create(user).Error // ← 无上下文
}
```
**问题**: 数据库操作失败时,调用方只知道"出错了",无法快速定位是哪个操作、哪个实体、哪个 ID 导致的失败。
**建议**: 使用 `fmt.Errorf("创建用户失败: %w", err)` 包装错误,在保持错误链的同时添加操作上下文。
---
### 问题 #16: 【低】common.CheckPassword 封装价值极低
**类型**: KISS 违规
**位置**: `internal/common/crypto.go:12-15`
```go
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
```
**问题**: 仅包装一行标准库调用,无额外逻辑。与同一文件中封装了 bcrypt 成本参数的 `HashPassword` 不同,`CheckPassword` 没有提供抽象价值。反而因为隐藏了 `bcrypt.CompareHashAndPassword` 的调用,在需要 `dummyHash` 比对时(如 auth_service.go 的时序攻击防护)不得不绕过它直接调用 bcrypt。
**建议**:
- 如果保留 `CheckPassword`,将 dummy hash 比对也内置进去
- 或者删除此函数,直接在各处显式调用 `bcrypt.CompareHashAndPassword`
---
### 问题 #17: 【低】deps_core.go 中文注释错别字
**类型**: 文档瑕疵
**位置**: `internal/router/deps_core.go:45`
```go
// 启动后台过清理 goroutine
```
**问题**: "过清理"应为"过期清理",少了一个"期"字。
**建议**: 修正为 `启动后台过期清理 goroutine`
---
## 汇总统计
| 严重程度 | 数量 | 问题编号 |
|---------|------|---------|
| 严重 | 3 | #1, #2, #3 |
| 高 | 2 | #4, #5 |
| 中 | 5 | #6, #7, #8, #9, #10 |
| 低 | 7 | #11, #12, #13, #14, #15, #16, #17 |
**总计: 17 个问题**
### 按原则分类
| 原则 | 问题编号 |
|------------|---------|
| 安全 | #1, #2, #3 |
| YAGNI | #6, #7, #8 |
| KISS | #5, #9, #16 |
| Bug | #4, #10 |
| 质量/可维护性 | #12, #13, #14, #15 |
| 文档 | #11, #17 |
---
## 整体评价
项目的分层架构设计合理严格遵循单向依赖router→controller→service→repository→model接口隔离原则ISP执行到位每层都通过最小接口依赖下层。依赖注入清晰无循环依赖。
主要问题集中在三个方面:
1. **安全防护需加强** - CSP 配置残留、时序攻击防护不完整
2. **代码清理不及时** - 存在死代码、预留目录、未使用函数
3. **工程基础设施薄弱** - 零测试、无结构化日志、错误上下文丢失
建议优先处理严重级别问题(#1~#3),然后按批次逐步处理其余问题。
---
## 修复记录
**修复日期**: 2025-05-31
**执行方式**: 全量修复commit: `fix: 审计问题全量修复(安全/YAGNI/Bug/代码质量)`
### 已修复13 项)
| # | 修复内容 | 变更文件 |
|---|---------|---------|
| 1 | 移除 CSP 中 esm.sh (Tiptap 残留) 和 cdnjs.cloudflare.com主题已本地化CSP 全面收紧为 'self' | `middleware/security.go` |
| 2 | StatusBanned 分支增加 dummy hash 防时序攻击 | `service/auth_service.go` |
| 3 | 删除手写 constantTimeEq改用 `crypto/subtle.ConstantTimeCompare` | `middleware/csrf_token.go`, `middleware/csrf.go` |
| 4 | 锁定操作消息修正"已删除"→"已锁定" | `controller/admin/admin_controller.go` |
| 5 | 删除 common.CheckPassword 薄封装,统一改用 bcrypt.CompareHashAndPassword | `common/crypto.go`, `service/auth_service.go` |
| 6 | 删除 12 个空预留目录(含第二轮追加 5 个) | `templates/MetaLab-2026/html/{post,comment,partials,search,error,admin,topic,user}`, `templates/{system,system/email}`, `templates/MetaLab-2026/static/{img,vendor}` |
| 9 | 删除 tokenCtrl 别名字段,统一使用 authCtrl | `router/deps_core.go`, `router/deps_extra.go`, `router/api.go` |
| 10 | 将 check()+recordFail 闭包重构为 try() 原子操作,消除竞态 | `middleware/ratelimit_core.go`, `middleware/ratelimit.go`, `middleware/ratelimit_cleanup.go`, `controller/auth_api_login.go`, `controller/auth_api_register.go` |
| 11 | 修正 review() 注释编号 3→5→4 | `service/audit_service.go` |
| 17 | 修正"过清理"→"过期清理" | `router/deps_core.go` |
### 已回滚(误删,已排期开发,保留)
| # | 回滚内容 | 变更文件 |
|---|---------|---------|
| 7 | 恢复 poll/resource shortcode 类型(误删) | `model/shortcode.go`, `service/shortcode_service.go` |
| 8 | 恢复 redis_store.go 预留实现(误删) | `session/redis_store.go` |
### 误报(实际不存在)
| # | 说明 |
|---|------|
| 12 | 编译产物从未被 git 跟踪,`.gitignore` 已生效,`git rm --cached` 为空操作 |
### 暂缓修复3 项)
| # | 暂缓原因 | 后续计划 |
|---|---------|---------|
| 13 | 零测试 — 需要建立测试框架、mock 策略,工作量大 | 核心模块优先hasMinRole、checkAndOperate、RateLimiter |
| 14 | 无结构化日志 — 需评估 slog vs zap全量替换 log.Printf | Go 1.21+ 使用标准库 slog 渐进替换 |
| 15 | 错误上下文丢失 — 涉及全部 repository 层,工作量大 | 按文件逐批添加 `fmt.Errorf("...: %w", err)` |

View File

@ -0,0 +1,27 @@
-- Migration: 重命名 BaseModel 使用表的主键列 uid → id
-- 涉及表: users, audit_submissions, notifications, user_checkins, user_tasks
-- 执行前请备份数据库
BEGIN;
-- users 表主键及序列重命名
ALTER TABLE users RENAME COLUMN uid TO id;
ALTER SEQUENCE IF EXISTS users_uid_seq RENAME TO users_id_seq;
-- audit_submissions 表主键及序列重命名
ALTER TABLE audit_submissions RENAME COLUMN uid TO id;
ALTER SEQUENCE IF EXISTS audit_submissions_uid_seq RENAME TO audit_submissions_id_seq;
-- notifications 表主键及序列重命名
ALTER TABLE notifications RENAME COLUMN uid TO id;
ALTER SEQUENCE IF EXISTS notifications_uid_seq RENAME TO notifications_id_seq;
-- user_checkins 表主键及序列重命名
ALTER TABLE user_checkins RENAME COLUMN uid TO id;
ALTER SEQUENCE IF EXISTS user_checkins_uid_seq RENAME TO user_checkins_id_seq;
-- user_tasks 表主键及序列重命名
ALTER TABLE user_tasks RENAME COLUMN uid TO id;
ALTER SEQUENCE IF EXISTS user_tasks_uid_seq RENAME TO user_tasks_id_seq;
COMMIT;

231
docs/post-system-audit.md Normal file
View File

@ -0,0 +1,231 @@
# 帖子系统代码审计报告
> 审计日期2026-05-30
> 审计范围:`internal/` 下所有 Post 相关文件router / controller / service / repository / model
> 审计标准DRY / KISS / YAGNI / LoD / SOLID + 分层架构规范
---
## 一、架构合规性总览
| 检查项 | 状态 | 说明 |
|--------|------|------|
| 分层依赖方向 | ✅ | router→controller→service→repo→model严格单向 |
| Controller 接口文件 | ✅ | `controller/interfaces.go` + `admin/interfaces.go` |
| Service Repository 接口 | ✅ | `service/repository.go`8 个方法,无冗余 |
| ISP 接口隔离 | ✅ | 前台 `postUseCase` 与后台 `adminPostUseCase` 分离 |
| DIP 依赖倒置 | ✅ | Controller→接口, Service→接口 |
| 依赖注入 | ✅ | 全部构造函数注入,无硬编码 |
| KISS | ✅ | 无过度设计 |
| YAGNI | ✅ | 无超前功能 |
| LoD | ✅ | 无跨层直接依赖 |
---
## 二、问题清单
### 🔴 中等问题4 个)
#### 问题 1Repository 方法代码重复(~70%
| 项 | 详情 |
|----|------|
| **文件** | `internal/repository/post_repo.go` |
| **位置** | `FindPageable`(行 49-73vs `FindAdminPageable`(行 76-102 |
| **违反原则** | DRY |
| **描述** | 两个方法的核心查询逻辑Table + Select + Joins + keyword LIKE + Count + Offset/Limit + ORDER BY几乎完全一致唯一区别是 `FindPageable` 固定过滤 `status = 'approved'``FindAdminPageable` 支持可选的 status 参数。两段代码约 70% 重复。 |
**现状:**
```go
// FindPageable (行 49-73)
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL").
Where("posts.status = ?", model.PostStatusApproved) // ← 唯一差异
if keyword != "" { /* LIKE 过滤 */ }
// ... Count + Offset/Limit + Find
}
// FindAdminPageable (行 76-102)
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL")
// ← 无硬编码 status由参数控制
if keyword != "" { /* LIKE 过滤 */ }
if status != "" { query = query.Where("posts.status = ?", status) }
// ... Count + Offset/Limit + Find
}
```
**建议修复:** 提取私有方法 `findPageableCommon`,两个公开方法调用它并传入各自的 WHERE 条件。
---
#### 问题 2Service 方法重复
| 项 | 详情 |
|----|------|
| **文件** | `internal/service/post_service.go` |
| **位置** | `List`(行 148-156vs `ListAdmin`(行 159-167 |
| **违反原则** | DRY |
| **描述** | 两个方法的 nil 检查 + 分页调用模式完全一致,唯一区别是调用的 repo 方法不同。 |
**现状:**
```go
// List (行 148-156)
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
posts, total, err := s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
if posts == nil { posts = []model.Post{} }
return posts, total, err
}
// ListAdmin (行 159-167)
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
posts, total, err := s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
if posts == nil { posts = []model.Post{} }
return posts, total, err
}
```
**建议修复:** 提取公共的 Pagination 创建 + nil 检查逻辑。
---
#### 问题 3Controller 权限检查重复 6 处
| 项 | 详情 |
|----|------|
| **文件** | `internal/controller/post_controller.go` |
| **位置** | `ShowPage`(行 82-90)、`EditPage`(行 144-150)、`Update`(行 202-211)、`Delete`(行 239-248)、`Submit`(行 272-281)、`ShowAPI`(行 333-339) |
| **违反原则** | DRY |
| **描述** | 以下模式在 6 个方法中逐字重复: |
```go
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
if !model.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return
}
```
**建议修复:** 提取私有方法 `getPostAndCheckAccess(id, c)` 返回 `(*model.Post, bool)`
---
#### 问题 4Model 层职责过重
| 项 | 详情 |
|----|------|
| **文件** | `internal/model/post.go` |
| **位置** | 行 48-83 |
| **违反原则** | SRP单一职责 |
| **描述** | 一个文件混合了多种职责: |
| 内容 | 类型 | 应在位置 |
|------|------|---------|
| `Post` struct | 数据模型 | ✅ Model 层 |
| `PostStatusDraft` 等常量 | 状态常量 | ✅ Model 层 |
| `PostStatusDisplayNames` | 视图映射 | ❌ 应移到 View/Controller 层 |
| `PostListResult` | DTO | ❌ 应移到 `model/dto.go` |
| `PostCreateRequest` / `PostUpdateRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
| `PostRejectRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
| `PostListQuery` | 查询 DTO | ❌ 应移到 `model/dto.go` |
| `IsPostAccessible` | 业务权限逻辑 | ❌ 应移到 Service 层 |
**建议修复:** DTO 结构体移到 `model/dto.go``PostStatusDisplayNames` 移到 common 或 controller`IsPostAccessible` 移到 service 层或独立权限模块。
---
### 🟡 轻微问题3 个)
#### 问题 5工具函数位置不当
| 项 | 详情 |
|----|------|
| **文件** | `internal/controller/post_controller.go` |
| **位置** | `saveUploadedFile`(行 410-430 |
| **违反原则** | SRP / LoD |
| **描述** | `saveUploadedFile` 是通用文件 I/O 工具函数(创建目录 + 32KB buffer 循环写入),与 HTTP 处理无关,不依赖 controller 的任何字段。放在 controller 文件中职责不匹配。 |
**建议修复:** 移到 `internal/common/` 或新建 `internal/util/` 包。
---
#### 问题 6Controller 层分页重复初始化
| 项 | 详情 |
|----|------|
| **文件** | `internal/controller/post_controller.go` |
| **位置** | `ListPage`(行 47-49`ListAPI`(行 307-308 |
| **违反原则** | DRY |
| **描述** | Controller 层为模板渲染再次创建 `Pagination` 对象并调用 `DefaultPagination()`,而 Service 层 `List()` / `ListAdmin()` 内部已经做过一次分页参数校验。Controller 层可以信任 Service 返回的 total 直接计算。 |
```go
// controller 层重复的:
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
// ... 然后调用 service.List()service 里又做了一遍
```
**建议修复:** Controller 层直接用 `common.Pagination.PageCount(total, pageSize)` 计算总页数,不再重复调用 `DefaultPagination()`
---
#### 问题 7Admin Restore 错误分类缺失
| 项 | 详情 |
|----|------|
| **文件** | `internal/controller/admin/admin_post_controller.go` |
| **位置** | `Restore`(行 153-155 |
| **违反原则** | 一致性 |
| **描述** | 同文件内其他方法(`Approve`/`Reject`/`Unlock`/`Lock`)都对 `ErrPostNotFound``errors.Is` 精确匹配返回 400`Restore` 没有,所有错误统一返回 500。 |
```go
// Restore — 缺少错误分类
func (ctrl *AdminPostController) Restore(c *gin.Context) {
// ...
if err := ctrl.postService.Restore(id); err != nil {
// ← 此处应匹配 ErrPostNotFound返回 400 而非 500
common.Error(c, http.StatusInternalServerError, err.Error())
return
}
}
```
**建议修复:** 添加 `errors.Is(err, common.ErrPostNotFound)` 判断,返回 400。
---
## 三、亮点
1. **状态机设计清晰**Post 状态流转draft → pending → approved/rejected → locked每个转换有明确前置条件检查和专用错误哨兵
2. **接口设计优秀**`postUseCase` vs `adminPostUseCase` 的分离体现良好的关注点分离
3. **错误哨兵统一管理**`common/errors.go` 集中定义所有 Post 相关错误
4. **审核开关灵活**:通过 `SiteSettings.IsAuditEnabled()` 运行时控制
5. **Shortcode 扩展性好**:新增类型只需添加常量和 case 分支
6. **软删除 + 恢复**:完善的软删除和恢复机制
---
## 四、优先级建议
| 优先级 | 问题编号 | 原因 |
|--------|---------|------|
| P0 | — | 无阻塞性问题 |
| P1 | 1, 3 | Repository/Controller 重复影响维护成本 |
| P2 | 2, 4 | Service 重复 + Model 职责拆分 |
| P3 | 5, 6, 7 | 轻微优化项 |

View File

@ -0,0 +1,319 @@
# Vditor 整合 + MD 存储迁移方案
> **状态:已实施 ✓** `2026-05-30`
## 决策与结果
| 决策 | 结论 | 结果 |
|------|------|------|
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist5.8MB(仅必需插件) |
---
## 最终数据流
```
编辑器: Vditor(wysiwyg) → vditor.getValue() → MD 纯文本
存储: 后端直接存 MD纯文本无 XSS 风险,无需 sanitize
└─ generateExcerpt() 生成 plain text 摘要
显示: Vditor.preview() 客户端 MD → HTML + github-dark 代码主题
```
---
## 变更文件清单11 个)
| 文件 | 改动 | 详情 |
|------|------|------|
| `internal/model/post.go` | 修改 | `Body` → MD 存储,去 `BodyHTML`,加 `Excerpt`varchar 500 |
| `internal/service/post_service.go` | 重写 | 去 bluemonday`generateExcerpt()`(纯 Go regex无外部依赖 |
| `internal/controller/post_controller.go` | 修改 | `UploadImage` → Vditor 原生格式,`ShowPage` 去 BodyHTML`html/template` import |
| `internal/common/response.go` | 新增函数 | `VditorUploadOk()` — Vditor 图片上传成功响应 |
| `templates/.../posts/new.html` | 重写 | Tiptap → Vditor去除 importmap/工具栏/语言选择器 |
| `templates/.../posts/show.html` | 重写 | Vditor 客户端 MD 渲染,去 highlight.js CDN去代码标签注入脚本 |
| `templates/.../posts/index.html` | 微调 | `Body` 截断 → `Excerpt`(带降级回退) |
| `templates/.../editor.js` | 重写 | Vditor 初始化 + CSRF + upload + draft + word count + submit |
| `templates/.../posts.css` | 删减 | 去 Tiptap 样式(~150 行)+ 工具栏样式(~40 行),加 Vditor 微调 |
| `static/vditor/dist/` | 新增 | 本地 Vditor 文件5.8MB |
| `go.mod / go.sum` | 清理 | 移除 bluemonday 依赖 |
**不涉及的路由/中间件/仓储:零改动。**
---
## 各层详细实现
### 1. Model `internal/model/post.go`
```go
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` // Markdown content
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary
// ... 其余字段不变
}
```
### 2. Service `internal/service/post_service.go`
- 删除 `sanitizePolicy`bluemonday UGC`sanitizeHTML()`
- 新增 `generateExcerpt(md string) string`
- 按行逐条去掉 MD 语法(代码块、图片、标题 #、粗体/斜体、引用 >、列表标记等)
- 链接保留文字 `[text](url)``text`
- 按 rune 截断 300 字符,末尾加 `...`
- 纯 Go `regexp` 实现,零外部依赖
- `Create()` / `Update()` 生成 `Excerpt`MD 直存无消毒
### 3. Controller `internal/controller/post_controller.go`
**UploadImage — Vditor 原生响应格式:**
```json
{
"code": 0,
"msg": "",
"data": {
"errFiles": [],
"succMap": {
"微信截图_2026.png": "/uploads/posts/1_1717071234567.png"
}
}
}
```
**ShowPage — 去掉 `PostBodyHTML`**MD 由前端渲染。
### 4. Common `internal/common/response.go`
```go
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"data": gin.H{
"errFiles": []string{},
"succMap": succMap,
},
})
}
```
### 5. 编辑器 `posts/new.html` + `editor.js`
**Vditor 初始化关键配置:**
```javascript
new Vditor('vditor', {
mode: 'wysiwyg',
cdn: '/static/vditor', // 所有动态加载走本地
height: '100%',
lang: 'zh_CN',
toolbar: [
'headings', 'bold', 'italic', 'strike', '|',
'line', 'code', 'inline-code', 'link', 'quote', '|',
'list', 'ordered-list', 'check', 'outdent', 'indent', '|',
'upload', 'table', '|',
'undo', 'redo', '|',
'fullscreen', 'code-theme', '|',
'outline', 'preview', 'devtools',
],
upload: {
url: '/api/posts/upload-image',
fieldName: 'file',
max: 5 * 1024 * 1024,
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
setHeaders() { // 每次请求重新读取 CSRF
const meta = document.querySelector('meta[name="csrf-token"]');
return { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' };
},
},
preview: {
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
},
})
```
**CSRF 处理**`upload.setHeaders` 为函数,每次上传前动态读取 `meta[name="csrf-token"]`,不会因 token 过期而失败。
**草稿机制**
- 每 2 秒自动保存 `title` + `md``localStorage`
- 键名:`draft_post_title` / `draft_post_body_md`(与旧 Tiptap HTML 草稿隔离)
- 新帖模式下自动恢复草稿
**表单提交**`vditor.getValue()` 获取 MD`POST /api/posts``PUT /api/posts/:id`
### 6. 详情页 `posts/show.html`
```html
<!-- MD 内容通过隐藏 textarea 安全传递给 JS -->
<textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
<!-- Vditor 仅需 method.min.js47KB不需要完整编辑器 -->
<script src="/static/vditor/dist/method.min.js"></script>
<script>
Vditor.preview(document.getElementById('postContent'), md, {
cdn: '/static/vditor',
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
});
</script>
```
### 7. 列表页 `posts/index.html`
```html
<!-- Excerpt 优先,降级回退 Body 截断(兼容旧数据) -->
<p class="post-card-summary">
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
</p>
```
### 8. 样式 `posts.css`
- 移除:`.tiptap` 及所有子选择器(~100 行、highlight.js 硬编码主题色(~35 行)、手动工具栏样式(~40 行、JS 注入代码标签 CSS
- 保留:`.editor-layout` / `.editor-header` / `.editor-main` / `.editor-pane` / `.editor-statusbar`(布局)
- 新增:`#vditor` flex 适配、`.vditor-toolbar` / `.vditor-content` 主题微调
---
## Vditor 本地文件结构
```
templates/MetaLab-2026/static/vditor/dist/
├── index.css # 43KB 编辑器 + 预览全部样式
├── index.min.js # 292KB 编辑器核心wysiwyg/sv/ir + 工具栏 + 上传)
├── method.min.js # 47KB 独立方法Vditor.preview 等,详情页用)
├── css/content-theme/
│ ├── light.css # 内容区明亮主题
│ ├── dark.css # 内容区暗色主题
│ ├── ant-design.css # Ant Design 主题
│ └── wechat.css # 微信主题
├── images/
│ ├── img-loading.svg # 上传进度指示
│ ├── logo.png # Vditor logo
│ └── emoji/ # 内置表情包b3log/octocat/doge 等)
├── js/
│ ├── highlight.js/
│ │ ├── highlight.min.js # 1.4MB 代码高亮核心
│ │ ├── third-languages.js # 额外语言支持
│ │ └── styles/*.min.css # 60+ 代码主题
│ ├── lute/
│ │ └── lute.min.js # 3.9MB WASM MD 解析器Vditor 核心依赖)
│ ├── i18n/
│ │ └── zh_CN.js # 8KB 中文语言包
│ └── icons/
│ ├── material.js # Material Design 图标
│ └── ant.js # Ant Design 图标
```
**已剔除的插件**(节省 16MB+
| 插件 | 大小 | 功能 | 对技术论坛无用 |
|------|------|------|---------------|
| mathjax | 6.5MB | LaTeX 数学公式 | ❌ |
| mermaid | 2.6MB | 流程图/时序图 | ❌ |
| graphviz | 2.0MB | 图谱渲染 | ❌ |
| katex | 1.5MB | 数学公式引擎 | ❌ |
| echarts | 1.0MB | 图表渲染 | ❌ |
| markmap | 836KB | 思维导图 | ❌ |
| abcjs | 356KB | 五线谱 | ❌ |
| plantuml | 32KB | PlantUML | ❌ |
| flowchart.js / smiles-drawer | ~400KB | 流程图 / 化学分子式 | ❌ |
---
## Shortcode 扩展(`[zone:type:params]`
### 语法
在 Markdown 正文中嵌入特殊卡片,语法为 `[zone:类型:参数]`
```markdown
# 周末活动汇总
下面是我们本周的推荐活动:
[zone:event:summer2026]
更多内容请关注官方动态...
```
### 已注册类型
| 语法 | 渲染结果 | 状态 |
|------|---------|------|
| `[zone:event:活动ID]` | 活动卡片(🎪 图标 + ID + 跳转链接) | ✅ 已实现(占位) |
| `[zone:game:游戏slug]` | 游戏卡片(🎮 图标 + slug + 跳转链接) | ✅ 已实现(占位) |
| `[zone:poll:投票ID]` | 投票组件 | ⏳ 预留 |
| `[zone:resource:资源ID]` | 资源推荐卡片 | ⏳ 预留 |
### 架构
```
MD body "[zone:event:summer2026]"
ShortcodeService.Process() ← 后端:正则替换 [zone:...] → 占位 <div>
VDitor.preview() ← 前端MD → HTML占位 div 原样保留
renderShortcodes() ← 前端 shortcode.js扫描 .zone-card渲染 UI 卡片
```
### 文件清单
| 文件 | 职责 |
|------|------|
| `internal/model/shortcode.go` | 类型常量 + 正则 + DTO |
| `internal/service/shortcode_service.go` | 解析器 + 占位 HTML 生成器 |
| `internal/controller/post_controller.go` | ShowPage/ShowAPI 调用 Process() |
| `internal/router/deps_extra.go` | DI 注入 ShortcodeService |
| `templates/.../js/shortcode.js` | 前端卡片渲染器 |
| `templates/.../html/posts/show.html` | 引入 shortcode.js + 调用 renderShortcodes() |
| `templates/.../static/css/posts.css` | 卡片样式 |
| `templates/.../static/js/editor.js` | hint 自动补全提示 |
### 如何添加新类型
1. `internal/model/shortcode.go`:注册 `ShortcodeType` 常量
2. `internal/service/shortcode_service.go`:在 `renderPlaceholder()` 添加 case
3. `templates/.../js/shortcode.js`:在 `cardRenderers` 注册渲染函数
4. `templates/.../static/js/editor.js`:在 `hint.extend` 添加补全提示
### 注意事项
- 未注册或格式错误的 shortcode **不报错**,原文保留,避免破坏用户内容
- 占位 div 结构为 `<div class="zone-card" data-zone-type="..." data-zone-id="...">`
- 编辑器输入 `[zone:` 时自动弹出补全列表Vditor hint.extend
- 后端 API 就绪后,将 `shortcode.js` 中的静态卡片替换为 `fetch()` 动态数据即可
---
## 不涉及的部分
- `internal/router/api.go` — 路由不变(`POST /api/posts/upload-image` 端点不变)
- `internal/router/frontend.go` — SSR 页面路由不变
- `internal/repository/post_repo.go` — 仓储不变Model 字段变更由 GORM 自动映射)
- `internal/middleware/` — CSRF / Auth 中间件不变
- `internal/config/` — 配置不变
- `cmd/server/main.go``Static()` 映射不变(`/static``templates/.../static`
---
## 注意事项
1. **DB Schema**GORM AutoMigrate 自动添加 `excerpt` 列;旧的 `body_html` 列残留但不影响运行GORM 不会 DROP可手动清理
2. **已有帖子**:旧 HTML body 在 MD 预览中会显示为 HTML 源码,可手动清理或通过 SQL 迁移
3. **CSRF**`upload.setHeaders` 为函数,每次上传前动态读取最新 token不受 token 过期影响
4. **时序**Vditor 主文件先加载lute/highlight.js/i18n 等由 Vditor 内部按需动态加载,无需手动控制
5. **bluemonday**:已从 `go.mod` 中移除(`go mod tidy` 自动清理)

9
go.mod
View File

@ -5,20 +5,20 @@ go 1.26.0
require ( require (
github.com/chai2010/webp v1.4.0 github.com/chai2010/webp v1.4.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/redis/go-redis/v9 v9.20.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0 golang.org/x/image v0.41.0
golang.org/x/sync v0.20.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
@ -29,7 +29,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
@ -54,10 +53,10 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.54.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect

20
go.sum
View File

@ -1,11 +1,15 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@ -37,13 +41,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -68,8 +68,6 @@ 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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -83,6 +81,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
@ -115,8 +115,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=

55
internal/cache/home_cache.go vendored Normal file
View File

@ -0,0 +1,55 @@
package cache
import (
"sync"
"time"
"metazone.cc/metalab/internal/model"
)
// HomePageCache 首页内存缓存30 秒 TTL创建/更新/删除/审核通过时失效
type HomePageCache struct {
mu sync.RWMutex
posts []model.Post
total int64
expires time.Time
ttl time.Duration
}
// NewHomePageCache 创建首页缓存ttl 为缓存有效期
func NewHomePageCache(ttl time.Duration) *HomePageCache {
return &HomePageCache{ttl: ttl}
}
// Get 返回缓存数据ok=false 表示缓存未命中或已过期
func (c *HomePageCache) Get() (posts []model.Post, total int64, ok bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if time.Now().After(c.expires) {
return nil, 0, false
}
// 返回浅拷贝以防止调用方修改缓存内部数据
copied := make([]model.Post, len(c.posts))
copy(copied, c.posts)
return copied, c.total, true
}
// Set 写入缓存数据
func (c *HomePageCache) Set(posts []model.Post, total int64) {
c.mu.Lock()
defer c.mu.Unlock()
// 深拷贝一份避免外部修改影响缓存
c.posts = make([]model.Post, len(posts))
copy(c.posts, posts)
c.total = total
c.expires = time.Now().Add(c.ttl)
}
// Invalidate 主动失效缓存
func (c *HomePageCache) Invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.posts = nil
c.total = 0
c.expires = time.Time{}
}

View File

@ -0,0 +1,20 @@
package common
import "github.com/gin-gonic/gin"
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
// 如果用户未登录ok 返回 falserole 可能为空字符串
func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) {
if v, exists := c.Get("uid"); exists {
if u, isUint := v.(uint); isUint {
uid = u
ok = true
}
}
if v, exists := c.Get("role"); exists {
if r, isStr := v.(string); isStr {
role = r
}
}
return
}

View File

@ -11,52 +11,30 @@ import (
// Cookie 名称常量 // Cookie 名称常量
const ( const (
CookieName = "mlb_token" SessionCookieName = "mlb_sid" // 会话 ID CookieHttpOnly
RefreshCookieName = "mlb_refresh"
RMCookieName = "mlb_rm" // 记住我标记JS 可读
) )
// SetAuthCookies 设置 access + refresh Cookie // SetSessionCookie 写入会话 ID Cookie
// rememberMe=true → Cookie 持久化(30天,关浏览器后仍保持登录 // rememberMe=true → Cookie maxAge=30天关浏览器后仍保持登录
// rememberMe=false → Cookie 用 session 模式maxAge=0关浏览器即清除但页面开启期间自动刷新 // rememberMe=false → Cookie maxAge=空闲超时默认2h与Redis TTL一致关浏览器后自动清除
func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) { func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
secure := cfg.Server.Mode != "debug" secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode) c.SetSameSite(http.SameSiteLaxMode)
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure) var maxAge int
if rememberMe { if rememberMe {
setCookie(c, RefreshCookieName, refreshToken, int(cfg.JWT.RememberExpire*3600), secure) maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
setPlainCookie(c, RMCookieName, "1", int(cfg.JWT.RememberExpire*3600), secure)
} else { } else {
// session cookie关浏览器即清除但页面开启期间自动刷新生效 maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
setCookie(c, RefreshCookieName, refreshToken, 0, secure)
setPlainCookie(c, RMCookieName, "1", 0, secure)
}
} }
// SetAccessCookie 仅刷新 access Cookierefresh 续期调用) log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) { c.SetCookie(SessionCookieName, sid, maxAge, "/", "", secure, true)
}
// ClearSessionCookie 清除会话 ID Cookielogout 调用)
func ClearSessionCookie(c *gin.Context, cfg *config.Config) {
secure := cfg.Server.Mode != "debug" secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
} }
// ClearAuthCookies 清除所有认证 Cookielogout 调用)
func ClearAuthCookies(c *gin.Context, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetCookie(CookieName, "", -1, "/", "", secure, true)
c.SetCookie(RefreshCookieName, "", -1, "/", "", secure, true)
c.SetCookie(RMCookieName, "", -1, "/", "", secure, false)
}
// setCookie 写入一个 HttpOnly Cookie
func setCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
log.Printf("[setCookie] name=%s maxAge=%ds secure=%v", name, maxAge, secure)
c.SetCookie(name, value, maxAge, "/", "", secure, true)
}
// setPlainCookie 写入非 HttpOnly CookieJS 可读)
func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
c.SetCookie(name, value, maxAge, "/", "", secure, false)
}

View File

@ -12,9 +12,3 @@ func HashPassword(password string, cost int) (string, error) {
} }
return string(bytes), nil return string(bytes), nil
} }
// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@ -11,10 +11,10 @@ var (
ErrInvalidCred = errors.New("邮箱或密码错误") ErrInvalidCred = errors.New("邮箱或密码错误")
ErrUserNotFound = errors.New("用户不存在") ErrUserNotFound = errors.New("用户不存在")
ErrUserBanned = errors.New("该账号已被封禁") ErrUserBanned = errors.New("该账号已被封禁")
ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复") ErrUserDeleted = errors.New("该账号正在注销,登录即撤销注销")
// 统一返回,避免泄露用户存在性、软删除状态等内部信息 // 统一返回,避免泄露用户存在性、软删除状态等内部信息
ErrUserLocked = errors.New("邮箱或密码错误") ErrUserLocked = errors.New("邮箱或密码错误")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字") ErrWeakPassword = errors.New("密码需至少 8 位,且包含大写字母、小写字母和数字")
ErrTokenExpired = errors.New("登录已过期,请重新登录") ErrTokenExpired = errors.New("登录已过期,请重新登录")
ErrTokenInvalid = errors.New("无效的认证凭据") ErrTokenInvalid = errors.New("无效的认证凭据")
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录") ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
@ -32,9 +32,10 @@ var (
// 密码 & 注销相关 // 密码 & 注销相关
ErrIncorrectPassword = errors.New("当前密码错误") ErrIncorrectPassword = errors.New("当前密码错误")
ErrLoginBeforeDelete = errors.New("请先登录再注销") ErrLoginBeforeDelete = errors.New("请先登录再注销")
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号") ErrNeedsConfirmRestore = errors.New("需要二次确认撤销注销")
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限") ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
ErrRegistrationDisabled = errors.New("注册功能已关闭") ErrRegistrationDisabled = errors.New("注册功能已关闭")
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
// 帖子相关 // 帖子相关
ErrPostNotFound = errors.New("帖子不存在") ErrPostNotFound = errors.New("帖子不存在")
@ -42,5 +43,18 @@ var (
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核") ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过") ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁") ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
// 域能相关
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
ErrCreatePostNoExp = errors.New("经验不足Lv1 解锁投稿")
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
// 评论相关
ErrCommentNotFound = errors.New("评论不存在")
ErrCommentEmpty = errors.New("评论内容不能为空")
) )

29
internal/common/file.go Normal file
View File

@ -0,0 +1,29 @@
package common
import (
"mime/multipart"
"os"
)
// SaveUploadedFile 将 multipart.File 内容写入目标路径
func SaveUploadedFile(file multipart.File, dst string) error {
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
}
if err != nil {
break
}
}
return nil
}

View File

@ -1,21 +1,104 @@
package common package common
import ( import (
"fmt"
"hash/crc32"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// assetHashes 静态资源文件哈希表URL 路径 → 短哈希,文件不变哈希不变)
var assetHashes map[string]string
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
func ComputeAssetHashes(templateDir string) {
assetHashes = make(map[string]string)
// 前端主题templates/MetaLab-2026/static → URL /static/
walkStaticDir(filepath.Join(templateDir, "static"), "/static")
// 共享资源templates/shared/static → URL /shared/static/
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
// 管理后台templates/admin/static → URL /admin/static/
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
log.Printf("[AssetHashes] computed %d file hashes", len(assetHashes))
}
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32存入 assetHashes
func walkStaticDir(dir, urlPrefix string) {
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".js" && ext != ".css" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
h := crc32.ChecksumIEEE(data)
shortHash := strconv.FormatUint(uint64(h), 16)
// 生成 URL 路径urlPrefix + 相对于 dir 的子路径
rel, _ := filepath.Rel(dir, path)
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
assetHashes[urlPath] = shortHash
return nil
})
}
// AssetV 返回指定静态资源的哈希版本号(模板函数)
func AssetV(path string) string {
if h, ok := assetHashes[path]; ok {
return h
}
return "0"
}
// mdImageRe 匹配 Markdown 图片语法 ![alt](url)
var mdImageRe = regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
// ExtractFirstImage 从 Markdown 正文提取第一张图片 URL
func ExtractFirstImage(md string) string {
match := mdImageRe.FindStringSubmatch(md)
if len(match) > 1 {
return match[1]
}
return ""
}
// BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token // BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token
func BuildPageData(c *gin.Context, extra gin.H) gin.H { func BuildPageData(c *gin.Context, extra gin.H) gin.H {
data := gin.H{} data := gin.H{}
for k, v := range extra { for k, v := range extra {
data[k] = v data[k] = v
} }
// 注入 CSP nonce由 SecurityHeaders 中间件生成)
if nonce, exists := c.Get("csp_nonce"); exists {
data["CSPNonce"] = nonce
}
if username, exists := c.Get("username"); exists { if username, exists := c.Get("username"); exists {
data["IsLoggedIn"] = true data["IsLoggedIn"] = true
data["Username"] = username data["Username"] = username
} }
if avatar, exists := c.Get("avatar"); exists {
data["Avatar"] = avatar
}
if expVal, exists := c.Get("exp"); exists {
if exp, ok := expVal.(int); ok {
data["Exp"] = exp
data["Level"] = model.GetLevelByExp(exp)
}
}
// 注入 CSRF token由 CSRF 中间件设置到上下文中) // 注入 CSRF token由 CSRF 中间件设置到上下文中)
if token, exists := c.Get("csrf_token"); exists { if token, exists := c.Get("csrf_token"); exists {
data["CSRFToken"] = token data["CSRFToken"] = token
@ -25,6 +108,15 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
// 注入管理入口权限moderator 及以上可看到页脚管理面板链接) // 注入管理入口权限moderator 及以上可看到页脚管理面板链接)
if role, exists := c.Get("role"); exists { if role, exists := c.Get("role"); exists {
data["CanAccessAdmin"] = model.HasMinRole(role.(string), model.RoleModerator) data["CanAccessAdmin"] = model.HasMinRole(role.(string), model.RoleModerator)
data["IsOwner"] = model.HasMinRole(role.(string), model.RoleOwner)
}
// 注入维护状态
if isMaintenance, exists := c.Get("is_maintenance"); exists {
data["IsMaintenance"] = isMaintenance
}
// 注入封禁状态
if status, exists := c.Get("status"); exists && status == model.StatusBanned {
data["IsBanned"] = true
} }
return data return data
} }
@ -55,6 +147,16 @@ func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
return data return data
} }
// RedirectToLogin 重定向到登录页,携带当前页面路径作为 redirect 参数
func RedirectToLogin(c *gin.Context) {
returnURL := url.QueryEscape(c.Request.URL.Path)
if c.Request.URL.RawQuery != "" {
returnURL = url.QueryEscape(c.Request.URL.Path + "?" + c.Request.URL.RawQuery)
}
c.Redirect(http.StatusFound, fmt.Sprintf("/auth/login?redirect=%s", returnURL))
c.Abort()
}
// injectSiteInfo 从 context 注入站点展示信息 // injectSiteInfo 从 context 注入站点展示信息
func injectSiteInfo(c *gin.Context, data gin.H) { func injectSiteInfo(c *gin.Context, data gin.H) {
if framework, exists := c.Get("site_framework"); exists { if framework, exists := c.Get("site_framework"); exists {

View File

@ -49,5 +49,13 @@ func (p *Pagination) NextPage(totalPages int) int {
return next return next
} }
// PageCount 根据 total 和 pageSize 计算总页数(无需创建 Pagination 实例)
func PageCount(total int64, pageSize int) int {
if total == 0 {
return 0
}
return int((total + int64(pageSize) - 1) / int64(pageSize))
}
// 固定时间格式,前后端统一 // 固定时间格式,前后端统一
const TimeFormat = time.RFC3339 const TimeFormat = time.RFC3339

View File

@ -0,0 +1,12 @@
package common
import "metazone.cc/metalab/internal/model"
// PostStatusDisplayNames 帖子状态 → 中文名称映射(供模板渲染使用)
var PostStatusDisplayNames = map[string]string{
model.PostStatusDraft: "草稿",
model.PostStatusPending: "待审核",
model.PostStatusApproved: "已发布",
model.PostStatusRejected: "已退回",
model.PostStatusLocked: "已锁定",
}

36
internal/common/redis.go Normal file
View File

@ -0,0 +1,36 @@
package common
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"metazone.cc/metalab/internal/config"
)
// NewRedisClient 创建 Redis 客户端并尝试验证连接
// Redis 不可达时不返回错误,而是返回 clientgo-redis 自带自动重连),由调用方决定降级策略
func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s配合 FallbackStore 快速降级
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
log.Printf("[Redis] 连接失败: %v将降级为内存存储", err)
return client, fmt.Errorf("Redis 连接失败: %w", err)
}
return client, nil
}

View File

@ -22,3 +22,16 @@ func OkWithMessage(c *gin.Context, data interface{}, message string) {
func Error(c *gin.Context, code int, message string) { func Error(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{"success": false, "message": message}) c.JSON(code, gin.H{"success": false, "message": message})
} }
// VditorUploadOk Vditor 图片上传成功响应
// succMap: key=原始文件名, value=文件 URL
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"data": gin.H{
"errFiles": []string{},
"succMap": succMap,
},
})
}

View File

@ -13,7 +13,8 @@ import (
type Config struct { type Config struct {
Server ServerConfig `mapstructure:"server"` Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"` Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"` Session SessionConfig `mapstructure:"session"`
Redis RedisConfig `mapstructure:"redis"`
Bcrypt BcryptConfig `mapstructure:"bcrypt"` Bcrypt BcryptConfig `mapstructure:"bcrypt"`
Roles RolesConfig `mapstructure:"roles"` Roles RolesConfig `mapstructure:"roles"`
Audit AuditConfig `mapstructure:"audit"` Audit AuditConfig `mapstructure:"audit"`
@ -41,11 +42,20 @@ type DatabaseConfig struct {
SSLMode string `mapstructure:"sslmode"` SSLMode string `mapstructure:"sslmode"`
} }
type JWTConfig struct { // SessionConfig 服务端会话配置
Secret string `mapstructure:"secret"` type SessionConfig struct {
AccessExpire int `mapstructure:"access_expire"` // 分钟 IdleTimeout int `mapstructure:"idle_timeout"` // 不记住我:空闲超时(分钟),默认 144024小时
RefreshExpire int `mapstructure:"refresh_expire"` // 小时 RememberTimeout int `mapstructure:"remember_timeout"` // 记住我:空闲超时(分钟),默认 4320030天
RememberExpire int `mapstructure:"remember_expire"` // 小时 CleanupInterval int `mapstructure:"cleanup_interval"` // 后台清理间隔(秒),默认 300仅内存模式使用
}
// RedisConfig Redis 连接配置
type RedisConfig struct {
Enabled bool `mapstructure:"enabled"`
Host string `mapstructure:"host"`
Port string `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
} }
type BcryptConfig struct { type BcryptConfig struct {
@ -91,8 +101,8 @@ func Load(configPath string) *Config {
log.Fatalf("解析配置失败: %v", err) log.Fatalf("解析配置失败: %v", err)
} }
log.Printf("[Config] JWT.SecretLen=%d JWT.AccessExpire=%d min JWT.RefreshExpire=%d h JWT.RememberExpire=%d h | Server.Mode=%s", log.Printf("[Config] Session.IdleTimeout=%d min Session.RememberTimeout=%d min Session.CleanupInterval=%d s | Server.Mode=%s",
len(c.JWT.Secret), c.JWT.AccessExpire, c.JWT.RefreshExpire, c.JWT.RememberExpire, c.Server.Mode) c.Session.IdleTimeout, c.Session.RememberTimeout, c.Session.CleanupInterval, c.Server.Mode)
App = c App = c
return c return c
@ -127,7 +137,17 @@ func loadEnvFile(path string) {
// bindEnvOverride 将环境变量映射到 config 的嵌套键 // bindEnvOverride 将环境变量映射到 config 的嵌套键
func bindEnvOverride(v *viper.Viper) { func bindEnvOverride(v *viper.Viper) {
_ = v.BindEnv("database.password", "DATABASE_PASSWORD") _ = v.BindEnv("database.password", "DATABASE_PASSWORD")
_ = v.BindEnv("jwt.secret", "JWT_SECRET") _ = v.BindEnv("redis.password", "REDIS_PASSWORD")
}
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口
func (c *Config) GetIdleTimeout() int {
return c.Session.IdleTimeout
}
// GetRememberTimeout 返回记住我会话空闲超时(分钟)
func (c *Config) GetRememberTimeout() int {
return c.Session.RememberTimeout
} }
// DSN 返回 PostgreSQL 连接字符串 // DSN 返回 PostgreSQL 连接字符串

View File

@ -128,3 +128,8 @@ func (s *SiteSettings) SiteInfo() SiteInfoDefaults {
func (s *SiteSettings) IsRegistrationEnabled() bool { func (s *SiteSettings) IsRegistrationEnabled() bool {
return s.GetBool("registration.enabled", true) return s.GetBool("registration.enabled", true)
} }
// IsMaintenanceEnabled 是否处于维护模式(维护期间仅站长可登录和访问)
func (s *SiteSettings) IsMaintenanceEnabled() bool {
return s.GetBool("maintenance.enabled", false)
}

View File

@ -0,0 +1,67 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// AdminCommentController 后台评论管理
type AdminCommentController struct {
commentService adminCommentUseCase
}
// NewAdminCommentController 构造函数
func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController {
return &AdminCommentController{commentService: cs}
}
// CommentsPage SSR 页面
func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/comments/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "评论管理",
"ExtraCSS": "/admin/static/css/comments.css",
"ExtraJS": "/admin/static/js/comments.js",
}))
}
// ListComments API 列表
func (ctrl *AdminCommentController) ListComments(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
comments, total, err := ctrl.commentService.ListAllComments(keyword, false, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取评论列表失败")
return
}
if comments == nil {
comments = []model.AdminCommentRow{}
}
common.Ok(c, gin.H{
"items": comments,
"total": total,
"page": page,
"total_pages": common.PageCount(total, pageSize),
})
}
// Delete 软删除评论
func (ctrl *AdminCommentController) Delete(c *gin.Context) {
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的评论ID")
return
}
if err := ctrl.commentService.Delete(uint(commentID), 0, true); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "删除成功")
}

View File

@ -29,9 +29,22 @@ func (ac *AdminController) Dashboard(c *gin.Context) {
if err != nil { if err != nil {
totalUsers = 0 totalUsers = 0
} }
postStats, err := ac.adminService.GetPostStats()
if err != nil {
postStats = &service.PostStats{}
}
storeMetrics := ac.adminService.GetStoreMetrics()
c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{ c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "管理首页", "Title": "管理首页",
"TotalUsers": totalUsers, "TotalUsers": totalUsers,
"PostTotal": postStats.Total,
"PostApproved": postStats.Approved,
"PostPending": postStats.Pending,
"StoreType": storeMetrics.Type,
"StoreHealthy": storeMetrics.Healthy,
"StoreDegraded": storeMetrics.Degraded,
"DegradedNote": storeMetrics.DegradedNote,
"ActiveSessions": storeMetrics.ActiveCount,
})) }))
} }
@ -94,7 +107,7 @@ func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
if req.Status == model.StatusActive { if req.Status == model.StatusActive {
action = "解封" action = "解封"
} else if req.Status == model.StatusLocked { } else if req.Status == model.StatusLocked {
action = "已删除" action = "已锁定"
} }
common.OkMessage(c, action+"成功") common.OkMessage(c, action+"成功")
} }

View File

@ -0,0 +1,140 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// AdjustEnergy 调整用户域能
func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) {
operatorUID, ok := c.Get("uid")
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req struct {
UserIDs []uint `json:"user_ids" binding:"required,min=1"`
Amount int `json:"amount" binding:"required"`
Description string `json:"description" binding:"required,min=1,max=500"`
Mode string `json:"mode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description")
return
}
// 默认模式为 admin_transfer
if req.Mode == "" {
req.Mode = model.FundTypeAdminTransfer
}
// system_operation 仅 owner 可用
if req.Mode == model.FundTypeSystemOperation {
role, _ := c.Get("role")
if !model.HasMinRole(role.(string), model.RoleOwner) {
common.Error(c, http.StatusForbidden, "仅站长可使用系统操作模式")
return
}
}
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil {
if err == common.ErrInsufficientFund {
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
return
}
common.Error(c, http.StatusInternalServerError, "调整失败")
return
}
common.OkMessage(c, "域能调整成功")
}
// ListEnergyLogs 查询域能日志
func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) {
energyType := c.Query("type")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
logs, total, err := ac.energySvc.GetAdminEnergyLogs(energyType, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
// 转换为展示友好的视图
type logView struct {
model.EnergyLog
EnergyDisplay float64 `json:"energy_display"`
TypeName string `json:"type_name"`
}
views := make([]logView, len(logs))
for i, l := range logs {
views[i] = logView{
EnergyLog: l,
EnergyDisplay: float64(l.Amount) / 10,
TypeName: model.EnergyLogDisplayNames[l.Type],
}
}
common.Ok(c, gin.H{
"items": views,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": common.PageCount(total, pageSize),
})
}
// GetFundBalance 获取公户余额
func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
balance, err := ac.energySvc.GetFundBalance()
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, gin.H{
"balance": balance,
"balance_display": float64(balance) / 10,
})
}
// ListFundLogs 查询公户流水
func (ac *AdminEnergyController) ListFundLogs(c *gin.Context) {
logType := c.Query("type")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
logs, total, err := ac.energySvc.GetFundLogs(logType, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
type logView struct {
model.FundLog
AmountDisplay float64 `json:"amount_display"`
TypeName string `json:"type_name"`
}
views := make([]logView, len(logs))
for i, l := range logs {
views[i] = logView{
FundLog: l,
AmountDisplay: float64(l.Amount) / 10,
TypeName: model.FundLogDisplayNames[l.Type],
}
}
common.Ok(c, gin.H{
"items": views,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": common.PageCount(total, pageSize),
})
}

View File

@ -0,0 +1,55 @@
package admin
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// AdminEnergyController 后台域能管理控制器
type AdminEnergyController struct {
energySvc adminEnergyUseCase
}
// NewAdminEnergyController 构造函数
func NewAdminEnergyController(energySvc adminEnergyUseCase) *AdminEnergyController {
return &AdminEnergyController{energySvc: energySvc}
}
// EnergyPage 域能管理页面(公户余额 + 用户域能调整)
func (ac *AdminEnergyController) EnergyPage(c *gin.Context) {
balance := 0
if b, err := ac.energySvc.GetFundBalance(); err == nil {
balance = b
}
c.HTML(http.StatusOK, "admin/energy/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "域能管理",
"ExtraCSS": "/admin/static/css/energy.css",
"ExtraJS": "/admin/static/js/energy.js",
"FundBalance": balance,
"FundBalanceDisplay": float64(balance) / 10,
}))
}
// EnergyLogPage 域能日志页面
func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{
"Title": "域能日志",
"ExtraCSS": "/admin/static/css/energy.css",
"ExtraJS": "/admin/static/js/energy.js",
"LogTypes": model.EnergyLogDisplayNames,
}))
}
// FundLogPage 公户流水页面
func (ac *AdminEnergyController) FundLogPage(c *gin.Context) {
c.HTML(http.StatusOK, "admin/energy/fund_logs.html", common.BuildAdminPageData(c, gin.H{
"Title": "公户流水",
"ExtraCSS": "/admin/static/css/energy.css",
"ExtraJS": "/admin/static/js/energy.js",
"LogTypes": model.FundLogDisplayNames,
}))
}

View File

@ -0,0 +1,124 @@
package admin
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// Approve 审核通过
func (ctrl *AdminPostController) Approve(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if err := ctrl.postService.Approve(uint(id)); err != nil {
if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "审核失败")
}
return
}
common.OkMessage(c, "审核通过")
}
// Reject 退回
func (ctrl *AdminPostController) Reject(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req model.PostRejectRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请填写退回理由")
return
}
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "退回失败")
}
return
}
common.OkMessage(c, "已退回")
}
// Lock 锁定
func (ctrl *AdminPostController) Lock(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req model.PostLockRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请填写锁定理由")
return
}
if err := ctrl.postService.Lock(uint(id), req.Reason); err != nil {
if errors.Is(err, common.ErrPostNotFound) || errors.Is(err, common.ErrPostCannotLock) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "锁定失败")
}
return
}
common.OkMessage(c, "已锁定")
}
// Unlock 解锁
func (ctrl *AdminPostController) Unlock(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if err := ctrl.postService.Unlock(uint(id)); err != nil {
if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "解锁失败")
}
return
}
common.OkMessage(c, "已解锁")
}
// Restore 恢复软删除
func (ctrl *AdminPostController) Restore(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if err := ctrl.postService.Restore(uint(id)); err != nil {
if errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "恢复失败")
}
return
}
common.OkMessage(c, "已恢复")
}

View File

@ -1,12 +1,10 @@
package admin package admin
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -37,127 +35,27 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
return return
} }
if posts == nil { totalPages := common.PageCount(total, pageSize)
posts = []model.Post{} prevPage := page - 1
if prevPage < 1 {
prevPage = 1
}
nextPage := page + 1
if nextPage > totalPages {
nextPage = totalPages
} }
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
totalPages := p.TotalPages(total)
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{ c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "内容管理", "Title": "内容管理",
"Posts": posts, "Posts": posts,
"Total": total, "Total": total,
"Page": p.Page, "Page": page,
"TotalPages": totalPages, "TotalPages": totalPages,
"PrevPage": p.PrevPage(), "PrevPage": prevPage,
"NextPage": p.NextPage(totalPages), "NextPage": nextPage,
"Keyword": keyword, "Keyword": keyword,
"Status": status, "Status": status,
"StatusNames": model.PostStatusDisplayNames, "StatusNames": common.PostStatusDisplayNames,
"ExtraCSS": "/admin/static/css/posts.css", "ExtraCSS": "/admin/static/css/posts.css",
})) }))
} }
// Approve 审核通过
func (ctrl *AdminPostController) Approve(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if err := ctrl.postService.Approve(uint(id)); err != nil {
if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "审核失败")
}
return
}
common.OkMessage(c, "审核通过")
}
// Reject 退回
func (ctrl *AdminPostController) Reject(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req model.PostRejectRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请填写退回理由")
return
}
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "退回失败")
}
return
}
common.OkMessage(c, "已退回")
}
// Lock 锁定
func (ctrl *AdminPostController) Lock(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if err := ctrl.postService.Lock(uint(id)); err != nil {
if errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "锁定失败")
}
return
}
common.OkMessage(c, "已锁定")
}
// Unlock 解锁
func (ctrl *AdminPostController) Unlock(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if err := ctrl.postService.Unlock(uint(id)); err != nil {
if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "解锁失败")
}
return
}
common.OkMessage(c, "已解锁")
}
// Restore 恢复软删除
func (ctrl *AdminPostController) Restore(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if err := ctrl.postService.Restore(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "恢复失败")
return
}
common.OkMessage(c, "已恢复")
}

View File

@ -50,17 +50,17 @@ func (ac *AuditController) ListAudits(c *gin.Context) {
return return
} }
// 为每个审核记录附加提交者的用户名 // 为每个审核记录附加提交者的当前用户名
type auditItem struct { type auditItem struct {
model.AuditSubmission model.AuditSubmission
SubmitterUsername string `json:"submitter_username"` CurrentUsername string `json:"current_username"`
} }
items := make([]auditItem, 0, len(result.Items)) items := make([]auditItem, 0, len(result.Items))
for _, a := range result.Items { for _, a := range result.Items {
item := auditItem{AuditSubmission: a} item := auditItem{AuditSubmission: a}
if user, err := ac.userRepo.FindByID(a.UserID); err == nil { if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
item.SubmitterUsername = user.Username item.CurrentUsername = user.Username
} }
items = append(items, item) items = append(items, item)
} }

View File

@ -3,14 +3,17 @@ package admin
import ( import (
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service" "metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
) )
// adminUseCase AdminController 对 AdminService 的最小依赖ISP4 个方法) // adminUseCase AdminController 对 AdminService 的最小依赖ISP6 个方法)
type adminUseCase interface { type adminUseCase interface {
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error) ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
ResetUserToken(operatorUID, targetUID uint) error ResetUserToken(operatorUID, targetUID uint) error
CountUsers() (int64, error) CountUsers() (int64, error)
GetPostStats() (*service.PostStats, error)
GetStoreMetrics() session.StoreMetrics
} }
// auditUseCase AuditController 对 AuditService 的最小依赖ISP3 个方法) // auditUseCase AuditController 对 AuditService 的最小依赖ISP3 个方法)
@ -33,12 +36,27 @@ type auditStatusProvider interface {
FindByID(id uint) (*model.User, error) FindByID(id uint) (*model.User, error)
} }
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP6 个方法) // adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP7 个方法)
type adminPostUseCase interface { type adminPostUseCase interface {
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
Approve(postID uint) error Approve(postID uint) error
Reject(postID uint, reason string) error Reject(postID uint, reason string) error
Lock(postID uint) error Lock(postID uint, reason string) error
Unlock(postID uint) error Unlock(postID uint) error
Restore(postID uint) error Restore(postID uint) error
} }
// adminCommentUseCase AdminCommentController 对 CommentService 的最小依赖ISP2 个方法)
type adminCommentUseCase interface {
ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error)
Delete(commentID uint, requesterID uint, isAdmin bool) error
}
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖ISP4 个方法)
type adminEnergyUseCase interface {
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
GetFundBalance() (int, error)
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
}

View File

@ -31,43 +31,36 @@ func (ac *AuthController) Login(c *gin.Context) {
email := req.Email email := req.Email
ip := clientIP(c) ip := clientIP(c)
// --- 限流:账户维度 --- // --- 限流:账户维度(原子检查+递增)---
acctResult, recordAccount := ac.rateLimiter.AllowAccount(email) acctResult := ac.rateLimiter.AllowAccount(email)
if acctResult.Blocked { if acctResult.Blocked {
common.Error(c, http.StatusTooManyRequests, acctResult.Message) common.Error(c, http.StatusTooManyRequests, acctResult.Message)
return return
} }
// --- 限流IP 维度 --- // --- 限流IP 维度(原子检查+递增)---
ipResult, recordIP := ac.rateLimiter.AllowIP(ip) ipResult := ac.rateLimiter.AllowIP(ip)
if ipResult.Blocked { if ipResult.Blocked {
common.Error(c, http.StatusTooManyRequests, ipResult.Message) common.Error(c, http.StatusTooManyRequests, ipResult.Message)
return return
} }
accessToken, refreshToken, user, err := ac.authService.Login(req, ip) user, err := ac.authService.Login(req, ip)
if err != nil { if err != nil {
// 记录失败 → 两个维度各 +1 // 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录
if recordAccount != nil {
recordAccount()
}
if recordIP != nil {
recordIP()
}
switch err { switch err {
case common.ErrInvalidCred: case common.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁")
case common.ErrUserLocked: case common.ErrUserLocked:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrMaintenanceMode:
common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录")
case common.ErrNeedsConfirmRestore: case common.ErrNeedsConfirmRestore:
// 注销账号登录 → 需要二次确认恢复 // 注销账号登录 → 需要二次确认恢复
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"action": "confirm_restore", "action": "confirm_restore",
"message": "你的账号正在注销中,登录将中止注销流程", "message": "你的账号正在注销中,登录将撤销注销并恢复账号",
}) })
default: default:
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试") common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
@ -78,7 +71,14 @@ func (ac *AuthController) Login(c *gin.Context) {
// 登录成功 → 清除失败计数 // 登录成功 → 清除失败计数
ac.rateLimiter.Clear(email, ip) ac.rateLimiter.Clear(email, ip)
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) // 创建服务端 session
sid, err := ac.sessionManager.Create(user, req.RememberMe, ip, c.GetHeader("User-Agent"))
if err != nil {
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
return
}
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
common.OkWithMessage(c, user, "登录成功") common.OkWithMessage(c, user, "登录成功")
} }
@ -91,7 +91,7 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
return return
} }
accessToken, refreshToken, user, err := ac.authService.ConfirmRestore(req, clientIP(c)) user, err := ac.authService.ConfirmRestore(req, clientIP(c))
if err != nil { if err != nil {
switch err { switch err {
case common.ErrInvalidCred: case common.ErrInvalidCred:
@ -102,6 +102,12 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
return return
} }
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) sid, err := ac.sessionManager.Create(user, req.RememberMe, clientIP(c), c.GetHeader("User-Agent"))
common.OkWithMessage(c, user, "账号已恢复,欢迎回来") if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
return
}
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
common.OkWithMessage(c, user, "注销已撤销,欢迎回来")
} }

View File

@ -34,19 +34,20 @@ func (ac *AuthController) Register(c *gin.Context) {
return return
} }
// 注册 IP 限流1 分钟 5 次 // 注册 IP 限流1 分钟 5 次(原子检查+递增)
regResult, recordReg := ac.rateLimiter.AllowIP(clientIP(c) + ":register") regKey := clientIP(c) + ":register"
regResult := ac.rateLimiter.AllowIP(regKey)
if regResult.Blocked { if regResult.Blocked {
common.Error(c, http.StatusTooManyRequests, "注册请求过于频繁,请稍后重试") common.Error(c, http.StatusTooManyRequests, "注册请求过于频繁,请稍后重试")
return return
} }
accessToken, refreshToken, user, err := ac.authService.Register(req, clientIP(c)) user, err := ac.authService.Register(req, clientIP(c))
if err != nil { if err != nil {
if recordReg != nil { // 失败计数已在 AllowIP 中原子递增,无需额外记录
recordReg()
}
switch err { switch err {
case common.ErrMaintenanceMode:
common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册")
case common.ErrEmailExists: case common.ErrEmailExists:
common.Error(c, http.StatusConflict, "该邮箱已注册") common.Error(c, http.StatusConflict, "该邮箱已注册")
case common.ErrWeakPassword: case common.ErrWeakPassword:
@ -59,40 +60,48 @@ func (ac *AuthController) Register(c *gin.Context) {
return return
} }
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) // 注册成功 → 清除该 IP 的注册限流计数
ac.rateLimiter.ClearIP(regKey)
sid, err := ac.sessionManager.Create(user, req.RememberMe, clientIP(c), c.GetHeader("User-Agent"))
if err != nil {
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab") common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab")
} }
// Logout 退出登录:清除所有认证 Cookie // Logout 退出登录:删除服务端 session + 清除 Cookie
func (ac *AuthController) Logout(c *gin.Context) { func (ac *AuthController) Logout(c *gin.Context) {
common.ClearAuthCookies(c, ac.cfg) if sid, err := c.Cookie(common.SessionCookieName); err == nil && sid != "" {
_ = ac.sessionManager.Destroy(sid)
}
common.ClearSessionCookie(c, ac.cfg)
common.OkMessage(c, "已退出登录") common.OkMessage(c, "已退出登录")
} }
// RefreshToken 用 refresh token 换取新的 access token // RefreshToken 检查登录状态并续期session 模式下每次请求已自动续期,此端点用于前端显式检查)
func (ac *AuthController) RefreshToken(c *gin.Context) { func (ac *AuthController) RefreshToken(c *gin.Context) {
refreshToken, err := c.Cookie(common.RefreshCookieName) sid, err := c.Cookie(common.SessionCookieName)
if err != nil { if err != nil || sid == "" {
common.Error(c, http.StatusUnauthorized, "请重新登录") common.Error(c, http.StatusUnauthorized, "请重新登录")
return return
} }
accessToken, _, err := ac.tokenService.RefreshAccessToken(refreshToken) s, err := ac.sessionManager.Validate(sid)
if err != nil { if err != nil || s == nil {
common.ClearAuthCookies(c, ac.cfg) common.ClearSessionCookie(c, ac.cfg)
switch err {
case common.ErrTokenExpired, common.ErrTokenRevoked:
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录") common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
case common.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁")
default:
common.Error(c, http.StatusUnauthorized, "请重新登录")
}
return return
} }
common.SetAccessCookie(c, accessToken, ac.cfg) common.Ok(c, gin.H{
"uid": s.UserID,
common.Ok(c, nil) "email": s.Email,
"username": s.Username,
"role": s.Role,
})
} }

View File

@ -1,10 +1,12 @@
package controller package controller
import ( import (
"html/template"
"net/http" "net/http"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/session"
"metazone.cc/metalab/internal/theme" "metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -13,31 +15,41 @@ import (
// AuthController 认证相关页面 + API // AuthController 认证相关页面 + API
type AuthController struct { type AuthController struct {
authService authUseCase authService authUseCase
tokenService tokenRefresher sessionManager *session.Manager
rateLimiter rateLimiter rateLimiter rateLimiter
cfg *config.Config cfg *config.Config
siteSettings *config.SiteSettings
} }
// NewAuthController 构造函数 // NewAuthController 构造函数
func NewAuthController(authService authUseCase, tokenSvc tokenRefresher, limiter rateLimiter, cfg *config.Config) *AuthController { func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config, siteSettings *config.SiteSettings) *AuthController {
return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg} return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg, siteSettings: siteSettings}
} }
// RegisterPage 注册页面(已登录用户重定向到首页,注册关闭时重定向到登录页 // RegisterPage 注册页面(已登录用户重定向到首页)
func (ac *AuthController) RegisterPage(c *gin.Context) { func (ac *AuthController) RegisterPage(c *gin.Context) {
if _, exists := c.Get("uid"); exists { if _, exists := c.Get("uid"); exists {
c.Redirect(http.StatusFound, "/") c.Redirect(http.StatusFound, "/")
return return
} }
guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html") // 优先从 DB 读取社区准则,为空时 fallback 到静态文件
guidelinesHTML := ac.siteSettings.Get("site.guidelines", "")
var guidelines template.HTML
if guidelinesHTML != "" {
guidelines = template.HTML(guidelinesHTML)
} else {
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载准则失败") c.String(http.StatusInternalServerError, "加载准则失败")
return return
} }
guidelines = content
}
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
"Title": "注册", "Title": "注册",
"ExtraCSS": "/static/css/auth.css", "ExtraCSS": "/static/css/auth.css",
"Guidelines": guidelines, "Guidelines": guidelines,
"RegistrationEnabled": ac.authService.IsRegistrationEnabled(),
})) }))
} }

View File

@ -0,0 +1,63 @@
package controller
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// Delete DELETE /api/comments/:id
func (ctrl *CommentController) Delete(c *gin.Context) {
uid, role, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的评论ID")
return
}
// 权限检查由 service 层统一处理
isAdmin := model.HasMinRole(role, model.RoleAdmin)
if err := ctrl.commentService.Delete(uint(commentID), uid, isAdmin); err != nil {
if errors.Is(err, common.ErrPermissionDenied) {
common.Error(c, http.StatusForbidden, err.Error())
return
}
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "删除成功")
}
// SearchUsers GET /api/users/search?q=keyword
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
q := c.Query("q")
if q == "" {
common.Ok(c, []model.UserSearchResult{})
return
}
users, err := ctrl.commentService.SearchUsers(q, uid)
if err != nil {
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
return
}
common.Ok(c, users)
}

View File

@ -0,0 +1,11 @@
package controller
// CommentController 评论控制器
type CommentController struct {
commentService commentUseCase
}
// NewCommentController 构造函数
func NewCommentController(cs commentUseCase) *CommentController {
return &CommentController{commentService: cs}
}

View File

@ -0,0 +1,52 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// ListRootComments 获取文章顶级评论 GET /api/posts/:id/comments
func (ctrl *CommentController) ListRootComments(c *gin.Context) {
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
comments, total, err := ctrl.commentService.ListRootComments(uint(postID), page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取评论失败")
return
}
common.Ok(c, gin.H{
"items": comments,
"total": total,
"page": page,
"total_pages": common.PageCount(total, pageSize),
})
}
// ListReplies 获取顶级评论的全部回复 GET /api/posts/:id/comments/:root_id/replies
func (ctrl *CommentController) ListReplies(c *gin.Context) {
rootID, err := strconv.ParseUint(c.Param("root_id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的评论ID")
return
}
replies, err := ctrl.commentService.ListReplies(uint(rootID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取回复失败")
return
}
common.Ok(c, gin.H{"replies": replies})
}

View File

@ -0,0 +1,119 @@
package controller
import (
"bytes"
"image"
"image/jpeg"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// UploadImage 上传评论图片 POST /api/comments/upload-image
// 需要 LV4+Exp ≥ 1500复用帖子图片上传的 WebP 转码逻辑
func (ctrl *CommentController) UploadImage(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
// LV4+ 检查Exp ≥ 1500
expVal, exists := c.Get("exp")
if !exists {
common.Error(c, http.StatusForbidden, "无法获取经验值")
return
}
exp := expVal.(int)
if exp < 1500 {
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
common.Error(c, http.StatusBadRequest, "请选择文件")
return
}
defer file.Close()
// 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename))
allowedExt := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
if !allowedExt[ext] {
common.Error(c, http.StatusBadRequest, "仅支持 jpg/jpeg/png/gif/webp 格式")
return
}
// 限制 5MB
if header.Size > 5<<20 {
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
return
}
data, err := io.ReadAll(file)
if err != nil {
common.Error(c, http.StatusInternalServerError, "读取文件失败")
return
}
storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil {
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return
}
ts := time.Now().UnixMilli()
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
var savePath, url string
// JPEG/PNG 转 WebP
if isJPEGPNG {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
common.Error(c, http.StatusBadRequest, "图片格式无效")
return
}
webpData := encodeWebPBinary(img, len(data))
if webpData != nil && len(webpData) < len(data) {
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
savePath = filepath.Join(storageDir, filename)
os.WriteFile(savePath, webpData, 0644)
url = "/uploads/posts/" + filename
}
}
// 回退存储
if savePath == "" {
dataOut := data
if isJPEGPNG {
decImg, _, decErr := image.Decode(bytes.NewReader(data))
if decErr == nil {
var buf bytes.Buffer
if ext == ".png" {
png.Encode(&buf, decImg)
} else {
jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
}
dataOut = buf.Bytes()
}
}
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
savePath = filepath.Join(storageDir, filename)
os.WriteFile(savePath, dataOut, 0644)
url = "/uploads/posts/" + filename
}
// 返回标准 JSON前端将 URL 插入评论文本)
common.Ok(c, gin.H{"url": url})
}

View File

@ -0,0 +1,72 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// CreateRoot POST /api/posts/:id/comments
func (ctrl *CommentController) CreateRoot(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章ID")
return
}
var req struct {
Body string `json:"body" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请输入评论内容")
return
}
comment, err := ctrl.commentService.CreateRoot(uid, uint(postID), req.Body)
if err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Ok(c, comment)
}
// CreateReply POST /api/comments/:id/reply
func (ctrl *CommentController) CreateReply(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
parentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的评论ID")
return
}
var req struct {
Body string `json:"body" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请输入回复内容")
return
}
reply, err := ctrl.commentService.CreateReply(uid, uint(parentID), req.Body)
if err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Ok(c, reply)
}

View File

@ -0,0 +1,162 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// EnergyController 域能 API 控制器
type EnergyController struct {
energySvc energyUseCase
}
// NewEnergyController 构造函数
func NewEnergyController(energySvc energyUseCase) *EnergyController {
return &EnergyController{energySvc: energySvc}
}
// Energize 赋能文章
func (ec *EnergyController) Energize(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req struct {
PostID uint `json:"post_id" binding:"required"`
Amount int `json:"amount" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
if err != nil {
switch err {
case common.ErrPostNotFound:
common.Error(c, http.StatusNotFound, "文章不存在")
case common.ErrCannotEnergizeSelf:
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
case common.ErrInsufficientEnergy:
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
case common.ErrEnergizeLimitReached:
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
case common.ErrInvalidEnergyAmount:
common.Error(c, http.StatusBadRequest, "无效的赋能数量")
default:
common.Error(c, http.StatusInternalServerError, "赋能失败")
}
return
}
truncated := actualAmount != req.Amount
common.Ok(c, gin.H{
"exp_gained": expGained,
"actual_amount": actualAmount,
"truncated": truncated,
"message": buildEnergizeMessage(expGained, truncated),
})
}
// buildEnergizeMessage 根据获得的经验生成提示消息
func buildEnergizeMessage(expGained int, truncated bool) string {
if truncated {
if expGained > 0 {
return "单篇额度已满,实际赋能部分额度,+" + strconv.Itoa(expGained) + " 经验"
}
return "单篇额度已满,实际赋能部分额度"
}
if expGained > 0 {
return "赋能成功!+" + strconv.Itoa(expGained) + " 经验"
}
return "赋能成功!今日经验已满,对方仍获得激励"
}
// GetEnergyInfo 获取域能余额和经验上限状态,支持 ?post_id= 查询文章赋能总量
func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
postIDStr := c.DefaultQuery("post_id", "0")
postID, _ := strconv.ParseUint(postIDStr, 10, 64)
info, err := ec.energySvc.GetEnergyInfo(uid, uint(postID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取域能信息失败")
return
}
energyDisplay := float64(info.Energy) / 10
common.Ok(c, gin.H{
"energy": info.Energy,
"energy_display": energyDisplay,
"daily_energize_exp": info.DailyEnergizeExp,
"daily_exp_cap": info.DailyExpCap,
"daily_exp_cap_reached": info.DailyExpCapReached,
"post_energize_total": info.PostEnergizeTotal,
})
}
// GetEnergyLogs 获取我的域能流水
func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
logs, total, err := ec.energySvc.GetEnergyLogs(uid, days, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取流水失败")
return
}
common.Ok(c, gin.H{
"items": toEnergyLogViews(logs),
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": common.PageCount(total, pageSize),
})
}
// EnergyLogView 前端展示用的域能流水视图
type EnergyLogView struct {
ID uint `json:"id"`
Amount int `json:"amount"`
AmountDisplay float64 `json:"amount_display"`
Type string `json:"type"`
TypeName string `json:"type_name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
}
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {
views := make([]EnergyLogView, len(logs))
for i, l := range logs {
views[i] = EnergyLogView{
ID: l.ID,
Amount: l.Amount,
AmountDisplay: float64(l.Amount) / 10,
Type: l.Type,
TypeName: model.EnergyLogDisplayNames[l.Type],
Description: l.Description,
CreatedAt: l.CreatedAt.Format(common.TimeFormat),
}
}
return views
}

View File

@ -0,0 +1,122 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// FavoriteController 收藏夹 API 控制器
type FavoriteController struct {
svc favoriteUseCase
}
// NewFavoriteController 构造函数
func NewFavoriteController(svc favoriteUseCase) *FavoriteController {
return &FavoriteController{svc: svc}
}
// ListFolders 我的收藏夹列表 GET /api/folders
func (fc *FavoriteController) ListFolders(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
result, err := fc.svc.ListFolders(uid)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取收藏夹失败")
return
}
common.Ok(c, result)
}
// CreateFolder 创建收藏夹 POST /api/folders
func (fc *FavoriteController) CreateFolder(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
f, err := fc.svc.CreateFolder(uid, req.Name, req.Description, req.IsPublic)
if err != nil {
if err.Error() == "收藏夹名称已存在" || err.Error() == "收藏夹名称不能为空" || err.Error() == "收藏夹名称不能超过 50 个字符" {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Error(c, http.StatusInternalServerError, "创建失败")
return
}
common.OkWithMessage(c, f, "收藏夹创建成功")
}
// UpdateFolder 修改收藏夹 PUT /api/folders/:id
func (fc *FavoriteController) UpdateFolder(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
if err := fc.svc.UpdateFolder(uid, uint(folderID), req.Name, req.Description, req.IsPublic); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "收藏夹已更新")
}
// DeleteFolder 删除收藏夹 DELETE /api/folders/:id
func (fc *FavoriteController) DeleteFolder(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
return
}
if err := fc.svc.DeleteFolder(uid, uint(folderID)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "收藏夹已删除")
}

View File

@ -0,0 +1,143 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// ListFolderItems 获取收藏夹内文章列表 GET /api/folders/:id/posts
func (fc *FavoriteController) ListFolderItems(c *gin.Context) {
uid, _, _ := common.GetGinUser(c) // 允许未登录查看公开收藏夹
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
if err != nil {
if err == common.ErrPermissionDenied {
common.Error(c, http.StatusForbidden, "该收藏夹未公开")
return
}
common.Error(c, http.StatusInternalServerError, "获取失败")
return
}
common.Ok(c, result)
}
// AddToFolder 收藏文章 POST /api/folders/:id/posts
func (fc *FavoriteController) AddToFolder(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
return
}
var req struct {
PostID uint `json:"post_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.PostID == 0 {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
if err := fc.svc.AddFavorite(uid, req.PostID, func() *uint { v := uint(folderID); return &v }()); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "收藏成功")
}
// RemoveFromFolder 取消收藏 DELETE /api/folders/:id/posts/:postId
func (fc *FavoriteController) RemoveFromFolder(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
return
}
postID, err := strconv.ParseUint(c.Param("postId"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
return
}
if err := fc.svc.RemoveFavorite(uid, uint(postID)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
_ = folderID // 移除不依赖具体收藏夹
common.OkMessage(c, "已取消收藏")
}
// GetPostStatus 查询文章收藏状态 GET /api/posts/:id/folder-status
func (fc *FavoriteController) GetPostStatus(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Ok(c, gin.H{"favorited": false})
return
}
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
return
}
status, err := fc.svc.GetPostFavoriteStatus(uid, uint(postID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, status)
}
// ToggleFavorite 切换收藏 POST /api/posts/:id/favorite
func (fc *FavoriteController) ToggleFavorite(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
return
}
isFavored, err := fc.svc.ToggleFavorite(uid, uint(postID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.Ok(c, gin.H{
"favorited": isFavored,
})
}

View File

@ -0,0 +1,188 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
// FollowController 关注 API 控制器
type FollowController struct {
followSvc followUseCase
}
// NewFollowController 构造函数
func NewFollowController(followSvc followUseCase) *FollowController {
return &FollowController{followSvc: followSvc}
}
// Toggle 切换关注 POST /api/users/:uid/follow
func (fc *FollowController) Toggle(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
if err != nil {
if err.Error() == "不能关注自己" {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
// 获取最新状态
status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
common.Ok(c, gin.H{
"is_following": isFollowing,
"status": status,
})
}
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
func (fc *FollowController) GetStatus(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false
status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, status)
}
// Followers 粉丝列表 GET /api/users/:uid/followers
func (fc *FollowController) Followers(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
uid, _, _ := common.GetGinUser(c) // 允许未登录
result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, result)
}
// Following 关注列表 GET /api/users/:uid/following
func (fc *FollowController) Following(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
uid, _, _ := common.GetGinUser(c)
result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, result)
}
// FollowPageController 关注/粉丝页面控制器
type FollowPageController struct {
followSvc followUseCase
}
// NewFollowPageController 构造函数
func NewFollowPageController(followSvc followUseCase) *FollowPageController {
return &FollowPageController{followSvc: followSvc}
}
// FollowersPage 粉丝列表页面
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
}
c.HTML(http.StatusOK, "follow/followers.html", common.BuildPageData(c, gin.H{
"Title": "粉丝列表",
"ExtraCSS": "/static/css/follow.css",
"Result": result,
"TargetUID": targetUID,
"Page": result.Page,
"TotalPages": result.TotalPages,
"HasPrev": result.Page > 1,
"HasNext": result.Page < result.TotalPages,
"PrevPage": result.Page - 1,
"NextPage": result.Page + 1,
}))
}
// FollowingPage 关注列表页面
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
}
c.HTML(http.StatusOK, "follow/following.html", common.BuildPageData(c, gin.H{
"Title": "关注列表",
"ExtraCSS": "/static/css/follow.css",
"Result": result,
"TargetUID": targetUID,
"Page": result.Page,
"TotalPages": result.TotalPages,
"HasPrev": result.Page > 1,
"HasNext": result.Page < result.TotalPages,
"PrevPage": result.Page - 1,
"NextPage": result.Page + 1,
}))
}

View File

@ -1,31 +1,33 @@
package controller package controller
import ( import (
"io"
"metazone.cc/metalab/internal/middleware" "metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
) )
// authUseCase AuthController 对 AuthService 的最小依赖ISP4 个方法) // authUseCase AuthController 对 AuthService 的最小依赖ISP5 个方法)
// 注意Login/Register/ConfirmRestore 不再返回 JWTsession 由 controller 通过 SessionManager 创建
type authUseCase interface { type authUseCase interface {
Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) Register(req model.RegisterRequest, regIP string) (*model.User, error)
Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) Login(req model.LoginRequest, loginIP string) (*model.User, error)
ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error)
CheckEmail(email string) (bool, error) CheckEmail(email string) (bool, error)
IsRegistrationEnabled() bool
} }
// tokenRefresher AuthController 对 TokenService 的最小依赖ISP1 个方法) // rateLimiter AuthController 对 RateLimiter 的最小依赖ISP4 个方法)
type tokenRefresher interface {
RefreshAccessToken(refreshTokenStr string) (string, *model.User, error)
}
// rateLimiter AuthController 对 RateLimiter 的最小依赖ISP3 个方法)
type rateLimiter interface { type rateLimiter interface {
AllowAccount(email string) (middleware.RateLimitResult, func()) AllowAccount(email string) middleware.RateLimitResult
AllowIP(ip string) (middleware.RateLimitResult, func()) AllowIP(ip string) middleware.RateLimitResult
Clear(email, ip string) Clear(email, ip string)
ClearIP(ipKey string)
} }
// postUseCase PostController 对 PostService 的最小依赖ISP6 个方法) // postUseCase PostController 对 PostService 的最小依赖ISP8 个方法)
type postUseCase interface { type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error) Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error) GetByID(id uint) (*model.Post, error)
@ -33,4 +35,154 @@ type postUseCase interface {
Update(postID uint, title, body string) error Update(postID uint, title, body string) error
Delete(postID uint) error Delete(postID uint) error
SubmitForAudit(postID uint) error SubmitForAudit(postID uint) error
RecordRead(userID, postID uint)
RecordGuestRead(visitorID string, postID uint)
IsPostAccessible(userID uint, role string, postUserID uint) bool
}
// studioUseCase StudioController 对 PostService 的最小依赖ISP9 个方法)
type studioUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
Update(postID uint, title, body string) error
Delete(postID uint) error
SubmitForAudit(postID uint) error
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
GetOverview(userID uint) (*service.StudioOverview, error)
IsPostAccessible(userID uint, role string, postUserID uint) bool
CanCreatePost(userID uint) bool
}
// spaceUseCase SpaceController 对 SpaceService 的最小依赖ISP5 个方法)
type spaceUseCase interface {
GetSpaceUser(uid uint) (*model.User, error)
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
CountUserPosts(uid uint) (int64, error)
GetUserStats(uid uint) (likes, favorites, reads int64, err error)
UpdateUser(user *model.User) error
}
// favoriteUseCaseForSpace SpaceController 对 FavoriteService 的最小依赖ISP收藏夹相关
type favoriteUseCaseForSpace interface {
ListFolders(userID uint) (*service.FavoriteListResult, error)
ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error)
}
// sessionManager 登录管理对会话管理的最小依赖ISP4 个方法)
type sessionManager interface {
ListByUID(uid uint) ([]*session.Session, error)
Destroy(sid string) error
DestroyOtherByUID(uid uint, currentSID string) error
UpdateRemark(sid string, uid uint, remark string) error
}
// levelUseCase LevelController 对 LevelService 的最小依赖
type levelUseCase interface {
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
GetLevel(userID uint) (level int, exp int, checkedIn bool, err error)
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
}
// energyUseCase EnergyController 对 EnergyService 的最小依赖ISP3 个方法)
type energyUseCase interface {
Energize(energizerID uint, postID uint, amount int) (int, int, error)
GetEnergyInfo(userID uint, postID uint) (*service.EnergyInfo, error)
GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error)
}
// energyDeducter 删稿/改名时域能扣减接口ISP
type energyDeducter interface {
DeductOnDeletePost(authorUserID uint, postID uint) error
}
// energyRenameHandler 改名域能操作接口ISP
type energyRenameHandler interface {
DeductOnRename(userID uint) error
}
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖ISP4 个方法)
type energyAdminUseCase interface {
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
GetFundBalance() (int, error)
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
}
// commentUseCase CommentController 对 CommentService 的最小依赖ISP6 个方法)
type commentUseCase interface {
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
CreateReply(userID, parentID uint, body string) (*model.Comment, error)
Delete(commentID uint, requesterID uint, isAdmin bool) error
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
ListReplies(rootID uint) ([]model.Comment, error)
SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error)
}
// reactionUseCase ReactionController 对 ReactionService 的最小依赖ISP4 个方法)
type reactionUseCase interface {
ToggleLike(userID, postID uint) (bool, error)
ToggleDislike(userID, postID uint) (bool, error)
GetReaction(userID, postID uint) (service.ReactionType, error)
GetPostLikesCount(postID uint) (int, error)
}
// followUseCase FollowController 对 FollowService 的最小依赖ISP4 个方法)
type followUseCase interface {
Toggle(followerID, followeeID uint) (bool, error)
GetStatus(currentUserID, targetUserID uint) (*service.FollowStatus, error)
ListFollowers(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
ListFollowing(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
}
// favoriteUseCase FavoriteController 对 FavoriteService 的最小依赖ISP9 个方法)
type favoriteUseCase interface {
ListFolders(userID uint) (*service.FavoriteListResult, error)
CreateFolder(userID uint, name, description string, isPublic bool) (*model.Folder, error)
UpdateFolder(userID, folderID uint, name, description string, isPublic bool) error
DeleteFolder(userID, folderID uint) error
ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error)
AddFavorite(userID, postID uint, folderID *uint) error
RemoveFavorite(userID, postID uint) error
ToggleFavorite(userID, postID uint) (bool, error)
GetPostFavoriteStatus(userID, postID uint) (*service.FavoriteStatus, error)
}
// profileProvider SettingsController 对 AuthService 的最小依赖ISP4 个方法)
type profileProvider interface {
GetProfile(userID uint) (*model.User, error)
UpdateProfile(userID uint, username, bio string) error
ChangePassword(userID uint, currentPassword, newPassword string) error
DeleteAccount(userID uint, password, reason string) error
}
// avatarProvider SettingsController 头像上传对 Service 的最小依赖ISP2 个方法)
type avatarProvider interface {
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
}
// auditSubmittable SettingsController 对 AuditService 的依赖ISP4 个方法)
type auditSubmittable interface {
ShouldAudit(userID uint) (bool, error)
SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error
Submit(userID uint, auditType, newValue string) error
GetPendingTypes(userID uint) ([]string, error)
}
// sessionConfig SettingsController 对配置的最小依赖ISP2 个方法)
type sessionConfig interface {
GetIdleTimeout() int
GetRememberTimeout() int
}
// taskCompleter SettingsController 对 LevelService 的依赖ISP2 个方法)
type taskCompleter interface {
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
HasCompletedTask(userID uint, taskType string) (bool, error)
}
// notifyPrefReader SettingsController 通知偏好的接口ISP
type notifyPrefReader interface {
GetNotifyPrefs(userID uint) (map[string]bool, error)
UpdateNotifyPref(userID uint, key string, enabled bool) error
} }

View File

@ -0,0 +1,61 @@
package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// LevelController 积分/等级/签到 API
type LevelController struct {
levelSvc levelUseCase
}
// NewLevelController 构造函数
func NewLevelController(levelSvc levelUseCase) *LevelController {
return &LevelController{levelSvc: levelSvc}
}
// CheckIn 手动签到 API已废弃前端手动按钮保留后端接口
func (lc *LevelController) CheckIn(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
uid := uidVal.(uint)
checkedIn, newExp, levelUp, err := lc.levelSvc.CheckIn(uid)
if err != nil {
common.Error(c, http.StatusInternalServerError, "签到失败")
return
}
level, _, _, _ := lc.levelSvc.GetLevel(uid)
common.Ok(c, gin.H{
"checked_in": checkedIn,
"exp": newExp,
"level": level,
"level_up": levelUp,
})
}
// GetLevel 获取当前等级、经验值和签到状态 API
func (lc *LevelController) GetLevel(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
uid := uidVal.(uint)
level, exp, checkedIn, err := lc.levelSvc.GetLevel(uid)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取失败")
return
}
common.Ok(c, gin.H{"level": level, "exp": exp, "checked_in": checkedIn})
}

View File

@ -10,9 +10,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// notifProvider MessageController 所需的通知服务接口ISP4 个方法) // notifProvider MessageController 所需的通知服务接口ISP5 个方法)
type notifProvider interface { type notifProvider interface {
List(userID uint, page, pageSize int) (*model.NotificationListResult, error) List(userID uint, page, pageSize int) (*model.NotificationListResult, error)
ListByCategory(userID uint, category string, page, pageSize int) (*model.NotificationListResult, error)
CountUnread(userID uint) (int64, error) CountUnread(userID uint) (int64, error)
MarkRead(id, userID uint) error MarkRead(id, userID uint) error
MarkAllRead(userID uint) error MarkAllRead(userID uint) error

View File

@ -10,16 +10,17 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// MessagesPage 消息中心页面需登录noindex // MessagesPage 消息中心页面需登录noindex,支持分类 TAB
func (mc *MessageController) MessagesPage(c *gin.Context) { func (mc *MessageController) MessagesPage(c *gin.Context) {
uidVal, exists := c.Get("uid") uidVal, exists := c.Get("uid")
if !exists { if !exists {
c.Redirect(http.StatusFound, "/auth/login") common.RedirectToLogin(c)
c.Abort()
return return
} }
uid := uidVal.(uint) uid := uidVal.(uint)
tab := c.DefaultQuery("tab", "all")
// 从 query 取分页参数 // 从 query 取分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 { if page < 1 {
@ -27,7 +28,15 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
} }
pageSize := 20 pageSize := 20
result, err := mc.notifService.List(uid, page, pageSize) var result *model.NotificationListResult
var err error
if tab == "all" {
result, err = mc.notifService.List(uid, page, pageSize)
} else {
result, err = mc.notifService.ListByCategory(uid, tab, page, pageSize)
}
if err != nil { if err != nil {
result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1} result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1}
} }
@ -51,5 +60,6 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
"UnreadCount": result.Unread, "UnreadCount": result.Unread,
"NotifyTypeNames": model.NotifyTypeNames, "NotifyTypeNames": model.NotifyTypeNames,
"AuditTypeNames": model.AuditTypeNames, "AuditTypeNames": model.AuditTypeNames,
"CurrentTab": tab,
})) }))
} }

View File

@ -0,0 +1,109 @@
package controller
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// Submit API 提交审核
func (ctrl *PostController) Submit(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
if post.UserID != uid {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return
}
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "提交审核失败")
}
return
}
common.OkMessage(c, "已提交审核")
}
// ListAPI 帖子列表 JSON API
func (ctrl *PostController) ListAPI(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
return
}
common.Ok(c, model.PostListResult{
Items: posts,
Total: total,
Page: page,
TotalPages: common.PageCount(total, pageSize),
})
}
// ShowAPI 帖子详情 JSON API
func (ctrl *PostController) ShowAPI(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
uid, role, _ := common.GetGinUser(c)
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
if post.Status != model.PostStatusApproved {
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
}
// 已登录用户阅读计数(已发布帖子,自动去重+防刷)
if uid > 0 && post.Status == model.PostStatusApproved {
ctrl.postService.RecordRead(uid, post.ID)
}
// 访客阅读计数(基于 Cookie 标识去重+防刷)
if uid == 0 && post.Status == model.PostStatusApproved {
vid := ctrl.getVisitorID(c)
ctrl.postService.RecordGuestRead(vid, post.ID)
}
ctrl.applyShortcode(post)
common.Ok(c, post)
}

View File

@ -1,373 +1,27 @@
package controller package controller
import ( import (
"errors" "bytes"
"fmt" "crypto/rand"
"html/template" "encoding/hex"
"mime/multipart" "image"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// PostController 帖子控制器SSR 页面 + API // PostController 帖子控制器SSR 页面 + API
type PostController struct { type PostController struct {
postService postUseCase postService postUseCase
shortcodeSvc *service.ShortcodeService
} }
// NewPostController 构造函数 // NewPostController 构造函数
func NewPostController(ps postUseCase) *PostController { func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostController {
return &PostController{postService: ps} return &PostController{postService: ps, shortcodeSvc: scs}
}
// ListPage 帖子列表页
func (ctrl *PostController) ListPage(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
if err != nil {
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子",
"Error": "加载帖子列表失败",
}))
return
}
if posts == nil {
posts = []model.Post{}
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
totalPages := p.TotalPages(total)
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子",
"Posts": posts,
"Total": total,
"Page": p.Page,
"TotalPages": totalPages,
"PrevPage": p.PrevPage(),
"NextPage": p.NextPage(totalPages),
"Keyword": keyword,
"ExtraCSS": "/static/css/posts.css",
}))
}
// ShowPage 帖子详情页
func (ctrl *PostController) ShowPage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
// 获取当前用户信息
var uid uint
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
if post.Status != model.PostStatusApproved {
isAuthor := uid == post.UserID
isModerator := model.HasMinRole(role, model.RoleModerator)
if !isAuthor && !isModerator {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
}
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
"Title": post.Title,
"Post": post,
"PostBodyHTML": template.HTML(post.BodyHTML),
"UID": uid,
"StatusNames": model.PostStatusDisplayNames,
"ExtraCSS": "/static/css/posts.css",
}))
}
// NewPage 发帖页面
func (ctrl *PostController) NewPage(c *gin.Context) {
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
"Title": "撰写帖子",
"ExtraCSS": "/static/css/posts.css",
}))
}
// EditPage 编辑页面
func (ctrl *PostController) EditPage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil || post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到或无法编辑",
}))
return
}
// 权限检查:仅作者和 moderator+ 可编辑
var uid uint
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
"Title": "编辑帖子",
"Post": post,
"ExtraCSS": "/static/css/posts.css",
}))
}
// Create API 发帖
func (ctrl *PostController) Create(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req model.PostCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
return
}
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
if err != nil {
common.Error(c, http.StatusInternalServerError, "发帖失败")
return
}
common.OkWithMessage(c, post, "发布成功")
}
// Update API 编辑帖子
func (ctrl *PostController) Update(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req model.PostUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
return
}
// 权限检查:取帖子归属
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
roleObj, _ := c.Get("role")
role, _ := roleObj.(string)
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
common.Error(c, http.StatusForbidden, "无权编辑此帖子")
return
}
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
if errors.Is(err, common.ErrPostCannotEdit) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "编辑失败")
}
return
}
common.OkMessage(c, "编辑成功")
}
// Delete API 删除帖子
func (ctrl *PostController) Delete(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
roleObj, _ := c.Get("role")
role, _ := roleObj.(string)
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
common.Error(c, http.StatusForbidden, "无权删除此帖子")
return
}
if err := ctrl.postService.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
common.OkMessage(c, "删除成功")
}
// Submit API 提交审核
func (ctrl *PostController) Submit(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
// 权限检查:仅帖子作者可提交审核
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
if post.UserID != uid {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return
}
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "提交审核失败")
}
return
}
common.OkMessage(c, "已提交审核")
}
// ListAPI 帖子列表 JSON API
func (ctrl *PostController) ListAPI(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
return
}
if posts == nil {
posts = []model.Post{}
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
common.Ok(c, model.PostListResult{
Items: posts,
Total: total,
Page: p.Page,
TotalPages: p.TotalPages(total),
})
}
// ShowAPI 帖子详情 JSON API
func (ctrl *PostController) ShowAPI(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
if post.Status != model.PostStatusApproved {
var uid uint
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
isAuthor := uid == post.UserID
isModerator := model.HasMinRole(role, model.RoleModerator)
if !isAuthor && !isModerator {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
}
common.Ok(c, post)
} }
// allowedImageExt 允许的图片扩展名 // allowedImageExt 允许的图片扩展名
@ -375,77 +29,61 @@ var allowedImageExt = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
} }
// UploadImage 上传帖子图片需登录multipart/form-data // visitorCookieName Cookie 名称
func (ctrl *PostController) UploadImage(c *gin.Context) { const visitorCookieName = "visitor_id"
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint) // visitorCookieMaxAge 访客 Cookie 有效期30 天)
if !ok { const visitorCookieMaxAge = 30 * 24 * 3600
common.Error(c, http.StatusUnauthorized, "请先登录")
return // getVisitorID 获取或创建访客标识 Cookie
func (ctrl *PostController) getVisitorID(c *gin.Context) string {
if cookie, err := c.Cookie(visitorCookieName); err == nil && cookie != "" {
return cookie
}
// 生成 16 字节随机 hex32 字符)
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return ""
}
vid := hex.EncodeToString(b)
c.SetCookie(visitorCookieName, vid, visitorCookieMaxAge, "/", "", false, true)
return vid
} }
file, header, err := c.Request.FormFile("file") // applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
if err != nil { func (ctrl *PostController) applyShortcode(post *model.Post) {
common.Error(c, http.StatusBadRequest, "请选择文件") if ctrl.shortcodeSvc != nil {
return result := ctrl.shortcodeSvc.Process(post.Body)
post.Body = result.ProcessedBody
} }
defer file.Close()
// 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename))
if !allowedImageExt[ext] {
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
return
} }
// 限制 5MB // encodeWebPBinary 二分查找 WebP 质量,找 ≤ targetBytes 的最高质量quality 1~80
maxSize := int64(5 << 20) // 返回 nil 表示最低质量仍超限(几乎不可能发生),应由调用方回退原格式
if header.Size > maxSize { func encodeWebPBinary(img image.Image, targetBytes int) []byte {
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB") var buf bytes.Buffer
return if err := webp.Encode(&buf, img, &webp.Options{Quality: 80}); err != nil {
}
// 存储路径
storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil {
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return
}
// 唯一文件名uid_timestamp.ext
ts := time.Now().UnixMilli()
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
savePath := filepath.Join(storageDir, filename)
if err := saveUploadedFile(file, savePath); err != nil {
common.Error(c, http.StatusInternalServerError, "保存图片失败")
return
}
url := "/uploads/posts/" + filename
common.Ok(c, gin.H{"url": url})
}
// saveUploadedFile 将 multipart.File 写入目标路径
func saveUploadedFile(file multipart.File, dst string) error {
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
// 限制读取 5MB 防止内存放大
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
}
if err != nil {
break
}
}
return nil return nil
} }
if buf.Len() <= targetBytes {
return buf.Bytes()
}
low, high := 1, 80
var best []byte
for low <= high {
mid := (low + high) / 2
buf.Reset()
if err := webp.Encode(&buf, img, &webp.Options{Quality: float32(mid)}); err != nil {
return nil
}
if buf.Len() <= targetBytes {
best = make([]byte, buf.Len())
copy(best, buf.Bytes())
low = mid + 1
} else {
high = mid - 1
}
}
return best
}

View File

@ -0,0 +1,124 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// ListPage 帖子列表页
func (ctrl *PostController) ListPage(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
if err != nil {
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子",
"Error": "加载帖子列表失败",
}))
return
}
totalPages := common.PageCount(total, pageSize)
prevPage := page - 1
if prevPage < 1 {
prevPage = 1
}
nextPage := page + 1
if nextPage > totalPages {
nextPage = totalPages
}
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子",
"Posts": posts,
"Total": total,
"Page": page,
"TotalPages": totalPages,
"PrevPage": prevPage,
"NextPage": nextPage,
"Keyword": keyword,
"ExtraCSS": "/static/css/posts.css",
}))
}
// ShowPage 帖子详情页
func (ctrl *PostController) ShowPage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
uid, role, _ := common.GetGinUser(c)
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
// 未登录用户:引导登录后回到当前帖子
if uid == 0 {
common.RedirectToLogin(c)
return
}
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
// 已登录用户阅读计数(已发布帖子,自动去重+防刷)
if uid > 0 && post.Status == model.PostStatusApproved {
ctrl.postService.RecordRead(uid, post.ID)
}
// 访客阅读计数(基于 Cookie 标识去重+防刷)
if uid == 0 && post.Status == model.PostStatusApproved {
vid := ctrl.getVisitorID(c)
ctrl.postService.RecordGuestRead(vid, post.ID)
}
ctrl.applyShortcode(post)
// Open Graph 社交分享数据
ogImage := common.ExtractFirstImage(post.Body)
if ogImage != "" && !strings.HasPrefix(ogImage, "http") {
prefix := ""
if !strings.HasPrefix(ogImage, "/") {
prefix = "/"
}
ogImage = "https://" + c.Request.Host + prefix + ogImage
}
ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id)
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
"Title": post.Title,
"Post": post,
"UID": uid,
"StatusNames": common.PostStatusDisplayNames,
"ExtraCSS": "/static/css/posts.css",
"OgTitle": post.Title,
"OgDescription": post.Excerpt,
"OgImage": ogImage,
"OgURL": ogURL,
}))
}

View File

@ -0,0 +1,138 @@
package controller
import (
"bytes"
"fmt"
"image"
_ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画)
"image/jpeg"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"metazone.cc/metalab/internal/common"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin"
)
// UploadImage 上传帖子图片需登录multipart/form-data
// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize
func (ctrl *PostController) UploadImage(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
common.Error(c, http.StatusBadRequest, "请选择文件")
return
}
defer file.Close()
// 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename))
if !allowedImageExt[ext] {
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
return
}
// 限制 5MB
maxSize := int64(5 << 20)
if header.Size > maxSize {
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
return
}
// 读取全部数据(后续 WebP 转换需要)
data, err := io.ReadAll(file)
if err != nil {
common.Error(c, http.StatusInternalServerError, "读取文件失败")
return
}
// 存储路径
storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil {
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return
}
ts := time.Now().UnixMilli()
var savePath, url string
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
isWebP := ext == ".webp"
isGIF := ext == ".gif"
// 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝
// JPEG/PNG解码后做 WebP 压缩(二分查找质量,目标 ≤ 原文件大小)
if isJPEGPNG {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
return
}
webpData := encodeWebPBinary(img, len(data))
if webpData != nil && len(webpData) < len(data) {
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
savePath = filepath.Join(storageDir, filename)
if err := os.WriteFile(savePath, webpData, 0644); err == nil {
url = "/uploads/posts/" + filename
}
}
}
// GIF仅解码验证不转换
if isGIF {
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
return
}
}
// WebP仅解码验证不重复编码
if isWebP {
if _, err := webp.Decode(bytes.NewReader(data)); err != nil {
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
return
}
}
// 回退存储JPEG/PNG 重编码剥离元数据WebP 已验证直接存GIF 不重编(丢动画)
if savePath == "" {
dataOut := data
if isJPEGPNG {
decImg, _, decErr := image.Decode(bytes.NewReader(data))
if decErr == nil {
var buf bytes.Buffer
var encErr error
if ext == ".png" {
encErr = png.Encode(&buf, decImg)
} else {
encErr = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
}
if encErr == nil && buf.Len() > 0 {
dataOut = buf.Bytes()
}
}
}
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
savePath = filepath.Join(storageDir, filename)
if err := os.WriteFile(savePath, dataOut, 0644); err != nil {
common.Error(c, http.StatusInternalServerError, "保存图片失败")
return
}
url = "/uploads/posts/" + filename
}
common.VditorUploadOk(c, map[string]string{header.Filename: url})
}

View File

@ -0,0 +1,108 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
// ReactionController 赞/踩 API 控制器
type ReactionController struct {
reactionSvc reactionUseCase
}
// NewReactionController 构造函数
func NewReactionController(reactionSvc reactionUseCase) *ReactionController {
return &ReactionController{reactionSvc: reactionSvc}
}
// ToggleLike 切换点赞 POST /api/posts/:id/like
func (rc *ReactionController) ToggleLike(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章ID")
return
}
isLiked, err := rc.reactionSvc.ToggleLike(uid, uint(postID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
// 获取最新点赞数
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
common.Ok(c, gin.H{
"liked": isLiked,
"likes_count": likesCount,
})
}
// ToggleDislike 切换踩 POST /api/posts/:id/dislike
func (rc *ReactionController) ToggleDislike(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章ID")
return
}
isDisliked, err := rc.reactionSvc.ToggleDislike(uid, uint(postID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.Ok(c, gin.H{
"disliked": isDisliked,
})
}
// GetReaction 查询当前用户对文章的反应状态 GET /api/posts/:id/reaction
func (rc *ReactionController) GetReaction(c *gin.Context) {
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章ID")
return
}
// 未登录用户返回 none + 公开计数
uid, _, ok := common.GetGinUser(c)
if !ok {
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
common.Ok(c, gin.H{
"reaction": string(service.ReactionNone),
"likes_count": likesCount,
})
return
}
reaction, err := rc.reactionSvc.GetReaction(uid, uint(postID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
common.Ok(c, gin.H{
"reaction": string(reaction),
"likes_count": likesCount,
})
}

View File

@ -55,5 +55,5 @@ func (sc *SettingsController) DeleteAccount(c *gin.Context) {
return return
} }
common.OkMessage(c, "账号已注销7 天内重新登录即可恢复") common.OkMessage(c, "账号已注销7 天内重新登录即可撤销注销")
} }

View File

@ -0,0 +1,28 @@
package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// AuditStatus 查询当前用户的待审核类型(需登录)
func (sc *SettingsController) AuditStatus(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
types, err := sc.auditService.GetPendingTypes(uid.(uint))
if err != nil {
common.Ok(c, gin.H{"pending_types": []string{}})
return
}
if types == nil {
types = []string{}
}
common.Ok(c, gin.H{"pending_types": types})
}

View File

@ -0,0 +1,79 @@
package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// SetNotifyPrefReader 注入通知偏好读写器
func (sc *SettingsController) SetNotifyPrefReader(reader notifyPrefReader) {
sc.notifyPrefReader = reader
}
// GetNotifyPrefs 获取通知偏好 GET /api/settings/notify-prefs
func (sc *SettingsController) GetNotifyPrefs(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
if sc.notifyPrefReader == nil {
common.Error(c, http.StatusInternalServerError, "服务不可用")
return
}
prefs, err := sc.notifyPrefReader.GetNotifyPrefs(uid.(uint))
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取失败")
return
}
common.Ok(c, prefs)
}
// UpdateNotifyPref 更新通知偏好 PUT /api/settings/notify-prefs
func (sc *SettingsController) UpdateNotifyPref(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
if sc.notifyPrefReader == nil {
common.Error(c, http.StatusInternalServerError, "服务不可用")
return
}
var req struct {
Key string `json:"key"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
// 验证 key 是否合法
allowedKeys := map[string]bool{
model.NotifyComment: true,
model.NotifyCommentReply: true,
model.NotifyLikeAggregated: true,
model.NotifyFollow: true,
}
if !allowedKeys[req.Key] {
common.Error(c, http.StatusBadRequest, "无效的通知类型")
return
}
if err := sc.notifyPrefReader.UpdateNotifyPref(uid.(uint), req.Key, req.Enabled); err != nil {
common.Error(c, http.StatusInternalServerError, "更新失败")
return
}
common.OkMessage(c, "通知偏好已更新")
}

View File

@ -43,6 +43,23 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
common.Error(c, http.StatusInternalServerError, "操作失败") common.Error(c, http.StatusInternalServerError, "操作失败")
return return
} }
// 改名:首次免费,非首次扣 6 域能
if req.Username != "" && req.Username != user.Username && sc.energySvc != nil {
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
if hasCompleted {
if err := sc.energySvc.DeductOnRename(userID); err != nil {
switch err {
case common.ErrInsufficientEnergy:
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
default:
common.Error(c, http.StatusInternalServerError, "操作失败")
}
return
}
}
}
if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil { if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil {
handleAuditSubmitError(c, err) handleAuditSubmitError(c, err)
return return
@ -52,11 +69,42 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
} }
// 管理员及以上直接更新 // 管理员及以上直接更新
// 先获取当前用户信息,判断是否改名
currentUser, err := sc.authService.GetProfile(userID)
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
// 改名:首次免费,非首次扣 6 域能
if req.Username != "" && req.Username != currentUser.Username && sc.energySvc != nil {
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
if hasCompleted {
if err := sc.energySvc.DeductOnRename(userID); err != nil {
switch err {
case common.ErrInsufficientEnergy:
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
default:
common.Error(c, http.StatusInternalServerError, "操作失败")
}
return
}
}
}
if err := sc.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil { if err := sc.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil {
handleSettingsError(c, err) handleSettingsError(c, err)
return return
} }
// 触发首次任务奖励(静默失败不影响主流程)
if req.Username != "" {
_, _, _ = sc.levelSvc.CompleteTask(userID, "username")
}
if req.Bio != "" {
_, _, _ = sc.levelSvc.CompleteTask(userID, "bio")
}
common.OkMessage(c, "个人资料已更新") common.OkMessage(c, "个人资料已更新")
} }
@ -112,5 +160,8 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
return return
} }
// 触发首次上传头像奖励
_, _, _ = sc.levelSvc.CompleteTask(userID, "avatar")
common.Ok(c, gin.H{"url": url}) common.Ok(c, gin.H{"url": url})
} }

View File

@ -0,0 +1,161 @@
package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// ListSessions 列出当前用户的所有活跃会话
func (sc *SettingsController) ListSessions(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
sessions, err := sc.sessionMgr.ListByUID(uid.(uint))
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
currentSID, _ := c.Cookie(common.SessionCookieName)
type sessionItem struct {
ID string `json:"id"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Remark string `json:"remark"`
RememberMe bool `json:"remember_me"`
CreatedAt string `json:"created_at"`
LastAccess string `json:"last_access"`
IsCurrent bool `json:"is_current"`
}
var items []sessionItem
for _, s := range sessions {
items = append(items, sessionItem{
ID: s.ID,
IP: s.IP,
UserAgent: s.UserAgent,
Remark: s.Remark,
RememberMe: s.RememberMe,
CreatedAt: s.CreatedAt.Format("2006-01-02 15:04:05"),
LastAccess: s.LastAccess.Format("2006-01-02 15:04:05"),
IsCurrent: s.ID == currentSID,
})
}
if items == nil {
items = []sessionItem{}
}
common.Ok(c, gin.H{"sessions": items})
}
// DestroySession 踢出指定会话(需验证归属当前用户)
func (sc *SettingsController) DestroySession(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
sid := c.Param("sid")
if sid == "" {
common.Error(c, http.StatusBadRequest, "缺少会话 ID")
return
}
// 验证目标会话属于当前用户
sessions, err := sc.sessionMgr.ListByUID(uid.(uint))
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
found := false
for _, s := range sessions {
if s.ID == sid {
found = true
break
}
}
if !found {
common.Error(c, http.StatusNotFound, "会话不存在")
return
}
// 不能踢出当前会话
currentSID, _ := c.Cookie(common.SessionCookieName)
if sid == currentSID {
common.Error(c, http.StatusBadRequest, "不能踢出当前设备,请使用退出登录")
return
}
if err := sc.sessionMgr.Destroy(sid); err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.OkMessage(c, "已踢出该设备")
}
// DestroyOtherSessions 一键踢出非当前设备的所有会话
func (sc *SettingsController) DestroyOtherSessions(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
currentSID, _ := c.Cookie(common.SessionCookieName)
if currentSID == "" {
common.Error(c, http.StatusBadRequest, "无法识别当前会话")
return
}
if err := sc.sessionMgr.DestroyOtherByUID(uid.(uint), currentSID); err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.OkMessage(c, "已踢出所有其他设备")
}
// UpdateSessionRemark 更新指定会话的备注
func (sc *SettingsController) UpdateSessionRemark(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
sid := c.Param("sid")
if sid == "" {
common.Error(c, http.StatusBadRequest, "缺少会话 ID")
return
}
var req struct {
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请检查输入")
return
}
// 备注长度限制
if len(req.Remark) > 32 {
common.Error(c, http.StatusBadRequest, "备注不能超过 32 个字符")
return
}
if err := sc.sessionMgr.UpdateRemark(sid, uid.(uint), req.Remark); err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.OkMessage(c, "备注已更新")
}

View File

@ -1,61 +1,50 @@
package controller package controller
import ( import (
"io"
"net/http" "net/http"
"time"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// profileProvider SettingsController 对 Service 层的最小依赖ISP4 个方法)
type profileProvider interface {
GetProfile(userID uint) (*model.User, error)
UpdateProfile(userID uint, username, bio string) error
ChangePassword(userID uint, currentPassword, newPassword string) error
DeleteAccount(userID uint, password, reason string) error
}
// avatarProvider 头像上传对 Service 层的最小依赖ISP2 个方法)
type avatarProvider interface {
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
}
// auditSubmittable 个人设置对审核服务的依赖ISP4 个方法)
type auditSubmittable interface {
ShouldAudit(userID uint) (bool, error)
SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error
Submit(userID uint, auditType, newValue string) error
GetPendingTypes(userID uint) ([]string, error)
}
// SettingsController 个人设置控制器 // SettingsController 个人设置控制器
type SettingsController struct { type SettingsController struct {
authService profileProvider authService profileProvider
avatarService avatarProvider avatarService avatarProvider
auditService auditSubmittable auditService auditSubmittable
sessionMgr sessionManager
sessionCfg sessionConfig
levelSvc taskCompleter
energySvc energyRenameHandler
notifyPrefReader notifyPrefReader
} }
// NewSettingsController 构造函数 // NewSettingsController 构造函数
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController { func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig, levelSvc taskCompleter) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService} return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg, levelSvc: levelSvc}
}
// WithEnergyService 链式注入域能服务
func (sc *SettingsController) WithEnergyService(svc energyRenameHandler) *SettingsController {
sc.energySvc = svc
return sc
} }
// SettingsPage 个人设置页面(需登录) // SettingsPage 个人设置页面(需登录)
func (sc *SettingsController) SettingsPage(c *gin.Context) { func (sc *SettingsController) SettingsPage(c *gin.Context) {
uidVal, exists := c.Get("uid") uidVal, exists := c.Get("uid")
if !exists { if !exists {
c.Redirect(http.StatusFound, "/auth/login") common.RedirectToLogin(c)
c.Abort()
return return
} }
uid := uidVal.(uint) uid := uidVal.(uint)
tab := c.Param("tab") tab := c.Param("tab")
if tab != "profile" && tab != "account" { if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" && tab != "favorites" && tab != "notify" {
c.String(http.StatusNotFound, "页面不存在") c.String(http.StatusNotFound, "页面不存在")
return return
} }
@ -69,7 +58,14 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
// 查询待审核类型(用于前端显示审核提示) // 查询待审核类型(用于前端显示审核提示)
pendingTypes, _ := sc.auditService.GetPendingTypes(uid) pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{ // 是否已完成首次改名(用于前端确认弹窗显示消耗提示)
hasCompletedRename := false
if sc.levelSvc != nil {
completed, _ := sc.levelSvc.HasCompletedTask(uid, "username")
hasCompletedRename = completed
}
data := gin.H{
"Title": "个人设置", "Title": "个人设置",
"ExtraCSS": "/static/css/settings.css", "ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab, "ActiveTab": tab,
@ -78,24 +74,44 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
"StatusName": model.StatusDisplayNames[user.Status], "StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes, "PendingTypes": pendingTypes,
"AuditTypes": model.AuditTypeNames, "AuditTypes": model.AuditTypeNames,
})) "HasCompletedRename": hasCompletedRename,
} }
// AuditStatus 查询当前用户的待审核类型(需登录) // 登录管理 tab 需要额外数据
func (sc *SettingsController) AuditStatus(c *gin.Context) { if tab == "sessions" {
uid, exists := c.Get("uid") sessions, _ := sc.sessionMgr.ListByUID(uid)
if !exists { if sessions == nil {
common.Error(c, http.StatusUnauthorized, "请先登录") sessions = []*session.Session{}
return }
currentSID, _ := c.Cookie(common.SessionCookieName)
// 解析每个 session 的设备信息
type sessionView struct {
*session.Session
DeviceInfo session.DeviceInfo
IsCurrent bool
TTLMinutes int // 剩余有效分钟数(仅非当前设备显示)
}
var views []sessionView
for _, s := range sessions {
timeout := sc.sessionCfg.GetIdleTimeout() // 临时会话超时(分钟)
if s.RememberMe {
timeout = sc.sessionCfg.GetRememberTimeout() // 记住我超时(分钟)
}
elapsed := int(time.Since(s.LastAccess).Minutes())
ttl := timeout - elapsed
if ttl < 0 {
ttl = 0
}
views = append(views, sessionView{
Session: s,
DeviceInfo: session.ParseUA(s.UserAgent),
IsCurrent: s.ID == currentSID,
TTLMinutes: ttl,
})
}
data["Sessions"] = views
data["CurrentSID"] = currentSID
} }
types, err := sc.auditService.GetPendingTypes(uid.(uint)) c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
if err != nil {
common.Ok(c, gin.H{"pending_types": []string{}})
return
}
if types == nil {
types = []string{}
}
common.Ok(c, gin.H{"pending_types": types})
} }

View File

@ -0,0 +1,121 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
type SpaceController struct {
spaceService spaceUseCase
followSvc followUseCase
favoriteSvc favoriteUseCaseForSpace
}
// NewSpaceController 构造函数
func NewSpaceController(spaceService spaceUseCase) *SpaceController {
return &SpaceController{spaceService: spaceService}
}
// WithFollowService 注入关注服务(可选依赖)
func (ctrl *SpaceController) WithFollowService(svc followUseCase) {
ctrl.followSvc = svc
}
// WithFavoriteService 注入收藏夹服务(可选依赖)
func (ctrl *SpaceController) WithFavoriteService(svc favoriteUseCaseForSpace) {
ctrl.favoriteSvc = svc
}
// MySpace 自己的空间(/space— 需登录,重定向到 /space/{uid}
func (ctrl *SpaceController) MySpace(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.RedirectToLogin(c)
return
}
c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10))
}
// ShowSpace 统一空间页面(/space/:uid?tab=articles|following|followers|collections|settings
func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
uidStr := c.Param("uid")
uid, err := strconv.ParseUint(uidStr, 10, 64)
if err != nil {
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
"Title": "用户不存在",
"Error": "用户不存在",
"ExtraCSS": "/static/css/space.css",
}))
return
}
spaceUser, err := ctrl.spaceService.GetSpaceUser(uint(uid))
if err != nil || spaceUser == nil {
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
"Title": "用户不存在",
"Error": "用户不存在",
"ExtraCSS": "/static/css/space.css",
}))
return
}
// 当前登录用户
currentUID, _, _ := common.GetGinUser(c)
isOwnSpace := currentUID == uint(uid)
// 当前激活的 Tab
tab := c.DefaultQuery("tab", "articles")
// 分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 { page = 1 }
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18"))
if pageSize < 1 || pageSize > 50 { pageSize = 18 }
// 构建基础数据(含用户统计)
postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))
totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid))
data := gin.H{
"Title": spaceUser.Username + " 的空间",
"SpaceUser": spaceUser,
"IsOwnSpace": isOwnSpace,
"ActiveTab": tab,
"CurrentUID": currentUID,
"ExtraCSS": "/static/css/space.css",
"PostCount": postCount,
"TotalLikes": totalLikes,
"TotalFavorites": totalFavorites,
"TotalReads": totalReads,
}
// ---- 根据 Tab 加载对应数据 ----
switch tab {
case "following":
ctrl.loadFollowingData(c, data, uint(uid), currentUID, page, pageSize)
case "followers":
ctrl.loadFollowersData(c, data, uint(uid), currentUID, page, pageSize)
case "collections":
if !isOwnSpace {
// 访客不可看收藏夹,重定向到文章
c.Redirect(http.StatusFound, "/space/"+uidStr+"?tab=articles")
return
}
ctrl.loadCollectionsData(c, data, currentUID, page, pageSize)
case "settings":
if !isOwnSpace {
c.Redirect(http.StatusFound, "/space/"+uidStr+"?tab=articles")
return
}
// 设置页无需额外数据,模板中直接渲染隐私开关
default: // articles
ctrl.loadArticlesData(c, data, uint(uid), page, pageSize)
}
c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, data))
}

View File

@ -0,0 +1,162 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
// loadArticlesData 加载文章列表数据
func (ctrl *SpaceController) loadArticlesData(c *gin.Context, data gin.H, uid uint, page, pageSize int) {
posts, total, err := ctrl.spaceService.GetPostsByUser(uid, page, pageSize)
if err != nil {
posts = []model.Post{}
total = 0
}
totalPages := common.PageCount(total, pageSize)
data["Posts"] = posts
data["Total"] = total
data["Page"] = page
data["TotalPages"] = totalPages
data["PrevPage"] = max(1, page-1)
data["NextPage"] = min(totalPages, page+1)
}
// loadFollowingData 加载关注列表数据
func (ctrl *SpaceController) loadFollowingData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) {
if ctrl.followSvc == nil {
data["FollowingResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
return
}
res, err := ctrl.followSvc.ListFollowing(targetUID, currentUID, page, pageSize)
if err != nil || res == nil {
res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
}
data["FollowingResult"] = res
}
// loadFollowersData 加载粉丝列表数据
func (ctrl *SpaceController) loadFollowersData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) {
if ctrl.followSvc == nil {
data["FollowersResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
return
}
res, err := ctrl.followSvc.ListFollowers(targetUID, currentUID, page, pageSize)
if err != nil || res == nil {
res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
}
data["FollowersResult"] = res
}
// loadCollectionsData 加载收藏夹数据
func (ctrl *SpaceController) loadCollectionsData(c *gin.Context, data gin.H, uid uint, page, pageSize int) {
if ctrl.favoriteSvc == nil {
data["FoldersResult"] = &service.FavoriteListResult{Folders: []model.Folder{}}
return
}
// 收藏夹列表
folders, err := ctrl.favoriteSvc.ListFolders(uid)
if err != nil || folders == nil {
folders = &service.FavoriteListResult{Folders: []model.Folder{}}
}
data["FoldersResult"] = folders
// 默认展示第一个收藏夹的内容
folderParam := c.DefaultQuery("folder", "")
var activeFolderID uint
if folderParam != "" {
fid, parseErr := strconv.ParseUint(folderParam, 10, 64)
if parseErr == nil {
activeFolderID = uint(fid)
}
}
if activeFolderID == 0 && len(folders.Folders) > 0 {
activeFolderID = folders.Folders[0].ID
}
var folderItems *service.FolderItemsResult
if activeFolderID > 0 {
items, itemErr := ctrl.favoriteSvc.ListFolderItems(uid, activeFolderID, page, pageSize)
if itemErr != nil || items == nil {
folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0}
} else {
folderItems = items
}
}
if folderItems == nil {
folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0}
}
data["FolderItems"] = folderItems
data["ActiveFolderID"] = activeFolderID
// 找到当前激活的收藏夹对象传给模板
var activeFolder *model.Folder
for i := range folders.Folders {
if folders.Folders[i].ID == activeFolderID {
activeFolder = &folders.Folders[i]
break
}
}
data["ActiveFolder"] = activeFolder
data["Page"] = page
data["TotalPages"] = folderItems.TotalPages
data["PrevPage"] = max(1, page-1)
data["NextPage"] = min(folderItems.TotalPages, page+1)
}
// UpdatePrivacy PUT /api/space/privacy — 更新隐私设置
func (ctrl *SpaceController) UpdatePrivacy(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req struct {
Field string `json:"field"`
Value bool `json:"value"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
// 支持的字段映射(仅处理已实现的字段)
allowedFields := map[string]string{
"follow_list": "follow_list_public",
"follower_list": "follower_list_public",
}
column, ok := allowedFields[req.Field]
if !ok {
common.OkMessage(c, "已保存") // 未实现字段直接返回成功,前端不感知
return
}
user, err := ctrl.spaceService.GetSpaceUser(uid)
if err != nil || user == nil {
common.Error(c, http.StatusNotFound, "用户不存在")
return
}
switch column {
case "follow_list_public":
user.FollowListPublic = req.Value
case "follower_list_public":
user.FollowerListPublic = req.Value
}
if err := ctrl.spaceService.UpdateUser(user); err != nil {
common.Error(c, http.StatusInternalServerError, "保存失败")
return
}
common.OkMessage(c, "隐私设置已更新")
}

View File

@ -0,0 +1,133 @@
package controller
import (
"log"
"net/http"
"strconv"
"time"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// Delete 删除帖子
func (ctrl *StudioController) Delete(c *gin.Context) {
uid, role, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
post, getErr := ctrl.postService.GetByID(uint(id))
if getErr != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return
}
authorID := post.UserID
if err := ctrl.postService.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
// 删稿扣作者域能(无视负数,有限重试后静默忽略)
if ctrl.energySvc != nil {
const maxRetries = 3
backoff := 100 * time.Millisecond
for i := 0; i < maxRetries; i++ {
if err := ctrl.energySvc.DeductOnDeletePost(authorID, uint(id)); err == nil {
break
}
if i < maxRetries-1 {
time.Sleep(backoff)
backoff *= 2
} else {
log.Printf("删稿扣域能失败(user=%d, post=%d): %v", authorID, id, err)
}
}
}
common.OkMessage(c, "删除成功")
}
// Submit 提交审核
func (ctrl *StudioController) Submit(c *gin.Context) {
uid, role, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
post, getErr := ctrl.postService.GetByID(uint(id))
if getErr != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return
}
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "已提交审核")
}
// TrendsAPI 创作中心趋势数据 API GET /api/studio/trends?period=7d|30d|90d
func (ctrl *StudioController) TrendsAPI(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
if ctrl.trendsSvc == nil {
common.Error(c, http.StatusInternalServerError, "趋势服务不可用")
return
}
period := c.DefaultQuery("period", "7d")
var days int
switch period {
case "7d":
days = 7
case "30d":
days = 30
case "90d":
days = 90
default:
common.Error(c, http.StatusBadRequest, "无效的时间范围,支持 7d/30d/90d")
return
}
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
result, err := ctrl.trendsSvc.GetTrends(uid, since)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取趋势数据失败")
return
}
common.Ok(c, result)
}

View File

@ -0,0 +1,54 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// OverviewAPI 创作中心数据概览
func (ctrl *StudioController) OverviewAPI(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
overview, err := ctrl.postService.GetOverview(uid)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取数据失败")
return
}
common.Ok(c, overview)
}
// ListPostsAPI 我的帖子列表(支持状态筛选)
func (ctrl *StudioController) ListPostsAPI(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取列表失败")
return
}
common.Ok(c, gin.H{
"items": posts,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": common.PageCount(total, pageSize),
})
}

View File

@ -0,0 +1,34 @@
package controller
import (
"metazone.cc/metalab/internal/service"
)
// trendsUseCase StudioController 对趋势服务的最小依赖ISP
type trendsUseCase interface {
GetTrends(userID uint, since string) (*service.TrendResult, error)
}
// StudioController 创作中心控制器SSR 页面 + API
type StudioController struct {
postService studioUseCase
energySvc energyDeducter
trendsSvc trendsUseCase
}
// NewStudioController 构造函数
func NewStudioController(ps studioUseCase) *StudioController {
return &StudioController{postService: ps}
}
// WithEnergyService 链式注入域能服务
func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioController {
ctrl.energySvc = svc
return ctrl
}
// WithTrendsService 链式注入趋势服务
func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioController {
ctrl.trendsSvc = svc
return ctrl
}

View File

@ -0,0 +1,118 @@
package controller
import (
"log"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// OverviewPage 创作中心首页(数据概览)
func (ctrl *StudioController) OverviewPage(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.RedirectToLogin(c)
return
}
overview, err := ctrl.postService.GetOverview(uid)
if err != nil {
c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{
"Title": "创作中心",
"Error": "加载数据失败",
"ActiveTab": "overview",
"ExtraCSS": "/static/css/studio.css",
}))
return
}
c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{
"Title": "创作中心",
"Overview": overview,
"StatusNames": common.PostStatusDisplayNames,
"ActiveTab": "overview",
"ExtraCSS": "/static/css/studio.css",
}))
}
// PostsPage 内容管理页
func (ctrl *StudioController) PostsPage(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.RedirectToLogin(c)
return
}
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize)
if err != nil {
c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{
"Title": "内容管理 - 创作中心",
"Error": "加载失败",
"ActiveTab": "posts",
"ExtraCSS": "/static/css/studio.css",
}))
return
}
overview, err2 := ctrl.postService.GetOverview(uid)
if err2 != nil {
log.Printf("[PostsPage] GetOverview failed for user %d: %v", uid, err2)
}
c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{
"Title": "内容管理 - 创作中心",
"Posts": posts,
"Total": total,
"Page": page,
"PageSize": pageSize,
"TotalPages": common.PageCount(total, pageSize),
"CurrentStatus": status,
"Overview": overview,
"StatusNames": common.PostStatusDisplayNames,
"ActiveTab": "posts",
"ExtraCSS": "/static/css/studio.css",
}))
}
// DraftsPage 草稿箱页面
func (ctrl *StudioController) DraftsPage(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.RedirectToLogin(c)
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.ListByUser(uid, model.PostStatusDraft, page, pageSize)
if err != nil {
c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{
"Title": "草稿箱 - 创作中心",
"Error": "加载失败",
"ActiveTab": "drafts",
"ExtraCSS": "/static/css/studio.css",
}))
return
}
c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{
"Title": "草稿箱 - 创作中心",
"Posts": posts,
"Total": total,
"Page": page,
"PageSize": pageSize,
"TotalPages": common.PageCount(total, pageSize),
"StatusNames": common.PostStatusDisplayNames,
"ActiveTab": "drafts",
"ExtraCSS": "/static/css/studio.css",
}))
}

View File

@ -0,0 +1,78 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// Create 创建/发布帖子
func (ctrl *StudioController) Create(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
// LV0 用户不允许投稿
if !ctrl.postService.CanCreatePost(uid) {
common.Error(c, http.StatusForbidden, "经验不足Lv1 解锁投稿")
return
}
var req model.PostCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
return
}
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
if err != nil {
common.Error(c, http.StatusInternalServerError, "发布失败")
return
}
common.OkWithMessage(c, post, "发布成功")
}
// Update 编辑帖子
func (ctrl *StudioController) Update(c *gin.Context) {
uid, role, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req model.PostUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
return
}
post, getErr := ctrl.postService.GetByID(uint(id))
if getErr != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return
}
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
common.Error(c, http.StatusInternalServerError, "编辑失败")
return
}
common.OkMessage(c, "编辑成功")
}

View File

@ -0,0 +1,77 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// WritePage 写文章页面
func (ctrl *StudioController) WritePage(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.RedirectToLogin(c)
return
}
// 支持编辑模式:传入 post_id 参数
postIDStr := c.Query("id")
if postIDStr != "" {
id, err := strconv.ParseUint(postIDStr, 10, 64)
if err == nil {
post, err := ctrl.postService.GetByID(uint(id))
if err == nil {
_, role, _ := common.GetGinUser(c)
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
"Title": "编辑文章 - 创作中心",
"Post": post,
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
}))
return
}
}
}
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
"Title": "写文章 - 创作中心",
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
}))
}
// AnalyticsPage 数据分析页面
func (ctrl *StudioController) AnalyticsPage(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.RedirectToLogin(c)
return
}
overview, err := ctrl.postService.GetOverview(uid)
if err != nil {
c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{
"Title": "数据分析 - 创作中心",
"Error": "加载失败",
"ActiveTab": "analytics",
"ExtraCSS": "/static/css/studio.css",
}))
return
}
c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{
"Title": "数据分析 - 创作中心",
"Overview": overview,
"ActiveTab": "analytics",
"ExtraCSS": "/static/css/studio.css",
}))
}

View File

@ -1,48 +1,152 @@
package middleware package middleware
import ( import (
"net/http"
"strings"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验) // autoCheckIner AuthMiddleware 对自动签到服务的最小依赖
type autoCheckIner interface {
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
}
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager
type AuthMiddleware struct { type AuthMiddleware struct {
cfg *config.Config cfg *config.Config
userRepo tokenVersionStore sessionManager *session.Manager
levelSvc autoCheckIner
} }
// NewAuthMiddleware 构造函数 // NewAuthMiddleware 构造函数
func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMiddleware { func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware {
return &AuthMiddleware{cfg: cfg, userRepo: userRepo} return &AuthMiddleware{cfg: cfg, sessionManager: sm}
} }
// Required 登录认证中间件:校验 JWT → 检查 token_version → 注入用户信息 // WithLevelService 链式注入等级服务(用于自动签到)
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
am.levelSvc = svc
return am
}
// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
// 页面请求(非 /api/ 前缀)→ 验证失败清除 cookie 并重定向到登录页
// API 请求 → 验证失败返回 401 JSON
func (am *AuthMiddleware) Required() gin.HandlerFunc { func (am *AuthMiddleware) Required() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
claims, err := am.authenticateToken(c) sid, err := c.Cookie(common.SessionCookieName)
if err != nil { if err != nil || sid == "" {
common.ClearAuthCookies(c, am.cfg) am.abortUnauthorized(c)
c.AbortWithStatusJSON(401, gin.H{ return
}
s, err := am.sessionManager.Validate(sid)
if err != nil || s == nil {
am.abortUnauthorized(c)
return
}
injectSessionContext(c, s)
am.tryAutoCheckIn(c, s)
c.Next()
}
}
// abortUnauthorized 统一处理未认证API 返回 JSON页面请求跳转登录
func (am *AuthMiddleware) abortUnauthorized(c *gin.Context) {
common.ClearSessionCookie(c, am.cfg)
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false, "message": "登录已过期,请重新登录", "success": false, "message": "登录已过期,请重新登录",
}) })
} else {
common.RedirectToLogin(c)
}
}
// Optional 可选认证:已登录则注入用户信息,未登录也放行
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
return func(c *gin.Context) {
sid, err := c.Cookie(common.SessionCookieName)
if err != nil || sid == "" {
c.Next()
return
}
s, err := am.sessionManager.Validate(sid)
if err != nil || s == nil {
common.ClearSessionCookie(c, am.cfg)
c.Next()
return
}
injectSessionContext(c, s)
am.tryAutoCheckIn(c, s)
c.Next()
}
}
// tryAutoCheckIn 尝试自动签到,并同步 session Exp 与 DB静默失败不影响主流程
// CheckIn 即使已签到也会返回 DB 中最新的 Exp这里始终与 session 比较,
// 确保 CompleteTask 等非签到路径的 Exp 变化也能同步到 session避免 NAV 显示旧等级
// 被封禁用户跳过自动签到
func (am *AuthMiddleware) tryAutoCheckIn(c *gin.Context, s *session.Session) {
if am.levelSvc == nil || s == nil {
return
}
if s.Status == model.StatusBanned {
return
}
_, newExp, _, _ := am.levelSvc.CheckIn(s.UserID)
// 只允许 Exp 只增不减DB 异常返回 0 或主从延迟读到旧值时不会回退
if newExp > s.Exp {
s.Exp = newExp
_ = am.sessionManager.UpdateSession(s)
c.Set("exp", newExp)
}
}
// injectSessionContext 将会话中的用户信息注入 gin context
func injectSessionContext(c *gin.Context, s *session.Session) {
if s == nil {
return
}
c.Set("uid", s.UserID)
c.Set("email", s.Email)
c.Set("username", s.Username)
c.Set("avatar", s.Avatar)
c.Set("role", s.Role)
c.Set("status", s.Status)
c.Set("exp", s.Exp)
c.Set("sid", s.ID)
}
// BannedWriteGuard 封禁用户写操作守卫
// 仅拦截 POST/PUT/DELETE/PATCH 请求GET/HEAD/OPTIONS 放行
// 需在 Required() 之后调用,确保上下文中已有用户 status
func (am *AuthMiddleware) BannedWriteGuard() gin.HandlerFunc {
return func(c *gin.Context) {
// 只检查写操作
switch c.Request.Method {
case "GET", "HEAD", "OPTIONS":
c.Next()
return
}
status, _ := c.Get("status")
if status == model.StatusBanned {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false,
"message": "账号已被封禁,无法执行此操作",
})
return return
} }
injectUserContext(c, claims)
c.Next()
}
}
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := am.authenticateToken(c)
if err != nil {
c.Next()
return
}
injectUserContext(c, claims)
c.Next() c.Next()
} }
} }

View File

@ -12,16 +12,25 @@ import (
// AdminAuth 管理后台页面认证:失败时 302 跳首页而非返回 JSON // AdminAuth 管理后台页面认证:失败时 302 跳首页而非返回 JSON
func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc { func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
claims, err := am.authenticateToken(c) sid, err := c.Cookie(common.SessionCookieName)
if err != nil { if err != nil || sid == "" {
log.Printf("[AdminAuth] auth failed path=%s err=%v → 302", c.Request.URL.Path, err) common.ClearSessionCookie(c, am.cfg)
common.ClearAuthCookies(c, am.cfg)
c.Redirect(http.StatusFound, "/") c.Redirect(http.StatusFound, "/")
c.Abort() c.Abort()
return return
} }
log.Printf("[AdminAuth] OK: uid=%v role=%v path=%s", claims["uid"], claims["role"], c.Request.URL.Path)
injectUserContext(c, claims) s, err := am.sessionManager.Validate(sid)
if err != nil || s == nil {
log.Printf("[AdminAuth] session invalid path=%s → 302", c.Request.URL.Path)
common.ClearSessionCookie(c, am.cfg)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
log.Printf("[AdminAuth] OK uid=%d role=%s path=%s", s.UserID, s.Role, c.Request.URL.Path)
injectSessionContext(c, s)
c.Next() c.Next()
} }
} }

View File

@ -1,46 +0,0 @@
package middleware
import (
"log"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// parseToken 解析并验证 JWT
func parseToken(tokenStr, secret string) (jwt.MapClaims, error) {
now := time.Now()
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
log.Printf("[parseToken] FAIL: now=%v err=%v (secret_len=%d token_len=%d)",
now, err, len(secret), len(tokenStr))
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, jwt.ErrSignatureInvalid
}
if exp, exists := claims["exp"]; exists {
var expTime time.Time
switch v := exp.(type) {
case float64:
expTime = time.Unix(int64(v), 0)
case *jwt.NumericDate:
expTime = v.Time
}
if !expTime.IsZero() {
log.Printf("[parseToken] OK: exp=%v (%d) now=%v isExpired=%v",
expTime, expTime.Unix(), now, now.After(expTime))
}
}
return claims, nil
}
// IsLoginPage 检查是否已在登录状态,已登录用户跳过登录/注册页
func IsLoginPage(c *gin.Context) bool {
return strings.HasPrefix(c.Request.URL.Path, "/auth/")
}

View File

@ -1,48 +0,0 @@
package middleware
import (
"log"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// authenticateToken 统一的认证核心流程:读 Cookie → 解析 JWT → 检查 token_version
func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, error) {
tokenStr, err := c.Cookie(common.CookieName)
if err != nil {
return nil, err
}
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
if err != nil {
return nil, err
}
uid := uint(claims["uid"].(float64))
tokenVer := int(claims["ver"].(float64))
currentVer, err := am.userRepo.FindTokenVersion(uid)
if err != nil {
return nil, err
}
if tokenVer != currentVer {
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
return nil, common.ErrTokenRevoked
}
return claims, nil
}
// injectUserContext 将 JWT claims 中的用户信息注入 gin context
func injectUserContext(c *gin.Context, claims jwt.MapClaims) {
if claims == nil {
return
}
uid, ok := claims["uid"].(float64)
if !ok {
return
}
c.Set("uid", uint(uid))
c.Set("email", claims["email"])
c.Set("username", claims["username"])
c.Set("role", claims["role"])
}

View File

@ -1,6 +1,8 @@
package middleware package middleware
import ( import (
"crypto/subtle"
"log"
"net/http" "net/http"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
@ -43,7 +45,9 @@ func CSRF(cfg *config.Config) gin.HandlerFunc {
return return
} }
if !constantTimeEq(cookieToken, headerToken) { if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
log.Printf("[CSRF] MISMATCH | path=%s | cookie_len=%d | header_len=%d",
c.Request.URL.Path, len(cookieToken), len(headerToken))
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "CSRF 验证失败", "success": false, "message": "CSRF 验证失败",
}) })

View File

@ -26,7 +26,7 @@ func SetCSRFToken(c *gin.Context, cfg *config.Config) string {
} }
c.SetSameSite(http.SameSiteStrictMode) c.SetSameSite(http.SameSiteStrictMode)
maxAge := int(cfg.JWT.RememberExpire * 3600) maxAge := cfg.Session.RememberTimeout * 60
c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false) c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false)
c.Set(csrfMetaName, token) c.Set(csrfMetaName, token)
return token return token
@ -41,14 +41,4 @@ func generateCSRFToken() (string, error) {
return hex.EncodeToString(b), nil return hex.EncodeToString(b), nil
} }
// constantTimeEq 恒定时间字符串比较(防时序攻击)
func constantTimeEq(a, b string) bool {
if len(a) != len(b) {
return false
}
var result byte
for i := 0; i < len(a); i++ {
result |= a[i] ^ b[i]
}
return result == 0
}

View File

@ -0,0 +1,90 @@
package middleware
import (
"net/http"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"github.com/gin-gonic/gin"
)
// MaintenanceMiddleware 维护模式中间件维护期间仅站长Owner可访问
type MaintenanceMiddleware struct {
cfg *config.Config
siteSettings *config.SiteSettings
sessionManager *session.Manager
}
// NewMaintenanceMiddleware 构造函数
func NewMaintenanceMiddleware(cfg *config.Config, siteSettings *config.SiteSettings, sm *session.Manager) *MaintenanceMiddleware {
return &MaintenanceMiddleware{cfg: cfg, siteSettings: siteSettings, sessionManager: sm}
}
// Handler 返回 Gin 中间件处理函数
func (mm *MaintenanceMiddleware) Handler() gin.HandlerFunc {
return func(c *gin.Context) {
// 未启用维护模式 → 放行
if !mm.siteSettings.IsMaintenanceEnabled() {
c.Next()
return
}
path := c.Request.URL.Path
// 白名单:登录相关页面和 API 始终可访问
if isMaintenanceWhitelist(path) {
c.Next()
return
}
// 读取 session cookie 并验证
sid, err := c.Cookie(common.SessionCookieName)
if err != nil || sid == "" {
blockAccess(c, path)
return
}
s, err := mm.sessionManager.Validate(sid)
if err != nil || s == nil {
blockAccess(c, path)
return
}
if !model.HasMinRole(s.Role, model.RoleOwner) {
blockAccess(c, path)
return
}
c.Next()
}
}
// isMaintenanceWhitelist 判断路径是否在维护白名单中
func isMaintenanceWhitelist(path string) bool {
if path == "/auth/login" || strings.HasPrefix(path, "/api/auth/") {
return true
}
if strings.HasPrefix(path, "/static/") || strings.HasPrefix(path, "/shared/") {
return true
}
if path == "/favicon.ico" || path == "/favicon.svg" {
return true
}
return false
}
// blockAccess 根据请求类型阻止访问
func blockAccess(c *gin.Context, path string) {
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/admin/api/") {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false,
"message": "社区正在维护中,仅站长可访问",
})
return
}
common.RedirectToLogin(c)
}

View File

@ -0,0 +1,24 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// SensitiveRateLimit 敏感操作(改密/注销/签到IP 维度限流中间件。
// 1 分钟最多 5 次请求,超限后封禁 5 分钟。
func SensitiveRateLimit(rl *RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
result := rl.AllowSensitive(c.ClientIP())
if result.Blocked {
c.JSON(http.StatusTooManyRequests, gin.H{
"success": false,
"error": result.Message,
})
c.Abort()
return
}
c.Next()
}
}

View File

@ -21,12 +21,19 @@ func NewRateLimiter() *RateLimiter {
return rl return rl
} }
// AllowAccount 检查账户维度是否允许登录尝试 // AllowAccount 原子检查+递增帐户维度。未被封禁则递增失败计数并返回允许。
func (rl *RateLimiter) AllowAccount(email string) (RateLimitResult, func()) { // 调用方在操作成功(如登录成功)时应调用 Clear 清除计数。
return rl.check(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true) func (rl *RateLimiter) AllowAccount(email string) RateLimitResult {
return rl.try(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true)
} }
// AllowIP 检查 IP 维度是否允许登录尝试 // AllowIP 原子检查+递增 IP 维度。未被封禁则递增失败计数并返回允许。
func (rl *RateLimiter) AllowIP(ip string) (RateLimitResult, func()) { // 调用方在操作成功时应调用 Clear 清除计数。
return rl.check(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false) func (rl *RateLimiter) AllowIP(ip string) RateLimitResult {
return rl.try(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
}
// AllowSensitive 敏感操作限流(改密/注销/签到),基于 IP1 分钟最多 5 次,超限封禁 5 分钟。
func (rl *RateLimiter) AllowSensitive(ip string) RateLimitResult {
return rl.try(rl.ipFailures, "sensitive:"+ip, sensitiveWindow, sensitiveBlockDur, sensitiveMaxRequests, false)
} }

View File

@ -10,6 +10,13 @@ func (rl *RateLimiter) Clear(email, ip string) {
delete(rl.ipFailures, ip) delete(rl.ipFailures, ip)
} }
// ClearIP 按 IP key 清除失败计数(用于注册等仅 IP 维度的限流)
func (rl *RateLimiter) ClearIP(ipKey string) {
rl.mu.Lock()
defer rl.mu.Unlock()
delete(rl.ipFailures, ipKey)
}
// cleanupLoop 定期清理过期条目 // cleanupLoop 定期清理过期条目
func (rl *RateLimiter) cleanupLoop() { func (rl *RateLimiter) cleanupLoop() {
ticker := time.NewTicker(cleanupInterval) ticker := time.NewTicker(cleanupInterval)

View File

@ -21,6 +21,11 @@ const (
ipBlockDur = 1 * time.Minute ipBlockDur = 1 * time.Minute
cleanupInterval = 2 * time.Minute cleanupInterval = 2 * time.Minute
maxEntries = 10000 maxEntries = 10000
// 敏感操作限流:改密/注销/签到
sensitiveWindow = 1 * time.Minute
sensitiveMaxRequests = 5
sensitiveBlockDur = 5 * time.Minute
) )
// windowState 单个维度的限流状态 // windowState 单个维度的限流状态
@ -30,8 +35,10 @@ type windowState struct {
blockedUntil time.Time blockedUntil time.Time
} }
// check 核心检查逻辑 // try 原子检查+递增:在持锁状态下判断是否被限流,若允许则递增计数。
func (rl *RateLimiter) check(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) (RateLimitResult, func()) { // 检查与递增在同一临界区内完成,无竞态窗口。
// 调用方需在操作成功后调用 Clear/或 ClearIP 清除计数。
func (rl *RateLimiter) try(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) RateLimitResult {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
@ -44,38 +51,28 @@ func (rl *RateLimiter) check(m map[string]*windowState, key string, window, bloc
} }
} }
// 已被封禁中
if !state.blockedUntil.IsZero() && now.Before(state.blockedUntil) { if !state.blockedUntil.IsZero() && now.Before(state.blockedUntil) {
retry := int(state.blockedUntil.Sub(now).Seconds()) + 1 retry := int(state.blockedUntil.Sub(now).Seconds()) + 1
msg := "请求过于频繁,请稍后重试" msg := "请求过于频繁,请稍后重试"
if lowKey { if lowKey {
msg = fmt.Sprintf("该账号登录尝试过于频繁,请 %d 秒后重试", retry) msg = fmt.Sprintf("该账号登录尝试过于频繁,请 %d 秒后重试", retry)
} }
return RateLimitResult{Blocked: true, RetryAfter: retry, Message: msg}, nil return RateLimitResult{Blocked: true, RetryAfter: retry, Message: msg}
} }
// 窗口过期 → 重置
if now.Sub(state.windowStart) > window { if now.Sub(state.windowStart) > window {
state.count = 0 state.count = 0
state.windowStart = now state.windowStart = now
state.blockedUntil = time.Time{} state.blockedUntil = time.Time{}
} }
recordFail := func() { // 原子递增计数(本次尝试已记录)
rl.mu.Lock() state.count++
defer rl.mu.Unlock() if state.count >= maxFails {
s := m[key] state.blockedUntil = now.Add(blockDur)
if s == nil {
return
}
if now.Sub(s.windowStart) > window {
s.count = 1
s.windowStart = now
return
}
s.count++
if s.count >= maxFails {
s.blockedUntil = now.Add(blockDur)
}
} }
return RateLimitResult{Blocked: false}, recordFail return RateLimitResult{Blocked: false}
} }

View File

@ -1,6 +0,0 @@
package middleware
// tokenVersionStore 最小接口:仅暴露 AuthMiddleware 需要的 1 个方法
type tokenVersionStore interface {
FindTokenVersion(userID uint) (int, error)
}

View File

@ -1,24 +1,41 @@
package middleware package middleware
import ( import (
"crypto/rand"
"encoding/base64"
"metazone.cc/metalab/internal/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// generateNonce 生成 16 字节随机 noncebase64 编码)
func generateNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// 降级:使用固定长度回退(极度罕见,仅 /dev/urandom 故障时)
panic("failed to generate CSP nonce: " + err.Error())
}
return base64.StdEncoding.EncodeToString(b)
}
// SecurityHeaders 添加安全相关 HTTP 响应头 // SecurityHeaders 添加安全相关 HTTP 响应头
// 作为纵深防御,不影响业务逻辑,纯附加 // 作为纵深防御,不影响业务逻辑,纯附加
func SecurityHeaders() gin.HandlerFunc { func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// 生成一次性 nonce用于 CSP 策略和模板内 <script>/<style> 标签
nonce := generateNonce()
c.Set("csp_nonce", nonce)
// Content-Security-Policy // Content-Security-Policy
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js) // script-src/style-src: 本站 + nonce替代 unsafe-inline
// style-src: 本站 + esm.sh (Tiptap CSS) + cdnjs (highlight.js 主题) // img-src: 本站 + data: URI头像裁切+ blob:(粘贴图片)
// connect-src: 本站 + esm.sh (source map 请求)
// img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片)
c.Header("Content-Security-Policy", c.Header("Content-Security-Policy",
"default-src 'self'; "+ "default-src 'self'; "+
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+ "style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: blob:; "+ "img-src 'self' data: blob:; "+
"connect-src 'self' https://esm.sh") "connect-src 'self'")
// 禁止 MIME 类型嗅探 // 禁止 MIME 类型嗅探
c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Content-Type-Options", "nosniff")
@ -29,6 +46,11 @@ func SecurityHeaders() gin.HandlerFunc {
// 引用策略 // 引用策略
c.Header("Referrer-Policy", "strict-origin-when-cross-origin") c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// HSTS仅在 release 模式启用,避免开发环境 localhost 证书问题
if config.App != nil && config.App.Server.Mode == "release" {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
c.Next() c.Next()
} }
} }

View File

@ -7,12 +7,13 @@ import (
) )
// InjectSiteInfo 注入站点展示信息到请求上下文(供 BuildPageData 使用) // InjectSiteInfo 注入站点展示信息到请求上下文(供 BuildPageData 使用)
// 应用于引擎级全局中间件,确保所有 SSR 页面都能读取 Framework/CopyrightStart // 应用于引擎级全局中间件,确保所有 SSR 页面都能读取 Framework/CopyrightStart/IsMaintenance
func InjectSiteInfo(siteSettings *config.SiteSettings) gin.HandlerFunc { func InjectSiteInfo(siteSettings *config.SiteSettings) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
info := siteSettings.SiteInfo() info := siteSettings.SiteInfo()
c.Set("site_framework", info.Framework) c.Set("site_framework", info.Framework)
c.Set("site_copyright_start", info.CopyrightStart) c.Set("site_copyright_start", info.CopyrightStart)
c.Set("is_maintenance", siteSettings.IsMaintenanceEnabled())
c.Next() c.Next()
} }
} }

65
internal/model/comment.go Normal file
View File

@ -0,0 +1,65 @@
package model
import "time"
// Comment 评论模型
type Comment struct {
ID uint `gorm:"primarykey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID顶级评论指向自身
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论IDNULL=顶级评论
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
LikesCount int `gorm:"default:0" json:"likes_count"`
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
UserID uint `gorm:"index;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 非数据库字段(联表查询填充)
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
// ReplyToName 被回复者当前显示名JOIN users 获取,动态解析,改名后自动更新)
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}非DB字段批量查询填充
// key=创建时的原始@用户名value={uid,当前显示名},用户改名后链接仍有效且显示新名
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
}
// CommentMention @提及映射绑定UID + 原始用户名,支持改名后自动更新显示名)
type CommentMention struct {
ID uint `gorm:"primarykey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
OriginalUsername string `gorm:"type:varchar(64);not null;default:''" json:"original_username"`
}
// MentionInfo @提及显示信息(前端渲染用)
type MentionInfo struct {
UID uint `json:"uid"`
Name string `json:"name"` // 当前显示名
}
// UserSearchResult @搜索用户结果
type UserSearchResult struct {
UID uint `json:"uid"`
Username string `json:"username"`
Level int `json:"level"`
Avatar string `json:"avatar"`
}
// AdminCommentRow 后台评论列表行
type AdminCommentRow struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Body string `json:"body"`
AuthorName string `json:"author_name"`
IsDeleted bool `json:"is_deleted"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -8,7 +8,7 @@ import (
// BaseModel 所有模型的公共字段 // BaseModel 所有模型的公共字段
type BaseModel struct { type BaseModel struct {
ID uint `gorm:"primarykey;column:uid" json:"uid"` ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View File

@ -0,0 +1,10 @@
package model
import "time"
// CommunityFund 公户余额单行表id=1
type CommunityFund struct {
ID uint `gorm:"primaryKey" json:"id"`
Balance int `gorm:"not null;default:0" json:"balance"` // 余额×10允许负数
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,13 @@
package model
import "time"
// DailyExpSummary 每日通过赋能获得的经验汇总(判断是否达 50 上限)
type DailyExpSummary struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_date;not null" json:"user_id"`
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_date;not null" json:"date"` // Asia/Shanghai 自然日
ExpEarned int `gorm:"default:0" json:"exp_earned"` // 当日通过赋能获得的经验值
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,14 @@
package model
import "time"
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
type DailyLikeSummary struct {
ID uint `gorm:"primarykey" json:"id"`
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DDAsia/Shanghai
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -20,4 +20,42 @@ type CheckEmailRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
} }
// ---- Post DTOs ----
// PostListResult 帖子列表查询结果
type PostListResult struct {
Items []Post `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
}
// PostCreateRequest 发帖请求
type PostCreateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostUpdateRequest 编辑请求
type PostUpdateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostRejectRequest 退回请求
type PostRejectRequest struct {
Reason string `json:"reason" binding:"required,min=1,max=500"`
}
// PostLockRequest 锁定请求
type PostLockRequest struct {
Reason string `json:"reason" binding:"required,min=1,max=500"`
}
// PostListQuery 列表查询参数
type PostListQuery struct {
Keyword string `form:"keyword"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}

View File

@ -0,0 +1,38 @@
package model
import "time"
// 域能流水类型常量
const (
EnergyTypeSignIn = "sign_in"
EnergyTypeRename = "rename"
EnergyTypeRenameRefund = "rename_refund"
EnergyTypeEnergize = "energize"
EnergyTypeEnergized = "energized"
EnergyTypeDeletePost = "delete_post"
EnergyTypeAdminAdjust = "admin_adjust"
)
// EnergyLog 域能流水记录
type EnergyLog struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Amount int `gorm:"not null" json:"amount"` // 变化量乘10正为增加负为扣减
Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
Description string `gorm:"type:varchar(500);default:''" json:"description,omitempty"`
OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"` // 操作者UID后台操作时必填
CreatedAt time.Time `json:"created_at"`
}
// EnergyLogDisplayNames 流水类型 → 中文名
var EnergyLogDisplayNames = map[string]string{
EnergyTypeSignIn: "每日签到",
EnergyTypeRename: "改名消耗",
EnergyTypeRenameRefund: "改名退款",
EnergyTypeEnergize: "赋能消耗",
EnergyTypeEnergized: "被赋能",
EnergyTypeDeletePost: "删稿消耗",
EnergyTypeAdminAdjust: "后台调整",
}

30
internal/model/folder.go Normal file
View File

@ -0,0 +1,30 @@
package model
import "time"
// Folder 收藏夹
type Folder struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Name string `gorm:"type:varchar(50);not null" json:"name"`
Description string `gorm:"type:varchar(200);default:''" json:"description"`
IsPublic bool `gorm:"default:false" json:"is_public"`
IsDefault bool `gorm:"default:false" json:"is_default"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 联表查询填充(非数据库字段)
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
}
// FolderItem 收藏记录
type FolderItem struct {
ID uint `gorm:"primarykey" json:"id"`
FolderID uint `gorm:"index;not null" json:"folder_id"`
PostID uint `gorm:"index;not null" json:"post_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
// 联表查询填充
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`
}

View File

@ -0,0 +1,35 @@
package model
import "time"
// 公户流水类型常量
const (
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
)
// FundLog 公户流水记录
type FundLog struct {
ID uint `gorm:"primarykey" json:"id"`
Amount int `gorm:"not null" json:"amount"` // +入公户 / 出公户×10
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"`
Description string `gorm:"type:text;not null" json:"description"`
CreatedAt time.Time `json:"created_at"`
}
// FundLogDisplayNames 公户流水类型 → 中文名
var FundLogDisplayNames = map[string]string{
FundTypeEnergizeTax: "赋能税收",
FundTypeRenameDeduction: "改名扣费",
FundTypeRenameRefund: "改名退款",
FundTypeDeletePost: "删稿扣费",
FundTypeAdminTransfer: "管理转出",
FundTypeSystemOperation: "系统操作",
}

25
internal/model/level.go Normal file
View File

@ -0,0 +1,25 @@
package model
import "fmt"
// LevelThresholds 等级阈值表(经验值区间下限)
// 索引即等级,如 thresholds[3] = 1500 表示 Lv3 至少需要 1500 经验值
var LevelThresholds = []int{0, 1, 200, 1500, 4500, 10800, 28800}
// GetLevelByExp 根据经验值计算当前等级
func GetLevelByExp(exp int) int {
for i := len(LevelThresholds) - 1; i >= 0; i-- {
if exp >= LevelThresholds[i] {
return i
}
}
return 0
}
// GetLevelName 根据等级返回展示文本(如 "Lv3"
func GetLevelName(level int) string {
if level < 0 {
level = 0
}
return fmt.Sprintf("Lv%d", level)
}

View File

@ -4,6 +4,16 @@ package model
const ( const (
NotifyAuditApproved = "audit_approved" NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected" NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected"
NotifyPostLocked = "post_locked"
NotifyPostUnlocked = "post_unlocked"
NotifyLevelUp = "level_up"
NotifyComment = "comment"
NotifyCommentReply = "comment_reply"
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知
NotifyCommentMention = "comment_mention" // 评论@提及通知
) )
// Notification 消息/通知模型 // Notification 消息/通知模型
@ -15,13 +25,40 @@ type Notification struct {
Title string `gorm:"type:varchar(200);not null" json:"title"` Title string `gorm:"type:varchar(200);not null" json:"title"`
Content string `gorm:"type:text" json:"content"` Content string `gorm:"type:text" json:"content"`
IsRead bool `gorm:"index:idx_user_read,priority:2;default:false;not null" json:"is_read"` IsRead bool `gorm:"index:idx_user_read,priority:2;default:false;not null" json:"is_read"`
RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID(文章/评论等)
CommentID *uint `gorm:"default:null" json:"comment_id,omitempty"` // 评论 ID用于跳转高亮目标评论
} }
// NotifyTypeNames 通知类型 → 中文名 // NotifyTypeNames 通知类型 → 中文名
var NotifyTypeNames = map[string]string{ var NotifyTypeNames = map[string]string{
NotifyAuditApproved: "系统通知", NotifyAuditApproved: "资料审核",
NotifyAuditRejected: "系统通知", NotifyAuditRejected: "资料审核",
NotifyPostApproved: "稿件审核",
NotifyPostRejected: "稿件审核",
NotifyPostLocked: "稿件审核",
NotifyPostUnlocked: "稿件审核",
NotifyLevelUp: "等级提升",
NotifyComment: "评论",
NotifyCommentReply: "回复",
NotifyCommentMention: "@ 提及",
NotifyLikeAggregated: "点赞",
NotifyFollow: "关注",
}
// NotifyCategory 通知类型所属分类 TAB
var NotifyCategory = map[string]string{
NotifyAuditApproved: "system",
NotifyAuditRejected: "system",
NotifyPostApproved: "system",
NotifyPostRejected: "system",
NotifyPostLocked: "system",
NotifyPostUnlocked: "system",
NotifyLevelUp: "system",
NotifyComment: "mention",
NotifyCommentReply: "mention",
NotifyCommentMention: "mention",
NotifyLikeAggregated: "like",
NotifyFollow: "follow",
} }
// NotificationListResult 消息列表查询结果 // NotificationListResult 消息列表查询结果

View File

@ -10,12 +10,22 @@ import (
type Post struct { type Post struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"` Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` Body string `gorm:"type:text" json:"body"` // Markdown content
BodyHTML string `gorm:"type:text" json:"body_html"` Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
UserID uint `gorm:"index;not null" json:"user_id"` UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"` Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"`
IsLocked bool `gorm:"default:false" json:"is_locked"`
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"` RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
AllowComment bool `gorm:"default:true" json:"allow_comment"` AllowComment bool `gorm:"default:true" json:"allow_comment"`
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能乘10冗余计数
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@ -34,45 +44,3 @@ const (
PostStatusRejected = "rejected" PostStatusRejected = "rejected"
PostStatusLocked = "locked" PostStatusLocked = "locked"
) )
// PostStatusDisplayNames 状态 → 中文名
var PostStatusDisplayNames = map[string]string{
PostStatusDraft: "草稿",
PostStatusPending: "待审核",
PostStatusApproved: "已发布",
PostStatusRejected: "已退回",
PostStatusLocked: "已锁定",
}
// PostListResult 帖子列表查询结果
type PostListResult struct {
Items []Post `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
}
// PostCreateRequest 发帖请求
type PostCreateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostUpdateRequest 编辑请求
type PostUpdateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostRejectRequest 退回请求
type PostRejectRequest struct {
Reason string `json:"reason" binding:"required,min=1,max=500"`
}
// PostListQuery 列表查询参数
type PostListQuery struct {
Keyword string `form:"keyword"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}

View File

@ -0,0 +1,10 @@
package model
import "time"
// PostDislike 踩记录(仅后台可见)
type PostDislike struct {
UserID uint `gorm:"primaryKey;not null" json:"user_id"`
PostID uint `gorm:"primaryKey;not null" json:"post_id"`
CreatedAt time.Time `json:"created_at"`
}

View File

@ -0,0 +1,12 @@
package model
import "time"
// PostEnergizeLog 赋能操作记录(校验单用户单文章赋能总量 ≤ 2 域能)
type PostEnergizeLog struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"` // 赋能者
PostID uint `gorm:"index;not null" json:"post_id"`
Amount int `gorm:"not null" json:"amount"` // 赋能域能量乘1010或20
CreatedAt time.Time `json:"created_at"`
}

View File

@ -0,0 +1,10 @@
package model
import "time"
// PostLike 点赞记录
type PostLike struct {
UserID uint `gorm:"primaryKey;not null" json:"user_id"`
PostID uint `gorm:"primaryKey;not null" json:"post_id"`
CreatedAt time.Time `json:"created_at"`
}

View File

@ -0,0 +1,20 @@
package model
import "time"
// PostReadLog 文章阅读记录(已登录用户每篇文章只记录一次)
type PostReadLog struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_post_read;not null" json:"user_id"`
PostID uint `gorm:"uniqueIndex:idx_user_post_read;not null" json:"post_id"`
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
}
// PostGuestReadLog 访客阅读记录(未登录用户,基于 Cookie 标识去重)
type PostGuestReadLog struct {
ID uint `gorm:"primarykey" json:"id"`
VisitorID string `gorm:"uniqueIndex:idx_visitor_post_read;type:varchar(64);not null" json:"visitor_id"`
PostID uint `gorm:"uniqueIndex:idx_visitor_post_read;not null" json:"post_id"`
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
}

View File

@ -0,0 +1,92 @@
package model
// =============================================================================
// Shortcode — Markdown 扩展语法
//
// 用法:在 MD 正文中插入 [zone:类型:参数],渲染时替换为对应 UI 卡片。
//
// 支持的 shortcode
// [zone:event:活动ID] → 官方活动卡片(标题、时间、封面、链接)
// [zone:game:游戏slug] → 游戏信息卡片(名称、封面、类型)
// [zone:poll:投票ID] → 投票卡片(※后端 API 开发中)
// [zone:resource:资源ID] → 资源卡片(※后端 API 开发中)
//
// 扩展现有类型:在 ShortcodeRegistry 中注册新类型 + 实现 Renderer 即可。
// =============================================================================
import "regexp"
// =============================================================================
// 语法常量
// =============================================================================
// ShortcodePattern 匹配所有 [zone:type:params] 模式的 shortcode
// 捕获组:$1=类型, $2=参数
var ShortcodePattern = regexp.MustCompile(`\[zone:(\w+):([^\]]+)\]`)
// =============================================================================
// 已注册的 Shortcode 类型
// =============================================================================
// ShortcodeType 定义一种 shortcode 的类型标识
type ShortcodeType string
const (
ShortcodeEvent ShortcodeType = "event" // [zone:event:活动ID]
ShortcodeGame ShortcodeType = "game" // [zone:game:游戏slug]
ShortcodePoll ShortcodeType = "poll" // [zone:poll:投票ID] ※后端 API 开发中
ShortcodeResource ShortcodeType = "resource" // [zone:resource:资源ID] ※后端 API 开发中
)
// allTypes 所有已定义的 shortcode 类型,添加新类型时在这里追加
var allTypes = []ShortcodeType{
ShortcodeEvent,
ShortcodeGame,
ShortcodePoll,
ShortcodeResource,
}
// IsValidType 检查给定类型是否已注册
func IsValidType(t string) bool {
for _, st := range allTypes {
if string(st) == t {
return true
}
}
return false
}
// =============================================================================
// 解析结果
// =============================================================================
// Shortcode 一次解析的结果
type Shortcode struct {
Raw string // 原始文本,如 "[zone:event:abc123]"
Type ShortcodeType // 类型
Params string // 参数ID/slug 等)
}
// ShortcodeResult 整个 body 的解析结果
type ShortcodeResult struct {
// ProcessedBody 替换后的 bodyshortcode → HTML 占位符),可直接传给模板渲染
ProcessedBody string
// Shortcodes 本次解析到的所有 shortcode 列表
Shortcodes []Shortcode
}
// =============================================================================
// 卡片渲染数据 DTO
// =============================================================================
// ShortcodeCard 渲染一张卡片的通用数据
// 前端根据 Type 决定具体 UI 组件,字段按需填充
type ShortcodeCard struct {
Type ShortcodeType `json:"type"`
ID string `json:"id"` // 业务 ID活动ID / 游戏slug
Title string `json:"title,omitempty"`
Cover string `json:"cover,omitempty"`
Desc string `json:"desc,omitempty"`
URL string `json:"url,omitempty"`
Extra string `json:"extra,omitempty"` // 扩展字段时间、标签等JSON string
}

View File

@ -14,7 +14,19 @@ type User struct {
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"` Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"` Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
// 经验值与域能
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
Energy int `gorm:"default:0" json:"energy"` // 域能余额可为负数乘10存储
// 关注系统
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开
// 通知偏好 (JSON string)
NotifyPrefs string `gorm:"type:text;default:'{\"comment\":true,\"comment_reply\":true,\"like_aggregated\":true,\"follow\":true}'" json:"-"`
// 安全与审计 // 安全与审计
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP

View File

@ -0,0 +1,11 @@
package model
import "time"
// UserCheckIn 用户签到记录(自然日维度,严格防重)
type UserCheckIn struct {
BaseModel
UserID uint `gorm:"uniqueIndex:idx_user_checkin_date,priority:1;not null" json:"user_id"`
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_checkin_date,priority:2;not null" json:"date"` // Asia/Shanghai 自然日
}

View File

@ -0,0 +1,18 @@
package model
import "time"
// UserFollow 关注关系
type UserFollow struct {
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
CreatedAt time.Time `json:"created_at"`
// 联表查询填充(非数据库字段)
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`
FolloweeUsername string `gorm:"-:migration;<-:false;column:followee_username" json:"followee_username,omitempty"`
FollowerAvatar string `gorm:"-:migration;<-:false;column:follower_avatar" json:"follower_avatar,omitempty"`
FolloweeAvatar string `gorm:"-:migration;<-:false;column:followee_avatar" json:"followee_avatar,omitempty"`
FollowerBio string `gorm:"-:migration;<-:false;column:follower_bio" json:"follower_bio,omitempty"`
FolloweeBio string `gorm:"-:migration;<-:false;column:followee_bio" json:"followee_bio,omitempty"`
}

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