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
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">
<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/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/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>
<h1 align="center">MetaLab</h1>
@ -17,17 +23,6 @@
MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者提供一个**平等、纯粹、开放**的技术交流空间。
> **当前状态:开发已暂停**。
### ⚠️ 暂停说明
本项目自 2026 年 5 月起暂停开发。主要原因:
- **编辑器集成问题未解决**——作为技术社区的核心功能,尝试了多种富文本/Markdown 编辑器方案,在 SSR + 原生 JS 架构下均无法实现满意的集成效果
- **优先保证社区上线**——经评估,社区早日上线运行的价值高于自建所有基础设施,决定先采用 [Flarum](https://flarum.org)MIT 协议)作为社区平台
本仓库代码保留,后续可能继续开发。
**核心理念:**
- 🎯 **专注技术**——代码、架构、逻辑与可验证的事实
- 🤝 **平等交流**——没有特权,每个成员权利平等
@ -43,7 +38,7 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
| 数据库 | **PostgreSQL** | 16+ |
| 认证 | **JWT** (HS256) | v5 |
| 密码加密 | **bcrypt** (cost=12) | — |
| 配置 | **Viper** | 1.21 |
| 编辑器 | **Vditor** | 3.11 |
| 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — |
| 架构 | 分层六边形架构 | — |
@ -61,12 +56,17 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
-**用户设置页**——个人资料修改(用户名、个性签名、头像上传与裁切)
-**头像处理**——JPEG/PNG/WebP 上传方形裁切512×512 WebP 编码 ≤100KB
-**消息中心**——审核通知推送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 (
"log"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/router"
"metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
@ -25,10 +27,15 @@ func main() {
if err != nil {
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)
}
// 初始化公户行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 的行)
// 支持软删除用户邮箱复用
migrations := []string{
@ -48,6 +55,9 @@ func main() {
r := gin.Default()
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变)
common.ComputeAssetHashes("templates/MetaLab-2026")
// 模板加载(前端主题 + 管理后台)
tmpl, err := theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
@ -65,6 +75,20 @@ func main() {
r.Static("/uploads/avatars", "./storage/uploads/avatars")
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 持久化 + 内存缓存)
siteSettings, err := config.NewSiteSettings(db)
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)

View File

@ -13,11 +13,20 @@ database:
dbname: metalab_dev
sslmode: disable
jwt:
secret: "" # 生产环境通过 JWT_SECRET 环境变量或 .env 注入
access_expire: 15 # 分钟
refresh_expire: 168 # 小时 (7 天)
remember_expire: 720 # 小时 (30 天)
# 会话配置(服务端 Session滑动窗口续期
# 每次请求自动续期,解决"记住我"掉线问题
session:
idle_timeout: 120 # 不记住我空闲超时分钟120 = 2 小时
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:
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 (
github.com/chai2010/webp v1.4.0
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/microcosm-cc/bluemonday v1.0.27
github.com/redis/go-redis/v9 v9.20.0
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0
golang.org/x/sync v0.20.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.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/fsnotify/fsnotify v1.9.0 // 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/goccy/go-json v0.10.5 // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/ugorji/go/codec v1.3.1 // 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
golang.org/x/arch v0.22.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/text v0.37.0 // 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/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
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/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
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/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
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/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
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/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
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/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
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/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/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
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 名称常量
const (
CookieName = "mlb_token"
RefreshCookieName = "mlb_refresh"
RMCookieName = "mlb_rm" // 记住我标记JS 可读
SessionCookieName = "mlb_sid" // 会话 ID CookieHttpOnly
)
// SetAuthCookies 设置 access + refresh Cookie
// rememberMe=true → Cookie 持久化(30天,关浏览器后仍保持登录
// rememberMe=false → Cookie 用 session 模式maxAge=0关浏览器即清除但页面开启期间自动刷新
func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) {
// SetSessionCookie 写入会话 ID Cookie
// rememberMe=true → Cookie maxAge=30天关浏览器后仍保持登录
// rememberMe=false → Cookie maxAge=空闲超时默认2h与Redis TTL一致关浏览器后自动清除
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode)
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
var maxAge int
if rememberMe {
setCookie(c, RefreshCookieName, refreshToken, int(cfg.JWT.RememberExpire*3600), secure)
setPlainCookie(c, RMCookieName, "1", int(cfg.JWT.RememberExpire*3600), secure)
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
} else {
// session cookie关浏览器即清除但页面开启期间自动刷新生效
setCookie(c, RefreshCookieName, refreshToken, 0, secure)
setPlainCookie(c, RMCookieName, "1", 0, secure)
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
}
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
c.SetCookie(SessionCookieName, sid, maxAge, "/", "", secure, true)
}
// SetAccessCookie 仅刷新 access Cookierefresh 续期调用)
func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) {
// ClearSessionCookie 清除会话 ID Cookielogout 调用)
func ClearSessionCookie(c *gin.Context, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode)
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
}
// 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
}
// 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("邮箱或密码错误")
ErrUserNotFound = errors.New("用户不存在")
ErrUserBanned = errors.New("该账号已被封禁")
ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复")
ErrUserDeleted = errors.New("该账号正在注销,登录即撤销注销")
// 统一返回,避免泄露用户存在性、软删除状态等内部信息
ErrUserLocked = errors.New("邮箱或密码错误")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含大写字母、小写字母和数字")
ErrTokenExpired = errors.New("登录已过期,请重新登录")
ErrTokenInvalid = errors.New("无效的认证凭据")
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
@ -32,15 +32,29 @@ var (
// 密码 & 注销相关
ErrIncorrectPassword = errors.New("当前密码错误")
ErrLoginBeforeDelete = errors.New("请先登录再注销")
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号")
ErrNeedsConfirmRestore = errors.New("需要二次确认撤销注销")
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
ErrRegistrationDisabled = errors.New("注册功能已关闭")
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
// 帖子相关
ErrPostNotFound = errors.New("帖子不存在")
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
ErrPostNotFound = errors.New("帖子不存在")
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotLock = 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
import (
"fmt"
"hash/crc32"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"metazone.cc/metalab/internal/model"
"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
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
data := gin.H{}
for k, v := range extra {
data[k] = v
}
// 注入 CSP nonce由 SecurityHeaders 中间件生成)
if nonce, exists := c.Get("csp_nonce"); exists {
data["CSPNonce"] = nonce
}
if username, exists := c.Get("username"); exists {
data["IsLoggedIn"] = true
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 中间件设置到上下文中)
if token, exists := c.Get("csrf_token"); exists {
data["CSRFToken"] = token
@ -25,6 +108,15 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
// 注入管理入口权限moderator 及以上可看到页脚管理面板链接)
if role, exists := c.Get("role"); exists {
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
}
@ -55,6 +147,16 @@ func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
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 注入站点展示信息
func injectSiteInfo(c *gin.Context, data gin.H) {
if framework, exists := c.Get("site_framework"); exists {

View File

@ -49,5 +49,13 @@ func (p *Pagination) NextPage(totalPages int) int {
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

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) {
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 {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"`
Session SessionConfig `mapstructure:"session"`
Redis RedisConfig `mapstructure:"redis"`
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
Roles RolesConfig `mapstructure:"roles"`
Audit AuditConfig `mapstructure:"audit"`
@ -41,11 +42,20 @@ type DatabaseConfig struct {
SSLMode string `mapstructure:"sslmode"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessExpire int `mapstructure:"access_expire"` // 分钟
RefreshExpire int `mapstructure:"refresh_expire"` // 小时
RememberExpire int `mapstructure:"remember_expire"` // 小时
// SessionConfig 服务端会话配置
type SessionConfig struct {
IdleTimeout int `mapstructure:"idle_timeout"` // 不记住我:空闲超时(分钟),默认 144024小时
RememberTimeout int `mapstructure:"remember_timeout"` // 记住我:空闲超时(分钟),默认 4320030天
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 {
@ -91,8 +101,8 @@ func Load(configPath string) *Config {
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",
len(c.JWT.Secret), c.JWT.AccessExpire, c.JWT.RefreshExpire, c.JWT.RememberExpire, c.Server.Mode)
log.Printf("[Config] Session.IdleTimeout=%d min Session.RememberTimeout=%d min Session.CleanupInterval=%d s | Server.Mode=%s",
c.Session.IdleTimeout, c.Session.RememberTimeout, c.Session.CleanupInterval, c.Server.Mode)
App = c
return c
@ -127,7 +137,17 @@ func loadEnvFile(path string) {
// bindEnvOverride 将环境变量映射到 config 的嵌套键
func bindEnvOverride(v *viper.Viper) {
_ = 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 连接字符串

View File

@ -128,3 +128,8 @@ func (s *SiteSettings) SiteInfo() SiteInfoDefaults {
func (s *SiteSettings) IsRegistrationEnabled() bool {
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 {
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{
"Title": "管理首页",
"TotalUsers": totalUsers,
"Title": "管理首页",
"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 {
action = "解封"
} else if req.Status == model.StatusLocked {
action = "已删除"
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
import (
"errors"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
@ -37,127 +35,27 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
return
}
if posts == nil {
posts = []model.Post{}
totalPages := common.PageCount(total, pageSize)
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{
"Title": "内容管理",
"Posts": posts,
"Total": total,
"Page": p.Page,
"Page": page,
"TotalPages": totalPages,
"PrevPage": p.PrevPage(),
"NextPage": p.NextPage(totalPages),
"PrevPage": prevPage,
"NextPage": nextPage,
"Keyword": keyword,
"Status": status,
"StatusNames": model.PostStatusDisplayNames,
"StatusNames": common.PostStatusDisplayNames,
"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
}
// 为每个审核记录附加提交者的用户名
// 为每个审核记录附加提交者的当前用户名
type auditItem struct {
model.AuditSubmission
SubmitterUsername string `json:"submitter_username"`
CurrentUsername string `json:"current_username"`
}
items := make([]auditItem, 0, len(result.Items))
for _, a := range result.Items {
item := auditItem{AuditSubmission: a}
if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
item.SubmitterUsername = user.Username
item.CurrentUsername = user.Username
}
items = append(items, item)
}

View File

@ -3,14 +3,17 @@ package admin
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
)
// adminUseCase AdminController 对 AdminService 的最小依赖ISP4 个方法)
// adminUseCase AdminController 对 AdminService 的最小依赖ISP6 个方法)
type adminUseCase interface {
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
ResetUserToken(operatorUID, targetUID uint) error
CountUsers() (int64, error)
GetPostStats() (*service.PostStats, error)
GetStoreMetrics() session.StoreMetrics
}
// auditUseCase AuditController 对 AuditService 的最小依赖ISP3 个方法)
@ -33,12 +36,27 @@ type auditStatusProvider interface {
FindByID(id uint) (*model.User, error)
}
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP6 个方法)
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP7 个方法)
type adminPostUseCase interface {
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
Reject(postID uint, reason string) error
Lock(postID uint) error
Lock(postID uint, reason string) error
Unlock(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
ip := clientIP(c)
// --- 限流:账户维度 ---
acctResult, recordAccount := ac.rateLimiter.AllowAccount(email)
// --- 限流:账户维度(原子检查+递增)---
acctResult := ac.rateLimiter.AllowAccount(email)
if acctResult.Blocked {
common.Error(c, http.StatusTooManyRequests, acctResult.Message)
return
}
// --- 限流IP 维度 ---
ipResult, recordIP := ac.rateLimiter.AllowIP(ip)
// --- 限流IP 维度(原子检查+递增)---
ipResult := ac.rateLimiter.AllowIP(ip)
if ipResult.Blocked {
common.Error(c, http.StatusTooManyRequests, ipResult.Message)
return
}
accessToken, refreshToken, user, err := ac.authService.Login(req, ip)
user, err := ac.authService.Login(req, ip)
if err != nil {
// 记录失败 → 两个维度各 +1
if recordAccount != nil {
recordAccount()
}
if recordIP != nil {
recordIP()
}
// 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录
switch err {
case common.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁")
case common.ErrUserLocked:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case common.ErrMaintenanceMode:
common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录")
case common.ErrNeedsConfirmRestore:
// 注销账号登录 → 需要二次确认恢复
c.JSON(http.StatusOK, gin.H{
"success": true,
"action": "confirm_restore",
"message": "你的账号正在注销中,登录将中止注销流程",
"message": "你的账号正在注销中,登录将撤销注销并恢复账号",
})
default:
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
@ -78,7 +71,14 @@ func (ac *AuthController) Login(c *gin.Context) {
// 登录成功 → 清除失败计数
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, "登录成功")
}
@ -91,7 +91,7 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
return
}
accessToken, refreshToken, user, err := ac.authService.ConfirmRestore(req, clientIP(c))
user, err := ac.authService.ConfirmRestore(req, clientIP(c))
if err != nil {
switch err {
case common.ErrInvalidCred:
@ -102,6 +102,12 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
return
}
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
common.OkWithMessage(c, user, "账号已恢复,欢迎回来")
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, "注销已撤销,欢迎回来")
}

View File

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

View File

@ -1,10 +1,12 @@
package controller
import (
"html/template"
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/session"
"metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin"
@ -12,32 +14,42 @@ import (
// AuthController 认证相关页面 + API
type AuthController struct {
authService authUseCase
tokenService tokenRefresher
rateLimiter rateLimiter
cfg *config.Config
authService authUseCase
sessionManager *session.Manager
rateLimiter rateLimiter
cfg *config.Config
siteSettings *config.SiteSettings
}
// NewAuthController 构造函数
func NewAuthController(authService authUseCase, tokenSvc tokenRefresher, limiter rateLimiter, cfg *config.Config) *AuthController {
return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg}
func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config, siteSettings *config.SiteSettings) *AuthController {
return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg, siteSettings: siteSettings}
}
// RegisterPage 注册页面(已登录用户重定向到首页,注册关闭时重定向到登录页
// RegisterPage 注册页面(已登录用户重定向到首页)
func (ac *AuthController) RegisterPage(c *gin.Context) {
if _, exists := c.Get("uid"); exists {
c.Redirect(http.StatusFound, "/")
return
}
guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
if err != nil {
c.String(http.StatusInternalServerError, "加载准则失败")
return
// 优先从 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 {
c.String(http.StatusInternalServerError, "加载准则失败")
return
}
guidelines = content
}
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
"Title": "注册",
"ExtraCSS": "/static/css/auth.css",
"Guidelines": guidelines,
"Title": "注册",
"ExtraCSS": "/static/css/auth.css",
"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
import (
"io"
"metazone.cc/metalab/internal/middleware"
"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 {
Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error)
Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
Register(req model.RegisterRequest, regIP string) (*model.User, error)
Login(req model.LoginRequest, loginIP string) (*model.User, error)
ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error)
CheckEmail(email string) (bool, error)
IsRegistrationEnabled() bool
}
// tokenRefresher AuthController 对 TokenService 的最小依赖ISP1 个方法)
type tokenRefresher interface {
RefreshAccessToken(refreshTokenStr string) (string, *model.User, error)
}
// rateLimiter AuthController 对 RateLimiter 的最小依赖ISP3 个方法)
// rateLimiter AuthController 对 RateLimiter 的最小依赖ISP4 个方法)
type rateLimiter interface {
AllowAccount(email string) (middleware.RateLimitResult, func())
AllowIP(ip string) (middleware.RateLimitResult, func())
AllowAccount(email string) middleware.RateLimitResult
AllowIP(ip string) middleware.RateLimitResult
Clear(email, ip string)
ClearIP(ipKey string)
}
// postUseCase PostController 对 PostService 的最小依赖ISP6 个方法)
// postUseCase PostController 对 PostService 的最小依赖ISP8 个方法)
type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
@ -33,4 +35,154 @@ type postUseCase interface {
Update(postID uint, title, body string) error
Delete(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"
)
// notifProvider MessageController 所需的通知服务接口ISP4 个方法)
// notifProvider MessageController 所需的通知服务接口ISP5 个方法)
type notifProvider interface {
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)
MarkRead(id, userID uint) error
MarkAllRead(userID uint) error

View File

@ -10,16 +10,17 @@ import (
"github.com/gin-gonic/gin"
)
// MessagesPage 消息中心页面需登录noindex
// MessagesPage 消息中心页面需登录noindex,支持分类 TAB
func (mc *MessageController) MessagesPage(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
c.Redirect(http.StatusFound, "/auth/login")
c.Abort()
common.RedirectToLogin(c)
return
}
uid := uidVal.(uint)
tab := c.DefaultQuery("tab", "all")
// 从 query 取分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
@ -27,7 +28,15 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
}
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 {
result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1}
}
@ -51,5 +60,6 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
"UnreadCount": result.Unread,
"NotifyTypeNames": model.NotifyTypeNames,
"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
import (
"errors"
"fmt"
"html/template"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"bytes"
"crypto/rand"
"encoding/hex"
"image"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin"
)
// PostController 帖子控制器SSR 页面 + API
type PostController struct {
postService postUseCase
postService postUseCase
shortcodeSvc *service.ShortcodeService
}
// NewPostController 构造函数
func NewPostController(ps postUseCase) *PostController {
return &PostController{postService: ps}
}
// 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)
func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostController {
return &PostController{postService: ps, shortcodeSvc: scs}
}
// allowedImageExt 允许的图片扩展名
@ -375,77 +29,61 @@ var allowedImageExt = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
}
// UploadImage 上传帖子图片需登录multipart/form-data
func (ctrl *PostController) UploadImage(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
// visitorCookieName Cookie 名称
const visitorCookieName = "visitor_id"
// visitorCookieMaxAge 访客 Cookie 有效期30 天)
const visitorCookieMaxAge = 30 * 24 * 3600
// getVisitorID 获取或创建访客标识 Cookie
func (ctrl *PostController) getVisitorID(c *gin.Context) string {
if cookie, err := c.Cookie(visitorCookieName); err == nil && cookie != "" {
return cookie
}
file, header, err := c.Request.FormFile("file")
if err != nil {
common.Error(c, http.StatusBadRequest, "请选择文件")
return
// 生成 16 字节随机 hex32 字符)
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
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
}
// 存储路径
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})
vid := hex.EncodeToString(b)
c.SetCookie(visitorCookieName, vid, visitorCookieMaxAge, "/", "", false, true)
return vid
}
// saveUploadedFile 将 multipart.File 写入目标路径
func saveUploadedFile(file multipart.File, dst string) error {
out, err := os.Create(dst)
if err != nil {
return err
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
func (ctrl *PostController) applyShortcode(post *model.Post) {
if ctrl.shortcodeSvc != nil {
result := ctrl.shortcodeSvc.Process(post.Body)
post.Body = result.ProcessedBody
}
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
}
// encodeWebPBinary 二分查找 WebP 质量,找 ≤ targetBytes 的最高质量quality 1~80
// 返回 nil 表示最低质量仍超限(几乎不可能发生),应由调用方回退原格式
func encodeWebPBinary(img image.Image, targetBytes int) []byte {
var buf bytes.Buffer
if err := webp.Encode(&buf, img, &webp.Options{Quality: 80}); err != 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
}
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, "操作失败")
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 {
handleAuditSubmitError(c, err)
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 {
handleSettingsError(c, err)
return
}
// 触发首次任务奖励(静默失败不影响主流程)
if req.Username != "" {
_, _, _ = sc.levelSvc.CompleteTask(userID, "username")
}
if req.Bio != "" {
_, _, _ = sc.levelSvc.CompleteTask(userID, "bio")
}
common.OkMessage(c, "个人资料已更新")
}
@ -112,5 +160,8 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
return
}
// 触发首次上传头像奖励
_, _, _ = sc.levelSvc.CompleteTask(userID, "avatar")
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
import (
"io"
"net/http"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"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 个人设置控制器
type SettingsController struct {
authService profileProvider
avatarService avatarProvider
auditService auditSubmittable
authService profileProvider
avatarService avatarProvider
auditService auditSubmittable
sessionMgr sessionManager
sessionCfg sessionConfig
levelSvc taskCompleter
energySvc energyRenameHandler
notifyPrefReader notifyPrefReader
}
// NewSettingsController 构造函数
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService}
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig, levelSvc taskCompleter) *SettingsController {
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 个人设置页面(需登录)
func (sc *SettingsController) SettingsPage(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
c.Redirect(http.StatusFound, "/auth/login")
c.Abort()
common.RedirectToLogin(c)
return
}
uid := uidVal.(uint)
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, "页面不存在")
return
}
@ -69,33 +58,60 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
// 查询待审核类型(用于前端显示审核提示)
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
"StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes,
"AuditTypes": model.AuditTypeNames,
}))
}
// AuditStatus 查询当前用户的待审核类型(需登录)
func (sc *SettingsController) AuditStatus(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
// 是否已完成首次改名(用于前端确认弹窗显示消耗提示)
hasCompletedRename := false
if sc.levelSvc != nil {
completed, _ := sc.levelSvc.HasCompletedTask(uid, "username")
hasCompletedRename = completed
}
types, err := sc.auditService.GetPendingTypes(uid.(uint))
if err != nil {
common.Ok(c, gin.H{"pending_types": []string{}})
return
data := gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
"StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes,
"AuditTypes": model.AuditTypeNames,
"HasCompletedRename": hasCompletedRename,
}
if types == nil {
types = []string{}
// 登录管理 tab 需要额外数据
if tab == "sessions" {
sessions, _ := sc.sessionMgr.ListByUID(uid)
if sessions == nil {
sessions = []*session.Session{}
}
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
}
common.Ok(c, gin.H{"pending_types": types})
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
}

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
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"
)
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验)
// autoCheckIner AuthMiddleware 对自动签到服务的最小依赖
type autoCheckIner interface {
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
}
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager
type AuthMiddleware struct {
cfg *config.Config
userRepo tokenVersionStore
cfg *config.Config
sessionManager *session.Manager
levelSvc autoCheckIner
}
// NewAuthMiddleware 构造函数
func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMiddleware {
return &AuthMiddleware{cfg: cfg, userRepo: userRepo}
func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware {
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 {
return func(c *gin.Context) {
claims, err := am.authenticateToken(c)
if err != nil {
common.ClearAuthCookies(c, am.cfg)
c.AbortWithStatusJSON(401, gin.H{
"success": false, "message": "登录已过期,请重新登录",
})
sid, err := c.Cookie(common.SessionCookieName)
if err != nil || sid == "" {
am.abortUnauthorized(c)
return
}
injectUserContext(c, claims)
s, err := am.sessionManager.Validate(sid)
if err != nil || s == nil {
am.abortUnauthorized(c)
return
}
injectSessionContext(c, s)
am.tryAutoCheckIn(c, s)
c.Next()
}
}
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行
// 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": "登录已过期,请重新登录",
})
} else {
common.RedirectToLogin(c)
}
}
// Optional 可选认证:已登录则注入用户信息,未登录也放行
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := am.authenticateToken(c)
if err != nil {
sid, err := c.Cookie(common.SessionCookieName)
if err != nil || sid == "" {
c.Next()
return
}
injectUserContext(c, claims)
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
}
c.Next()
}
}

View File

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

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
import (
"crypto/subtle"
"log"
"net/http"
"metazone.cc/metalab/internal/config"
@ -43,7 +45,9 @@ func CSRF(cfg *config.Config) gin.HandlerFunc {
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{
"success": false, "message": "CSRF 验证失败",
})

View File

@ -26,7 +26,7 @@ func SetCSRFToken(c *gin.Context, cfg *config.Config) string {
}
c.SetSameSite(http.SameSiteStrictMode)
maxAge := int(cfg.JWT.RememberExpire * 3600)
maxAge := cfg.Session.RememberTimeout * 60
c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false)
c.Set(csrfMetaName, token)
return token
@ -41,14 +41,4 @@ func generateCSRFToken() (string, error) {
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
}
// AllowAccount 检查账户维度是否允许登录尝试
func (rl *RateLimiter) AllowAccount(email string) (RateLimitResult, func()) {
return rl.check(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true)
// AllowAccount 原子检查+递增帐户维度。未被封禁则递增失败计数并返回允许。
// 调用方在操作成功(如登录成功)时应调用 Clear 清除计数。
func (rl *RateLimiter) AllowAccount(email string) RateLimitResult {
return rl.try(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true)
}
// AllowIP 检查 IP 维度是否允许登录尝试
func (rl *RateLimiter) AllowIP(ip string) (RateLimitResult, func()) {
return rl.check(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
// AllowIP 原子检查+递增 IP 维度。未被封禁则递增失败计数并返回允许。
// 调用方在操作成功时应调用 Clear 清除计数。
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)
}
// ClearIP 按 IP key 清除失败计数(用于注册等仅 IP 维度的限流)
func (rl *RateLimiter) ClearIP(ipKey string) {
rl.mu.Lock()
defer rl.mu.Unlock()
delete(rl.ipFailures, ipKey)
}
// cleanupLoop 定期清理过期条目
func (rl *RateLimiter) cleanupLoop() {
ticker := time.NewTicker(cleanupInterval)

View File

@ -21,6 +21,11 @@ const (
ipBlockDur = 1 * time.Minute
cleanupInterval = 2 * time.Minute
maxEntries = 10000
// 敏感操作限流:改密/注销/签到
sensitiveWindow = 1 * time.Minute
sensitiveMaxRequests = 5
sensitiveBlockDur = 5 * time.Minute
)
// windowState 单个维度的限流状态
@ -30,8 +35,10 @@ type windowState struct {
blockedUntil time.Time
}
// check 核心检查逻辑
func (rl *RateLimiter) check(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) (RateLimitResult, func()) {
// try 原子检查+递增:在持锁状态下判断是否被限流,若允许则递增计数。
// 检查与递增在同一临界区内完成,无竞态窗口。
// 调用方需在操作成功后调用 Clear/或 ClearIP 清除计数。
func (rl *RateLimiter) try(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) RateLimitResult {
rl.mu.Lock()
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) {
retry := int(state.blockedUntil.Sub(now).Seconds()) + 1
msg := "请求过于频繁,请稍后重试"
if lowKey {
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 {
state.count = 0
state.windowStart = now
state.blockedUntil = time.Time{}
}
recordFail := func() {
rl.mu.Lock()
defer rl.mu.Unlock()
s := m[key]
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)
}
// 原子递增计数(本次尝试已记录)
state.count++
if state.count >= maxFails {
state.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
import (
"crypto/rand"
"encoding/base64"
"metazone.cc/metalab/internal/config"
"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 响应头
// 作为纵深防御,不影响业务逻辑,纯附加
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// 生成一次性 nonce用于 CSP 策略和模板内 <script>/<style> 标签
nonce := generateNonce()
c.Set("csp_nonce", nonce)
// Content-Security-Policy
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js)
// style-src: 本站 + esm.sh (Tiptap CSS) + cdnjs (highlight.js 主题)
// connect-src: 本站 + esm.sh (source map 请求)
// img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片)
// script-src/style-src: 本站 + nonce替代 unsafe-inline
// img-src: 本站 + data: URI头像裁切+ blob:(粘贴图片)
c.Header("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: blob:; "+
"connect-src 'self' https://esm.sh")
"connect-src 'self'")
// 禁止 MIME 类型嗅探
c.Header("X-Content-Type-Options", "nosniff")
@ -29,6 +46,11 @@ func SecurityHeaders() gin.HandlerFunc {
// 引用策略
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()
}
}

View File

@ -7,12 +7,13 @@ import (
)
// InjectSiteInfo 注入站点展示信息到请求上下文(供 BuildPageData 使用)
// 应用于引擎级全局中间件,确保所有 SSR 页面都能读取 Framework/CopyrightStart
// 应用于引擎级全局中间件,确保所有 SSR 页面都能读取 Framework/CopyrightStart/IsMaintenance
func InjectSiteInfo(siteSettings *config.SiteSettings) gin.HandlerFunc {
return func(c *gin.Context) {
info := siteSettings.SiteInfo()
c.Set("site_framework", info.Framework)
c.Set("site_copyright_start", info.CopyrightStart)
c.Set("is_maintenance", siteSettings.IsMaintenanceEnabled())
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 所有模型的公共字段
type BaseModel struct {
ID uint `gorm:"primarykey;column:uid" json:"uid"`
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
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"`
}
// ---- 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

@ -2,8 +2,18 @@ package model
// 消息/通知类型常量
const (
NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected"
NotifyAuditApproved = "audit_approved"
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 消息/通知模型
@ -15,13 +25,40 @@ type Notification struct {
Title string `gorm:"type:varchar(200);not null" json:"title"`
Content string `gorm:"type:text" json:"content"`
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 通知类型 → 中文名
var NotifyTypeNames = map[string]string{
NotifyAuditApproved: "系统通知",
NotifyAuditRejected: "系统通知",
NotifyAuditApproved: "资料审核",
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 消息列表查询结果

View File

@ -8,17 +8,27 @@ import (
// Post 帖子/文章模型
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"`
BodyHTML string `gorm:"type:text" json:"body_html"`
UserID uint `gorm:"index;not null" json:"user_id"`
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
AllowComment bool `gorm:"default:true" json:"allow_comment"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
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 for list
UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
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"`
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:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 非数据库字段(联表查询填充)
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
@ -34,45 +44,3 @@ const (
PostStatusRejected = "rejected"
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

@ -12,15 +12,27 @@ type User struct {
Avatar string `gorm:"type:varchar(500);default:''" json:"avatar"`
Bio string `gorm:"type:text" json:"bio"`
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"`
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
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"`
// 经验值与域能
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
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
LastLoginAt *time.Time `gorm:"default:null" json:"last_login_at,omitempty"` // 最后登录时间
DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因
DeleteReason string `gorm:"type:text;default:''" json:"-"` // 注销原因
}
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动)

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