Compare commits

..

113 Commits

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
183 changed files with 15164 additions and 3167 deletions

View File

@ -1,3 +1,10 @@
> [!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">

View File

@ -27,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{
@ -70,14 +75,16 @@ func main() {
r.Static("/uploads/avatars", "./storage/uploads/avatars")
r.Static("/uploads/posts", "./storage/uploads/posts")
// Redis 客户端(可选)
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回
var redisClient *redis.Client
if cfg.Redis.Enabled {
redisClient, err = common.NewRedisClient(cfg.Redis)
if err != nil {
log.Fatalf("Redis 连接失败: %v", err)
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)
}
log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
} else {
log.Println("Redis 未启用,使用内存存储")
}

View File

@ -22,7 +22,7 @@ session:
# Redis 配置可选enabled: false 时使用内存存储)
redis:
enabled: false # 是否启用 Redis 存储会话
enabled: true # 是否启用 Redis 存储会话
host: 127.0.0.1
port: 6379
password: "" # 敏感值由 .env 注入

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;

2
go.mod
View File

@ -9,6 +9,7 @@ require (
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
)
@ -56,7 +57,6 @@ require (
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

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

@ -15,8 +15,8 @@ const (
)
// SetSessionCookie 写入会话 ID Cookie
// rememberMe=true → Cookie 持久化(30天,关浏览器后仍保持登录
// rememberMe=false → Cookie session 模式maxAge=0,关浏览器清除
// 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)
@ -25,7 +25,7 @@ func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.C
if rememberMe {
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
} else {
maxAge = 0 // session cookie
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
}
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)

View File

@ -14,7 +14,7 @@ var (
ErrUserDeleted = errors.New("该账号正在注销中,登录即撤销注销")
// 统一返回,避免泄露用户存在性、软删除状态等内部信息
ErrUserLocked = errors.New("邮箱或密码错误")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含大写字母、小写字母和数字")
ErrTokenExpired = errors.New("登录已过期,请重新登录")
ErrTokenInvalid = errors.New("无效的认证凭据")
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
@ -45,4 +45,16 @@ var (
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("评论内容不能为空")
)

View File

@ -82,10 +82,23 @@ func BuildPageData(c *gin.Context, extra gin.H) 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
@ -101,6 +114,10 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
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
}

View File

@ -3,25 +3,33 @@ package common
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"metazone.cc/metalab/internal/config"
)
// NewRedisClient 创建 Redis 客户端并验证连接
// 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,
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 {
return nil, fmt.Errorf("Redis 连接失败: %w", err)
log.Printf("[Redis] 连接失败: %v将降级为内存存储", err)
return client, fmt.Errorf("Redis 连接失败: %w", err)
}
return client, nil

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,10 +29,17 @@ 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,
"PostTotal": postStats.Total,
"PostApproved": postStats.Approved,
"PostPending": postStats.Pending,
"StoreType": storeMetrics.Type,
"StoreHealthy": storeMetrics.Healthy,
"StoreDegraded": storeMetrics.Degraded,

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"
)
@ -61,109 +59,3 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
"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) || 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

@ -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

@ -6,12 +6,13 @@ import (
"metazone.cc/metalab/internal/session"
)
// adminUseCase AdminController 对 AdminService 的最小依赖ISP5 个方法)
// 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
}
@ -41,7 +42,21 @@ type adminPostUseCase interface {
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

@ -51,8 +51,6 @@ func (ac *AuthController) Login(c *gin.Context) {
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:

View File

@ -1,6 +1,7 @@
package controller
import (
"html/template"
"net/http"
"metazone.cc/metalab/internal/common"
@ -17,11 +18,12 @@ type AuthController struct {
sessionManager *session.Manager
rateLimiter rateLimiter
cfg *config.Config
siteSettings *config.SiteSettings
}
// NewAuthController 构造函数
func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config) *AuthController {
return &AuthController{authService: authService, sessionManager: sm, 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 注册页面(已登录用户重定向到首页)
@ -30,10 +32,18 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
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": "注册",

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,6 +1,8 @@
package controller
import (
"io"
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
@ -25,7 +27,7 @@ type rateLimiter interface {
ClearIP(ipKey string)
}
// postUseCase PostController 对 PostService 的最小依赖ISP7 个方法)
// postUseCase PostController 对 PostService 的最小依赖ISP8 个方法)
type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
@ -33,10 +35,12 @@ 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 的最小依赖ISP8 个方法)
// studioUseCase StudioController 对 PostService 的最小依赖ISP9 个方法)
type studioUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
@ -46,12 +50,22 @@ type studioUseCase interface {
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 的最小依赖ISP2 个方法)
// 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 个方法)
@ -61,3 +75,114 @@ type sessionManager interface {
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,7 +10,7 @@ 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 {
@ -19,6 +19,8 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
}
uid := uidVal.(uint)
tab := c.DefaultQuery("tab", "all")
// 从 query 取分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
@ -26,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}
}
@ -50,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

@ -2,21 +2,10 @@ package controller
import (
"bytes"
"errors"
"fmt"
"crypto/rand"
"encoding/hex"
"image"
"image/jpeg"
"image/png"
_ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画)
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
@ -35,455 +24,30 @@ func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostContr
return &PostController{postService: ps, shortcodeSvc: scs}
}
// ListPage 帖子列表页
func (ctrl *PostController) ListPage(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
if err != nil {
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子",
"Error": "加载帖子列表失败",
}))
return
}
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": "未找到",
}))
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
}
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": "未找到",
}))
return
}
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,
}))
}
// 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.IsLocked {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到或无法编辑",
}))
return
}
uid, 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
}
// 已通过帖子有待审修订时,编辑页展示待审内容
if post.PendingBody != "" {
post.Title = post.PendingTitle
post.Body = post.PendingBody
}
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) {
uid, _, ok := common.GetGinUser(c)
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) {
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
}
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
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) {
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
}
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
return
}
if err := ctrl.postService.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
common.OkMessage(c, "删除成功")
}
// getPostAndCheckAccess 获取帖子并检查当前用户权限
// 返回 (*model.Post, true) 表示通过;返回 (nil, false) 表示已写入错误响应
func (ctrl *PostController) getPostAndCheckAccess(id uint, c *gin.Context, uid uint, role string) (*model.Post, bool) {
post, err := ctrl.postService.GetByID(id)
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return nil, false
}
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return nil, false
}
return post, true
}
// 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
}
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
if post.Status != model.PostStatusApproved {
uid, role, _ := common.GetGinUser(c)
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
}
ctrl.applyShortcode(post)
common.Ok(c, post)
}
// allowedImageExt 允许的图片扩展名
var allowedImageExt = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
}
// 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
// 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
}
// 读取全部数据(后续 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})
vid := hex.EncodeToString(b)
c.SetCookie(visitorCookieName, vid, visitorCookieMaxAge, "/", "", false, true)
return vid
}
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
@ -523,4 +87,3 @@ func encodeWebPBinary(img image.Image, targetBytes int) []byte {
}
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

@ -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

@ -1,7 +1,6 @@
package controller
import (
"io"
"net/http"
"time"
@ -12,46 +11,27 @@ import (
"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)
}
// sessionConfig 登录管理对配置的最小依赖ISP2 个方法)
type sessionConfig interface {
GetIdleTimeout() int // 分钟
GetRememberTimeout() int // 分钟
}
// SettingsController 个人设置控制器
type SettingsController struct {
authService profileProvider
avatarService avatarProvider
auditService auditSubmittable
sessionMgr sessionManager
sessionCfg sessionConfig
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, sessionMgr sessionManager, sessionCfg sessionConfig) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg}
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 个人设置页面(需登录)
@ -64,7 +44,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
uid := uidVal.(uint)
tab := c.Param("tab")
if tab != "profile" && tab != "account" && tab != "sessions" {
if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" && tab != "favorites" && tab != "notify" {
c.String(http.StatusNotFound, "页面不存在")
return
}
@ -78,15 +58,23 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
// 查询待审核类型(用于前端显示审核提示)
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
// 是否已完成首次改名(用于前端确认弹窗显示消耗提示)
hasCompletedRename := false
if sc.levelSvc != nil {
completed, _ := sc.levelSvc.HasCompletedTask(uid, "username")
hasCompletedRename = completed
}
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,
"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,
}
// 登录管理 tab 需要额外数据
@ -127,22 +115,3 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
}
// 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

@ -9,9 +9,11 @@ import (
"github.com/gin-gonic/gin"
)
// SpaceController 用户空间控制器
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
type SpaceController struct {
spaceService spaceUseCase
spaceService spaceUseCase
followSvc followUseCase
favoriteSvc favoriteUseCaseForSpace
}
// NewSpaceController 构造函数
@ -19,6 +21,16 @@ 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)
@ -29,7 +41,7 @@ func (ctrl *SpaceController) MySpace(c *gin.Context) {
c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10))
}
// ShowSpace 查看他人或自己的空间/space/:uid)— 无需认证
// 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)
@ -52,52 +64,58 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
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", "10"))
if pageSize < 1 || pageSize > 50 {
pageSize = 10
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,
}
posts, total, err := ctrl.spaceService.GetPostsByUser(uint(uid), page, pageSize)
if err != nil {
c.HTML(http.StatusInternalServerError, "space/index.html", common.BuildPageData(c, gin.H{
"Title": "加载失败",
"Error": "加载帖子失败,请稍后重试",
"ExtraCSS": "/static/css/space.css",
}))
return
// ---- 根据 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)
}
totalPages := common.PageCount(total, pageSize)
prevPage := page - 1
if prevPage < 1 {
prevPage = 1
}
nextPage := page + 1
if nextPage > totalPages {
nextPage = totalPages
}
// 检查是否是自己的空间
isOwnSpace := false
if currentUID, _, ok := common.GetGinUser(c); ok {
isOwnSpace = currentUID == uint(uid)
}
c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, gin.H{
"Title": spaceUser.Username + " 的空间",
"SpaceUser": spaceUser,
"Posts": posts,
"Total": total,
"Page": page,
"TotalPages": totalPages,
"PrevPage": prevPage,
"NextPage": nextPage,
"IsOwnSpace": isOwnSpace,
"ExtraCSS": "/static/css/space.css",
}))
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

@ -1,18 +1,19 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
"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 构造函数
@ -20,349 +21,14 @@ func NewStudioController(ps studioUseCase) *StudioController {
return &StudioController{postService: ps}
}
// ==============================
// SSR 页面方法
// ==============================
// 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",
}))
// WithEnergyService 链式注入域能服务
func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioController {
ctrl.energySvc = svc
return ctrl
}
// 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, _ := ctrl.postService.GetOverview(uid)
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",
}))
}
// 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",
}))
}
// ==============================
// API 方法
// ==============================
// 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),
})
}
// Create 创建/发布帖子
func (ctrl *StudioController) Create(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
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 编辑帖子
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, "编辑成功")
}
// 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
}
if err := ctrl.postService.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
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, "已提交审核")
// 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

@ -2,18 +2,26 @@ 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"
)
// autoCheckIner AuthMiddleware 对自动签到服务的最小依赖
type autoCheckIner interface {
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
}
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager
type AuthMiddleware struct {
cfg *config.Config
sessionManager *session.Manager
cfg *config.Config
sessionManager *session.Manager
levelSvc autoCheckIner
}
// NewAuthMiddleware 构造函数
@ -21,33 +29,47 @@ func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
}
// WithLevelService 链式注入等级服务(用于自动签到)
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
am.levelSvc = svc
return am
}
// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
// 验证失败清除 cookie → 返回 401
// 页面请求(非 /api/ 前缀)→ 验证失败清除 cookie 并重定向到登录页
// API 请求 → 验证失败返回 401 JSON
func (am *AuthMiddleware) Required() gin.HandlerFunc {
return func(c *gin.Context) {
sid, err := c.Cookie(common.SessionCookieName)
if err != nil || sid == "" {
common.ClearSessionCookie(c, am.cfg)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false, "message": "登录已过期,请重新登录",
})
am.abortUnauthorized(c)
return
}
s, err := am.sessionManager.Validate(sid)
if err != nil || s == nil {
common.ClearSessionCookie(c, am.cfg)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false, "message": "登录已过期,请重新登录",
})
am.abortUnauthorized(c)
return
}
injectSessionContext(c, s)
am.tryAutoCheckIn(c, s)
c.Next()
}
}
// abortUnauthorized 统一处理未认证API 返回 JSON页面请求跳转登录
func (am *AuthMiddleware) abortUnauthorized(c *gin.Context) {
common.ClearSessionCookie(c, am.cfg)
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false, "message": "登录已过期,请重新登录",
})
} else {
common.RedirectToLogin(c)
}
}
// Optional 可选认证:已登录则注入用户信息,未登录也放行
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
return func(c *gin.Context) {
@ -65,10 +87,31 @@ func (am *AuthMiddleware) Optional() gin.HandlerFunc {
}
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 {
@ -77,6 +120,33 @@ func injectSessionContext(c *gin.Context, s *session.Session) {
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

@ -46,16 +46,14 @@ func CSRF(cfg *config.Config) gin.HandlerFunc {
}
if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
log.Printf("[CSRF] MISMATCH | path=%s | cookie(len=%d)=%q | header(len=%d)=%q",
c.Request.URL.Path, len(cookieToken), cookieToken, len(headerToken), headerToken)
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 验证失败",
})
return
}
log.Printf("[CSRF] OK | path=%s | token(len=%d)=%q", c.Request.URL.Path, len(cookieToken), cookieToken)
c.Set(csrfMetaName, cookieToken)
c.Next()
}

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

@ -32,3 +32,8 @@ func (rl *RateLimiter) AllowAccount(email string) RateLimitResult {
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

@ -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 单个维度的限流状态

View File

@ -1,19 +1,38 @@
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/style-src: 本站 + inlinehighlight.js 主题已本地化在 static/vditor/ 下
// 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'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: blob:; "+
"connect-src 'self'")
@ -27,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()
}
}

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

@ -47,6 +47,11 @@ 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"`

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,10 +2,18 @@ package model
// 消息/通知类型常量
const (
NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_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 消息/通知模型
@ -17,15 +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: "资料审核",
NotifyPostApproved: "稿件审核",
NotifyPostRejected: "稿件审核",
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

@ -12,16 +12,23 @@ type Post struct {
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;not null" json:"user_id"`
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
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"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
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" 允许

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

@ -15,11 +15,24 @@ type User struct {
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"`
}

View File

@ -0,0 +1,23 @@
package model
// 任务类型常量
const (
TaskTypeAvatar = "avatar"
TaskTypeUsername = "username"
TaskTypeBio = "bio"
)
// TaskTypeNames 任务类型 → 中文名
var TaskTypeNames = map[string]string{
TaskTypeAvatar: "上传头像",
TaskTypeUsername: "设置用户名",
TaskTypeBio: "设置个性签名",
}
// UserTask 用户首次完成任务记录(严格防重)
type UserTask struct {
BaseModel
UserID uint `gorm:"uniqueIndex:idx_user_task,priority:1;not null" json:"user_id"`
TaskType string `gorm:"type:varchar(20);uniqueIndex:idx_user_task,priority:2;not null" json:"task_type"`
}

View File

@ -0,0 +1,184 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
)
// CreateMention 创建@提及记录
func (r *CommentRepo) CreateMention(mention *model.CommentMention) error {
return r.db.Create(mention).Error
}
// FindMentionsByComment 查询某条评论的@提及列表
func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMention, error) {
var list []model.CommentMention
err := r.db.Where("comment_id = ?", commentID).Find(&list).Error
return list, err
}
// LoadMentionsForComments 批量加载多条评论的有效@提及映射
// key=创建时的原始@用户名, value={uid, 用户当前显示名}
// 用户改名后原始名匹配body中的旧@文本显示名随users表更新
func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
if len(comments) == 0 {
return nil
}
commentIDs := make([]uint, len(comments))
for i, c := range comments {
commentIDs[i] = c.ID
}
type mentionRow struct {
CommentID uint
UID uint
OriginalUsername string
CurrentName string
}
var rows []mentionRow
err := r.db.Table("comment_mentions").
Select("comment_mentions.comment_id, comment_mentions.uid, comment_mentions.original_username, users.username AS current_name").
Joins("LEFT JOIN users ON users.id = comment_mentions.uid").
Where("comment_mentions.comment_id IN ?", commentIDs).
Find(&rows).Error
if err != nil {
return err
}
// 按 comment_id 分组key = original_username, value = MentionInfo
mentionMap := make(map[uint]map[string]model.MentionInfo)
for _, row := range rows {
if mentionMap[row.CommentID] == nil {
mentionMap[row.CommentID] = make(map[string]model.MentionInfo)
}
mentionMap[row.CommentID][row.OriginalUsername] = model.MentionInfo{
UID: row.UID,
Name: row.CurrentName,
}
}
for i := range comments {
if m, ok := mentionMap[comments[i].ID]; ok {
comments[i].Mentions = m
}
}
return nil
}
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/avatar/exp
// followerUID > 0 时按已关注优先排序其次按exp降序
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, followerUID uint) ([]struct {
UID uint
Username string
Avatar string
Exp int
}, error) {
type result struct {
UID uint
Username string
Avatar string
Exp int
}
var list []result
threshold := model.LevelThresholds[minLevel]
q := r.db.Table("users AS u").
Select("u.id AS uid, u.username, u.avatar, u.exp").
Where("u.username LIKE ? AND u.exp >= ? AND u.deleted_at IS NULL", "%"+keyword+"%", threshold).
Order("u.exp DESC")
if followerUID > 0 {
q = q.Joins("LEFT JOIN user_follows f ON f.followee_id = u.id AND f.follower_id = ?", followerUID).
Order("(f.follower_id IS NOT NULL) DESC, u.exp DESC")
}
err := q.Limit(limit).Find(&list).Error
var res []struct {
UID uint
Username string
Avatar string
Exp int
}
for _, u := range list {
res = append(res, struct {
UID uint
Username string
Avatar string
Exp int
}{UID: u.UID, Username: u.Username, Avatar: u.Avatar, Exp: u.Exp})
}
return res, err
}
// GetUsernameByID 通过 userID 获取用户名(用于通知内容)
func (r *CommentRepo) GetUsernameByID(userID uint) (string, error) {
var username string
err := r.db.Table("users").Select("username").Where("id = ?", userID).Scan(&username).Error
return username, err
}
// FindPostIDByComment 查询评论所属的文章ID
func (r *CommentRepo) FindPostIDByComment(commentID uint) (uint, error) {
var postID uint
err := r.db.Table("comments").Select("post_id").Where("id = ?", commentID).Scan(&postID).Error
return postID, err
}
// UpdateRootID 更新评论的 root_id 字段(顶级评论创建后回填)
func (r *CommentRepo) UpdateRootID(id, rootID uint) error {
return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("root_id", rootID).Error
}
// FindPostAuthorID 查询文章的作者 user_id
func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) {
var authorID uint
err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error
return authorID, err
}
// AggregateCommentsByAuthor 按日期聚合评论量(作者所有文章在时间段内的评论)
func (r *CommentRepo) AggregateCommentsByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Table("comments").
Select("TO_CHAR(comments.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("posts.user_id = ? AND comments.created_at >= ?", userID, since).
Group("TO_CHAR(comments.created_at, 'YYYY-MM-DD')").
Order("date ASC").
Scan(&results).Error
if results == nil {
results = []service.TrendPoint{}
}
return results, err
}
// ListAllComments 后台评论列表(支持关键词搜索、分页)
func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) {
var total int64
q := r.db.Table("comments").
Joins("LEFT JOIN users ON users.id = comments.user_id").
Joins("LEFT JOIN posts ON posts.id = comments.post_id")
if !showDeleted {
q = q.Where("comments.is_deleted = false")
}
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("comments.body LIKE ? OR users.username LIKE ? OR posts.title LIKE ?", like, like, like)
}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.AdminCommentRow
err := q.
Select("comments.id, comments.post_id, posts.title as post_title, comments.body, users.username as author_name, comments.is_deleted, comments.created_at, comments.updated_at").
Order("comments.created_at DESC").
Offset(offset).Limit(limit).
Scan(&rows).Error
return rows, total, err
}

View File

@ -0,0 +1,173 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// CommentRepo 评论数据访问
type CommentRepo struct {
db *gorm.DB
}
// NewCommentRepo 构造函数
func NewCommentRepo(db *gorm.DB) *CommentRepo {
return &CommentRepo{db: db}
}
// Create 创建评论
func (r *CommentRepo) Create(comment *model.Comment) error {
return r.db.Create(comment).Error
}
// FindByID 按 ID 查找评论(含作者信息)
func (r *CommentRepo) FindByID(id uint) (*model.Comment, error) {
var c model.Comment
err := r.db.Table("comments").
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
Joins("LEFT JOIN users ON users.id = comments.user_id").
Where("comments.id = ?", id).
First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
// FindRootComments 分页查询文章的顶级评论parent_id IS NULL未删除
func (r *CommentRepo) FindRootComments(postID uint, offset, limit int) ([]model.Comment, int64, error) {
var total int64
base := r.db.Table("comments").
Where("post_id = ? AND parent_id IS NULL AND is_deleted = false", postID)
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.Comment
err := base.
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
Joins("LEFT JOIN users ON users.id = comments.user_id").
Order("comments.created_at DESC").
Offset(offset).Limit(limit).
Find(&list).Error
if err != nil {
return nil, 0, err
}
// 为每条顶级评论填充回复数
if len(list) > 0 {
ids := make([]uint, len(list))
for i, c := range list {
ids[i] = c.ID
}
countMap := r.batchCountReplies(ids)
for i := range list {
list[i].RepliesCount = countMap[list[i].ID]
}
}
return list, total, nil
}
// FindReplies 查询某条顶级评论的所有回复(不分页,全量返回,未删除)
func (r *CommentRepo) FindReplies(rootID uint) ([]model.Comment, error) {
var list []model.Comment
err := r.db.Table("comments").
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
Joins("LEFT JOIN users ON users.id = comments.user_id").
Where("comments.root_id = ? AND comments.parent_id IS NOT NULL AND comments.is_deleted = false", rootID).
Order("comments.created_at ASC").
Find(&list).Error
if err != nil {
return nil, err
}
// 填充 reply_to_name
r.fillReplyToNames(list)
return list, nil
}
// fillReplyToNames 通过 JOIN users 获取被回复者当前显示名
func (r *CommentRepo) fillReplyToNames(list []model.Comment) {
if len(list) == 0 {
return
}
type nameRow struct {
UID uint
Username string
}
var rows []nameRow
r.db.Table("users").
Select("id, username").
Where("id IN (SELECT reply_to_uid FROM comments WHERE root_id IN (?) AND parent_id IS NOT NULL)", r.rootIDsFromList(list)).
Find(&rows)
m := make(map[uint]string)
for _, row := range rows {
m[row.UID] = row.Username
}
for i := range list {
if list[i].ReplyToUID != 0 {
list[i].ReplyToName = m[list[i].ReplyToUID]
}
}
}
func (r *CommentRepo) rootIDsFromList(list []model.Comment) []uint {
ids := make([]uint, len(list))
for i, c := range list {
ids[i] = c.RootID
}
return ids
}
// batchCountReplies 批量统计回复数
func (r *CommentRepo) batchCountReplies(rootIDs []uint) map[uint]int {
type countRow struct {
RootID uint
Count int
}
var rows []countRow
r.db.Table("comments").
Select("root_id, COUNT(*) as count").
Where("root_id IN ? AND parent_id IS NOT NULL AND is_deleted = false", rootIDs).
Group("root_id").
Find(&rows)
m := make(map[uint]int)
for _, row := range rows {
m[row.RootID] = row.Count
}
return m
}
// SoftDelete 软删除评论(设置 is_deleted=true
func (r *CommentRepo) SoftDelete(id uint) error {
return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("is_deleted", true).Error
}
// IncrCommentsCount 文章评论数+1
func (r *CommentRepo) IncrCommentsCount(postID uint) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comments_count", gorm.Expr("comments_count + 1")).Error
}
// DecrCommentsCount 文章评论数-1
func (r *CommentRepo) DecrCommentsCount(postID uint) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error
}
// CountRepliesByRootByStatus 统计某条顶级评论的回复数(可选是否统计已删除)
func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) {
var count int64
q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID)
if !includeDeleted {
q = q.Where("is_deleted = false")
}
err := q.Count(&count).Error
return int(count), err
}

View File

@ -0,0 +1,182 @@
package repository
import (
"time"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// EnergyRepo 域能数据访问
type EnergyRepo struct {
db *gorm.DB
fundStore service.FundStore // 用于 Transaction 时创建事务范围的 FundStore
}
// NewEnergyRepo 构造函数
func NewEnergyRepo(db *gorm.DB) *EnergyRepo {
return &EnergyRepo{db: db}
}
// SetFundStore 设置公户仓储引用(用于跨仓库事务)
func (r *EnergyRepo) SetFundStore(s service.FundStore) {
r.fundStore = s
}
// WithTx 基于给定事务连接创建新的 EnergyRepo确保事务内操作原子性
func (r *EnergyRepo) WithTx(tx *gorm.DB) service.EnergyStore {
return &EnergyRepo{db: tx}
}
// Transaction 在事务内执行业务逻辑,自动管理 EnergyStore 和 FundStore 的事务范围
func (r *EnergyRepo) Transaction(fn func(txEnergy service.EnergyStore, txFund service.FundStore) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
txEnergy := r.WithTx(tx)
var txFund service.FundStore
if r.fundStore != nil {
txFund = r.fundStore.WithTx(tx)
}
return fn(txEnergy, txFund)
})
}
// FindUserByID 查找用户(获取能量余额等)
func (r *EnergyRepo) FindUserByID(userID uint) (*model.User, error) {
var user model.User
err := r.db.First(&user, userID).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindPostByID 查找帖子
func (r *EnergyRepo) FindPostByID(postID uint) (*model.Post, error) {
var post model.Post
err := r.db.First(&post, postID).Error
if err != nil {
return nil, err
}
return &post, nil
}
// AddEnergy 原子增减用户域能余额amount 可为正负)
func (r *EnergyRepo) AddEnergy(userID uint, amount int) error {
return r.db.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("energy", gorm.Expr("energy + ?", amount)).Error
}
// IncrPostEnergy 原子增减文章累计被赋能域能
func (r *EnergyRepo) IncrPostEnergy(postID uint, amount int) error {
return r.db.Model(&model.Post{}).
Where("id = ?", postID).
UpdateColumn("total_energy_received", gorm.Expr("total_energy_received + ?", amount)).Error
}
// CreateEnergyLog 创建域能流水
func (r *EnergyRepo) CreateEnergyLog(log *model.EnergyLog) error {
return r.db.Create(log).Error
}
// SumUserPostEnergy 统计某用户对某文章累计赋能总量(用于 ≤ 20 检查)
func (r *EnergyRepo) SumUserPostEnergy(userID, postID uint) (int, error) {
var total int
err := r.db.Model(&model.PostEnergizeLog{}).
Where("user_id = ? AND post_id = ?", userID, postID).
Select("COALESCE(SUM(amount), 0)").
Scan(&total).Error
return total, err
}
// CreateEnergizeLog 创建赋能记录
func (r *EnergyRepo) CreateEnergizeLog(log *model.PostEnergizeLog) error {
return r.db.Create(log).Error
}
// GetDailyEnergizeExp 获取用户当日通过赋能获得的经验值
// 无记录时返回 nil, nil调用方当作 exp=0 处理)
func (r *EnergyRepo) GetDailyEnergizeExp(userID uint, date time.Time) (*model.DailyExpSummary, error) {
var summaries []model.DailyExpSummary
err := r.db.Where("user_id = ? AND date = ?", userID, date).Limit(1).Find(&summaries).Error
if err != nil {
return nil, err
}
if len(summaries) == 0 {
return nil, nil
}
return &summaries[0], nil
}
// UpsertDailyExp 创建或更新每日经验汇总
func (r *EnergyRepo) UpsertDailyExp(summary *model.DailyExpSummary) error {
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "date"}},
DoUpdates: clause.AssignmentColumns([]string{"exp_earned", "updated_at"}),
}).Create(summary).Error
}
// AddDailyExp 原子增加每日赋能经验值
func (r *EnergyRepo) AddDailyExp(userID uint, date time.Time, delta int) error {
return r.db.Model(&model.DailyExpSummary{}).
Where("user_id = ? AND date = ?", userID, date).
UpdateColumn("exp_earned", gorm.Expr("exp_earned + ?", delta)).Error
}
// ListEnergyLogsByUser 分页查询用户域能流水(近 N 天)
func (r *EnergyRepo) ListEnergyLogsByUser(userID uint, days int, offset, limit int) ([]model.EnergyLog, error) {
var logs []model.EnergyLog
query := r.db.Where("user_id = ?", userID).Order("created_at DESC")
if days > 0 {
since := time.Now().AddDate(0, 0, -days)
query = query.Where("created_at >= ?", since)
}
err := query.Offset(offset).Limit(limit).Find(&logs).Error
return logs, err
}
// CountEnergyLogsByUser 统计用户域能流水总数
func (r *EnergyRepo) CountEnergyLogsByUser(userID uint, days int) (int64, error) {
var count int64
query := r.db.Model(&model.EnergyLog{}).Where("user_id = ?", userID)
if days > 0 {
since := time.Now().AddDate(0, 0, -days)
query = query.Where("created_at >= ?", since)
}
err := query.Count(&count).Error
return count, err
}
// ListAllEnergyLogs 查询全部域能流水(后台,支持筛选)
func (r *EnergyRepo) ListAllEnergyLogs(energyType string, offset, limit int) ([]model.EnergyLog, int64, error) {
query := r.db.Model(&model.EnergyLog{})
if energyType != "" {
query = query.Where("type = ?", energyType)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var logs []model.EnergyLog
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
return logs, total, err
}
// AggregateEnergyByAuthor 按日期聚合赋能值(作者所有文章在时间段内的赋能记录)
func (r *EnergyRepo) AggregateEnergyByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Table("post_energize_logs").
Select("TO_CHAR(post_energize_logs.created_at, 'YYYY-MM-DD') AS date, COALESCE(SUM(post_energize_logs.amount), 0) AS value").
Joins("JOIN posts ON posts.id = post_energize_logs.post_id").
Where("posts.user_id = ? AND post_energize_logs.created_at >= ?", userID, since).
Group("TO_CHAR(post_energize_logs.created_at, 'YYYY-MM-DD')").
Order("date ASC").
Scan(&results).Error
if results == nil {
results = []service.TrendPoint{}
}
return results, err
}

View File

@ -0,0 +1,164 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
)
// FavoriteRepo 收藏夹数据访问
type FavoriteRepo struct {
db *gorm.DB
}
// NewFavoriteRepo 构造函数
func NewFavoriteRepo(db *gorm.DB) *FavoriteRepo {
return &FavoriteRepo{db: db}
}
// WithTx 基于事务连接创建 FavoriteRepo
func (r *FavoriteRepo) WithTx(tx *gorm.DB) service.FavoriteStore {
return &FavoriteRepo{db: tx}
}
// Transaction 在事务内执行业务逻辑
func (r *FavoriteRepo) Transaction(fn func(tx service.FavoriteStore) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(r.WithTx(tx))
})
}
// ======================== Folder CRUD ========================
// CreateFolder 创建收藏夹
func (r *FavoriteRepo) CreateFolder(folder *model.Folder) error {
return r.db.Create(folder).Error
}
// FindFolderByID 按 ID 查找收藏夹
func (r *FavoriteRepo) FindFolderByID(id uint) (*model.Folder, error) {
var f model.Folder
err := r.db.First(&f, id).Error
if err != nil {
return nil, err
}
return &f, nil
}
// ListFoldersByUser 列用户的收藏夹(附项目数)
func (r *FavoriteRepo) ListFoldersByUser(userID uint) ([]model.Folder, error) {
var folders []model.Folder
err := r.db.Table("folders").
Select("folders.*, COUNT(folder_items.id) AS item_count").
Joins("LEFT JOIN folder_items ON folder_items.folder_id = folders.id").
Where("folders.user_id = ?", userID).
Group("folders.id").
Order("folders.is_default DESC, folders.created_at ASC").
Scan(&folders).Error
return folders, err
}
// UpdateFolder 更新收藏夹
func (r *FavoriteRepo) UpdateFolder(folder *model.Folder) error {
return r.db.Save(folder).Error
}
// DeleteFolder 删除收藏夹
func (r *FavoriteRepo) DeleteFolder(id uint) error {
return r.db.Where("id = ?", id).Delete(&model.Folder{}).Error
}
// DeleteItemsByFolder 删除收藏夹内所有记录
func (r *FavoriteRepo) DeleteItemsByFolder(folderID uint) error {
return r.db.Where("folder_id = ?", folderID).Delete(&model.FolderItem{}).Error
}
// CountByNameAndUser 检查同用户下名称是否重复(排除自身)
func (r *FavoriteRepo) CountByNameAndUser(userID uint, name string, excludeID uint) (int64, error) {
var count int64
q := r.db.Model(&model.Folder{}).Where("user_id = ? AND name = ?", userID, name)
if excludeID > 0 {
q = q.Where("id != ?", excludeID)
}
err := q.Count(&count).Error
return count, err
}
// HasDefaultFolder 检查用户是否有默认收藏夹
func (r *FavoriteRepo) HasDefaultFolder(userID uint) (bool, error) {
var count int64
err := r.db.Model(&model.Folder{}).Where("user_id = ? AND is_default = ?", userID, true).Count(&count).Error
return count > 0, err
}
// ======================== FolderItem CRUD ========================
// CreateItem 添加收藏
func (r *FavoriteRepo) CreateItem(item *model.FolderItem) error {
return r.db.Create(item).Error
}
// DeleteItem 取消收藏
func (r *FavoriteRepo) DeleteItem(folderID, postID uint) error {
return r.db.Where("folder_id = ? AND post_id = ?", folderID, postID).Delete(&model.FolderItem{}).Error
}
// FindItemByUserAndPost 查找用户对某文章的收藏记录
func (r *FavoriteRepo) FindItemByUserAndPost(userID, postID uint) (*model.FolderItem, error) {
var item model.FolderItem
err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&item).Error
if err != nil {
return nil, err
}
return &item, nil
}
// ListItemsByFolder 列收藏夹内文章分页JOIN posts 获取标题)
func (r *FavoriteRepo) ListItemsByFolder(folderID uint, offset, limit int) ([]model.FolderItem, int64, error) {
var total int64
if err := r.db.Model(&model.FolderItem{}).Where("folder_id = ?", folderID).Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.FolderItem
err := r.db.Table("folder_items").
Select("folder_items.*, posts.title AS post_title").
Joins("LEFT JOIN posts ON posts.id = folder_items.post_id").
Where("folder_items.folder_id = ?", folderID).
Order("folder_items.created_at DESC").
Offset(offset).Limit(limit).
Scan(&items).Error
return items, total, err
}
// CountItemsByFolder 统计收藏夹内文章数
func (r *FavoriteRepo) CountItemsByFolder(folderID uint) (int64, error) {
var count int64
err := r.db.Model(&model.FolderItem{}).Where("folder_id = ?", folderID).Count(&count).Error
return count, err
}
// ======================== Post 计数器 ========================
// IncrPostFavoritesCount 原子更新文章收藏数
func (r *FavoriteRepo) IncrPostFavoritesCount(postID uint, delta int) error {
return r.db.Model(&model.Post{}).
Where("id = ?", postID).
UpdateColumn("favorites_count", gorm.Expr("favorites_count + ?", delta)).Error
}
// ======================== 趋势聚合 ========================
// AggregateFavoritesByAuthor 按日期聚合收藏量(对于某用户的文章)
func (r *FavoriteRepo) AggregateFavoritesByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Table("folder_items").
Select("TO_CHAR(folder_items.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
Joins("JOIN posts ON posts.id = folder_items.post_id").
Where("posts.user_id = ? AND folder_items.created_at >= ?", userID, since).
Group("TO_CHAR(folder_items.created_at, 'YYYY-MM-DD')").
Order("date ASC").
Scan(&results).Error
return results, err
}

View File

@ -0,0 +1,122 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
)
// FollowRepo 关注数据访问
type FollowRepo struct {
db *gorm.DB
}
// NewFollowRepo 构造函数
func NewFollowRepo(db *gorm.DB) *FollowRepo {
return &FollowRepo{db: db}
}
// WithTx 基于事务连接创建 FollowRepo
func (r *FollowRepo) WithTx(tx *gorm.DB) service.FollowStore {
return &FollowRepo{db: tx}
}
// Transaction 在事务内执行业务逻辑
func (r *FollowRepo) Transaction(fn func(tx service.FollowStore) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(r.WithTx(tx))
})
}
// Create 创建关注
func (r *FollowRepo) Create(follow *model.UserFollow) error {
return r.db.Create(follow).Error
}
// Delete 取消关注
func (r *FollowRepo) Delete(followerID, followeeID uint) error {
return r.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Delete(&model.UserFollow{}).Error
}
// Exists 检查是否已关注
func (r *FollowRepo) Exists(followerID, followeeID uint) (bool, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).
Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Count(&count).Error
return count > 0, err
}
// CountFollowers 粉丝数
func (r *FollowRepo) CountFollowers(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&count).Error
return count, err
}
// CountFollowing 关注数
func (r *FollowRepo) CountFollowing(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&count).Error
return count, err
}
// ListFollowers 粉丝列表分页JOIN users 获取用户名、头像、简介)
func (r *FollowRepo) ListFollowers(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
var total int64
if err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.UserFollow
err := r.db.Table("user_follows").
Select("user_follows.*, u.username AS follower_username, u.avatar AS follower_avatar, u.bio AS follower_bio").
Joins("INNER JOIN users u ON u.id = user_follows.follower_id").
Where("user_follows.followee_id = ?", userID).
Order("user_follows.created_at DESC").
Offset(offset).Limit(limit).
Scan(&items).Error
return items, total, err
}
// ListFollowing 关注列表分页JOIN users 获取用户名、头像、简介)
func (r *FollowRepo) ListFollowing(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
var total int64
if err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.UserFollow
err := r.db.Table("user_follows").
Select("user_follows.*, u.username AS followee_username, u.avatar AS followee_avatar, u.bio AS followee_bio").
Joins("INNER JOIN users u ON u.id = user_follows.followee_id").
Where("user_follows.follower_id = ?", userID).
Order("user_follows.created_at DESC").
Offset(offset).Limit(limit).
Scan(&items).Error
return items, total, err
}
// IncrFollowersCount 原子增减粉丝数
func (r *FollowRepo) IncrFollowersCount(userID uint, delta int) error {
return r.db.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("followers_count", gorm.Expr("followers_count + ?", delta)).Error
}
// IncrFollowingCount 原子增减关注数
func (r *FollowRepo) IncrFollowingCount(userID uint, delta int) error {
return r.db.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("following_count", gorm.Expr("following_count + ?", delta)).Error
}
// GetFollowListPublic 查询用户关注列表公开性
func (r *FollowRepo) GetFollowListPublic(userID uint) (bool, error) {
var listPublic bool
err := r.db.Model(&model.User{}).
Select("follow_list_public").
Where("id = ?", userID).
Scan(&listPublic).Error
return listPublic, err
}

View File

@ -0,0 +1,71 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// FundRepo 公户数据访问
type FundRepo struct {
db *gorm.DB
}
// NewFundRepo 构造函数
func NewFundRepo(db *gorm.DB) *FundRepo {
return &FundRepo{db: db}
}
// WithTx 基于给定事务连接创建新的 FundRepo
func (r *FundRepo) WithTx(tx *gorm.DB) service.FundStore {
return &FundRepo{db: tx}
}
// GetBalance 查询公户余额
func (r *FundRepo) GetBalance() (int, error) {
var fund model.CommunityFund
err := r.db.First(&fund, 1).Error
if err != nil {
return 0, err
}
return fund.Balance, nil
}
// LockAndGetBalance 加行锁查询公户余额(事务内使用,防止 TOCTOU 竞态)
func (r *FundRepo) LockAndGetBalance() (int, error) {
var fund model.CommunityFund
err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&fund, 1).Error
if err != nil {
return 0, err
}
return fund.Balance, nil
}
// IncrBalance 原子增减公户余额
func (r *FundRepo) IncrBalance(amount int) error {
return r.db.Model(&model.CommunityFund{}).
Where("id = 1").
UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error
}
// CreateLog 创建公户流水
func (r *FundRepo) CreateLog(log *model.FundLog) error {
return r.db.Create(log).Error
}
// ListLogs 分页查询公户流水
func (r *FundRepo) ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error) {
query := r.db.Model(&model.FundLog{})
if logType != "" {
query = query.Where("type = ?", logType)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var logs []model.FundLog
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
return logs, total, err
}

View File

@ -0,0 +1,75 @@
package repository
import (
"time"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// LevelRepo 积分/等级/签到/任务数据访问
type LevelRepo struct {
db *gorm.DB
}
// NewLevelRepo 构造函数
func NewLevelRepo(db *gorm.DB) *LevelRepo {
return &LevelRepo{db: db}
}
// FindByID 按 UID 查找用户(实现 userLevelStore 接口)
func (r *LevelRepo) FindByID(id uint) (*model.User, error) {
var user model.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
// AddExp 原子增加用户经验值
func (r *LevelRepo) AddExp(userID uint, delta int) error {
return r.db.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error
}
// FindCheckIn 查询指定自然日的签到记录
func (r *LevelRepo) FindCheckIn(userID uint, date time.Time) (*model.UserCheckIn, error) {
var ci model.UserCheckIn
err := r.db.Where("user_id = ? AND date = ?", userID, date).First(&ci).Error
if err != nil {
return nil, err
}
return &ci, nil
}
// CreateCheckIn 创建签到记录
func (r *LevelRepo) CreateCheckIn(ci *model.UserCheckIn) error {
return r.db.Create(ci).Error
}
// FindTask 查询用户是否已完成指定任务
func (r *LevelRepo) FindTask(userID uint, taskType string) (*model.UserTask, error) {
var t model.UserTask
err := r.db.Where("user_id = ? AND task_type = ?", userID, taskType).First(&t).Error
if err != nil {
return nil, err
}
return &t, nil
}
// CreateTask 创建任务完成记录
func (r *LevelRepo) CreateTask(task *model.UserTask) error {
return r.db.Create(task).Error
}
// HasTask 检查用户是否已完成过指定任务(无副作用查询)
func (r *LevelRepo) HasTask(userID uint, taskType string) (bool, error) {
var count int64
err := r.db.Model(&model.UserTask{}).
Where("user_id = ? AND task_type = ?", userID, taskType).
Count(&count).Error
return count > 0, err
}

View File

@ -60,3 +60,88 @@ func (r *NotificationRepo) MarkAllRead(userID uint) error {
Where("user_id = ? AND is_read = false", userID).
Update("is_read", true).Error
}
// ListByUserAndType 按类型分页查询用户消息
func (r *NotificationRepo) ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error) {
var list []model.Notification
err := r.db.Where("user_id = ? AND notify_type = ?", userID, notifyType).
Order("created_at DESC").
Offset(offset).Limit(limit).
Find(&list).Error
return list, err
}
// CountByUserAndType 按类型统计用户消息数
func (r *NotificationRepo) CountByUserAndType(userID uint, notifyType string) (int64, error) {
var count int64
err := r.db.Model(&model.Notification{}).Where("user_id = ? AND notify_type = ?", userID, notifyType).Count(&count).Error
return count, err
}
// CountUnreadByType 按类型统计用户未读消息数
func (r *NotificationRepo) CountUnreadByType(userID uint, notifyType string) (int64, error) {
var count int64
err := r.db.Model(&model.Notification{}).
Where("user_id = ? AND is_read = false AND notify_type = ?", userID, notifyType).
Count(&count).Error
return count, err
}
// GetUnnotifiedDailyLikeSummaries 获取未通知的每日点赞汇总
func (r *NotificationRepo) GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error) {
var summaries []model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND notified = ?", userID, false).Find(&summaries).Error
return summaries, err
}
// MarkDailyLikeSummaryNotified 标记汇总已通知
func (r *NotificationRepo) MarkDailyLikeSummaryNotified(id uint) error {
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
}
// GetUsernameByID 根据用户ID获取用户名
func (r *NotificationRepo) GetUsernameByID(userID uint) (string, error) {
var user model.User
err := r.db.Select("username").First(&user, userID).Error
return user.Username, err
}
// GetNotifyPrefs 获取用户通知偏好NotifyPrefs 字段原始 JSON
func (r *NotificationRepo) GetNotifyPrefs(userID uint) (string, error) {
var prefs string
err := r.db.Table("users").Select("notify_prefs").Where("id = ?", userID).Scan(&prefs).Error
return prefs, err
}
// ListByUserExcludeTypes 分页查询用户消息(排除指定通知类型)
func (r *NotificationRepo) ListByUserExcludeTypes(userID uint, excludeTypes []string, offset, limit int) ([]model.Notification, error) {
var list []model.Notification
q := r.db.Where("user_id = ?", userID)
if len(excludeTypes) > 0 {
q = q.Where("notify_type NOT IN ?", excludeTypes)
}
err := q.Order("created_at DESC").Offset(offset).Limit(limit).Find(&list).Error
return list, err
}
// CountByUserExcludeTypes 统计用户消息总数(排除指定通知类型)
func (r *NotificationRepo) CountByUserExcludeTypes(userID uint, excludeTypes []string) (int64, error) {
var count int64
q := r.db.Model(&model.Notification{}).Where("user_id = ?", userID)
if len(excludeTypes) > 0 {
q = q.Where("notify_type NOT IN ?", excludeTypes)
}
err := q.Count(&count).Error
return count, err
}
// CountUnreadExcludeTypes 统计用户未读消息数(排除指定通知类型)
func (r *NotificationRepo) CountUnreadExcludeTypes(userID uint, excludeTypes []string) (int64, error) {
var count int64
q := r.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = false", userID)
if len(excludeTypes) > 0 {
q = q.Where("notify_type NOT IN ?", excludeTypes)
}
err := q.Count(&count).Error
return count, err
}

View File

@ -36,7 +36,7 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
var post model.Post
err := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Joins("LEFT JOIN users ON users.id = posts.user_id").
Where("posts.id = ?", id).
First(&post).Error
if err != nil {
@ -49,7 +49,7 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Joins("LEFT JOIN users ON users.id = posts.user_id").
Where("posts.deleted_at IS NULL")
if keyword != "" {
@ -137,6 +137,24 @@ func (r *PostRepo) SoftDelete(id uint) error {
return r.db.Delete(&model.Post{}, id).Error
}
// CountPostsByStatus 统计帖子数量status 为空则统计全部未删除帖子)
func (r *PostRepo) CountPostsByStatus(status string) (int64, error) {
query := r.buildQuery("", status)
var total int64
err := query.Count(&total).Error
return total, err
}
// CountPending 统计待审核帖子数pending 状态 + approved 但有待审修订)
func (r *PostRepo) CountPending() (int64, error) {
var total int64
err := r.db.Table("posts").
Where("deleted_at IS NULL").
Where("status = ? OR (status = ? AND pending_body != '')", model.PostStatusPending, model.PostStatusApproved).
Count(&total).Error
return total, err
}
// Restore 恢复软删除
func (r *PostRepo) Restore(id uint) error {
result := r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil)

View File

@ -0,0 +1,134 @@
package repository
import (
"time"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CountUserPosts 统计某用户的已发布文章数
func (r *PostRepo) CountUserPosts(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.Post{}).
Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved).
Count(&count).Error
return count, err
}
// GetUserStats 获取用户的文章统计数据(总获赞/总收藏/总阅读)
func (r *PostRepo) GetUserStats(userID uint) (likes, favorites, reads int64, err error) {
type stats struct {
TotalLikes int64
TotalFavorites int64
TotalReads int64
}
var s stats
err = r.db.Model(&model.Post{}).
Select("COALESCE(SUM(likes_count), 0) AS total_likes, COALESCE(SUM(favorites_count), 0) AS total_favorites, COALESCE(SUM(views_count), 0) AS total_reads").
Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved).
Scan(&s).Error
return s.TotalLikes, s.TotalFavorites, s.TotalReads, err
}
// overviewRow GetOverviewByUserID 单次 GROUP BY 查询结果行
type overviewRow struct {
TotalPosts int64
DraftCount int64
PendingCount int64
ApprovedCount int64
RejectedCount int64
TotalViews int64
}
// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数 + 总阅读量
func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews int64, err error) {
var row overviewRow
err = r.db.Model(&model.Post{}).
Select(`
COUNT(*) AS total_posts,
COALESCE(SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END), 0) AS draft_count,
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_count,
COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) AS approved_count,
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count,
COALESCE(SUM(views_count), 0) AS total_views
`).
Where("user_id = ? AND deleted_at IS NULL", userID).
Scan(&row).Error
return row.TotalPosts, row.DraftCount, row.PendingCount, row.ApprovedCount, row.RejectedCount, row.TotalViews, err
}
// readCooldown 两次阅读记录最小间隔
const readCooldown = 2 * time.Second
// RecordRead 记录已登录用户阅读文章(防刷量:取去重+冷却)
// 返回 true 表示首次阅读新记录false 表示重复或冷却中
func (r *PostRepo) RecordRead(userID, postID uint) (bool, error) {
// 冷却检查:同用户两次阅读之间至少间隔 2 秒
var lastRead model.PostReadLog
if err := r.db.Where("user_id = ?", userID).
Order("read_at DESC").First(&lastRead).Error; err == nil {
if time.Since(lastRead.ReadAt) < readCooldown {
return false, nil
}
}
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).
Create(&model.PostReadLog{UserID: userID, PostID: postID})
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
// RecordGuestRead 记录访客阅读文章(基于 Cookie visitor_id 去重+冷却)
// 返回 true 表示首次阅读新记录false 表示重复或冷却中
func (r *PostRepo) RecordGuestRead(visitorID string, postID uint) (bool, error) {
// 冷却检查:同访客两次阅读之间至少间隔 2 秒
var lastRead model.PostGuestReadLog
if err := r.db.Where("visitor_id = ?", visitorID).
Order("read_at DESC").First(&lastRead).Error; err == nil {
if time.Since(lastRead.ReadAt) < readCooldown {
return false, nil
}
}
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).
Create(&model.PostGuestReadLog{VisitorID: visitorID, PostID: postID})
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
// IncrementViewsCount 阅读数 +1
func (r *PostRepo) IncrementViewsCount(postID uint) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID).
Update("views_count", gorm.Expr("views_count + 1")).Error
}
// AggregateViewsByAuthor 按日期聚合阅读量(作者所有文章在时间段内的阅读记录,含已登录+访客)
func (r *PostRepo) AggregateViewsByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Raw(`
SELECT TO_CHAR(t.read_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value
FROM (
SELECT prl.read_at FROM post_read_logs prl
JOIN posts p ON p.id = prl.post_id
WHERE p.user_id = ? AND prl.read_at >= ?
UNION ALL
SELECT pgrl.read_at FROM post_guest_read_logs pgrl
JOIN posts p ON p.id = pgrl.post_id
WHERE p.user_id = ? AND pgrl.read_at >= ?
) t
GROUP BY TO_CHAR(t.read_at, 'YYYY-MM-DD')
ORDER BY date ASC
`, userID, since, userID, since).Scan(&results).Error
if results == nil {
results = []service.TrendPoint{}
}
return results, err
}

View File

@ -0,0 +1,153 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
)
// ReactionRepo 赞/踩数据访问
type ReactionRepo struct {
db *gorm.DB
}
// NewReactionRepo 构造函数
func NewReactionRepo(db *gorm.DB) *ReactionRepo {
return &ReactionRepo{db: db}
}
// WithTx 基于给定事务连接创建新的 ReactionRepo
func (r *ReactionRepo) WithTx(tx *gorm.DB) service.ReactionStore {
return &ReactionRepo{db: tx}
}
// Transaction 在事务内执行业务逻辑
func (r *ReactionRepo) Transaction(fn func(tx service.ReactionStore) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(r.WithTx(tx))
})
}
// GetPostLikesCount 查询文章点赞数
func (r *ReactionRepo) GetPostLikesCount(postID uint) (int, error) {
var post model.Post
if err := r.db.Select("likes_count").First(&post, postID).Error; err != nil {
return 0, err
}
return post.LikesCount, nil
}
// FindLike 查找点赞记录
func (r *ReactionRepo) FindLike(userID, postID uint) (*model.PostLike, error) {
var like model.PostLike
err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&like).Error
if err != nil {
return nil, err
}
return &like, nil
}
// CreateLike 创建点赞记录
func (r *ReactionRepo) CreateLike(like *model.PostLike) error {
return r.db.Create(like).Error
}
// DeleteLike 删除点赞记录
func (r *ReactionRepo) DeleteLike(userID, postID uint) error {
return r.db.Where("user_id = ? AND post_id = ?", userID, postID).Delete(&model.PostLike{}).Error
}
// FindDislike 查找踩记录
func (r *ReactionRepo) FindDislike(userID, postID uint) (*model.PostDislike, error) {
var dislike model.PostDislike
err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&dislike).Error
if err != nil {
return nil, err
}
return &dislike, nil
}
// CreateDislike 创建踩记录
func (r *ReactionRepo) CreateDislike(dislike *model.PostDislike) error {
return r.db.Create(dislike).Error
}
// DeleteDislike 删除踩记录
func (r *ReactionRepo) DeleteDislike(userID, postID uint) error {
return r.db.Where("user_id = ? AND post_id = ?", userID, postID).Delete(&model.PostDislike{}).Error
}
// IncrPostLikes 原子增加文章点赞数
func (r *ReactionRepo) IncrPostLikes(postID uint, delta int) error {
return r.db.Model(&model.Post{}).
Where("id = ?", postID).
UpdateColumn("likes_count", gorm.Expr("likes_count + ?", delta)).Error
}
// IncrPostDislikes 原子增加文章踩数
func (r *ReactionRepo) IncrPostDislikes(postID uint, delta int) error {
return r.db.Model(&model.Post{}).
Where("id = ?", postID).
UpdateColumn("dislikes_count", gorm.Expr("dislikes_count + ?", delta)).Error
}
// GetPostAuthorID 查询文章作者ID
func (r *ReactionRepo) GetPostAuthorID(postID uint) (uint, error) {
var post model.Post
err := r.db.Select("user_id").First(&post, postID).Error
return post.UserID, err
}
// UpsertDailyLikeSummary 追加或创建每日点赞汇总
func (r *ReactionRepo) UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error {
var existing model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND date = ?", authorUID, date).First(&existing).Error
if err == gorm.ErrRecordNotFound {
return r.db.Create(&model.DailyLikeSummary{
AuthorUID: authorUID,
Date: date,
LikerUIDs: likerUID,
Notified: false,
}).Error
}
if err != nil {
return err
}
// 追加 likerUID
newUids := existing.LikerUIDs
if newUids == "" {
newUids = likerUID
} else {
newUids = newUids + "," + likerUID
}
return r.db.Model(&existing).Update("liker_uids", newUids).Error
}
// GetUnnotifiedSummaries 查询未通知的每日点赞汇总
func (r *ReactionRepo) GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSummary, error) {
var summaries []model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND notified = ?", userID, false).Find(&summaries).Error
return summaries, err
}
// MarkSummaryNotified 标记汇总已通知
func (r *ReactionRepo) MarkSummaryNotified(id uint) error {
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
}
// AggregateLikesByAuthor 按日期聚合点赞量(作者所有文章在时间段内的点赞)
func (r *ReactionRepo) AggregateLikesByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
var results []service.TrendPoint
err := r.db.Table("post_likes").
Select("TO_CHAR(post_likes.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
Joins("JOIN posts ON posts.id = post_likes.post_id").
Where("posts.user_id = ? AND post_likes.created_at >= ?", userID, since).
Group("TO_CHAR(post_likes.created_at, 'YYYY-MM-DD')").
Order("date ASC").
Scan(&results).Error
if results == nil {
results = []service.TrendPoint{}
}
return results, err
}

View File

@ -69,16 +69,15 @@ func (r *UserRepo) ExistsByUsername(username string) (bool, error) {
func (r *UserRepo) ExistsByEmailExclude(email string, excludeUID uint) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).
Where("email = ? AND uid != ?", email, excludeUID).
Where("email = ? AND id != ?", email, excludeUID).
Count(&count).Error
return count > 0, err
}
// FindByIDForAuth 认证专用查询:返回 uid/email/username/role/status
// 比 FindByID 轻量(不查 avatar、bio 等无用字段),避免全量 SELECT *
// FindByIDForAuth 认证专用查询:返回 uid/email/username/avatar/role/status
func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
var user model.User
err := r.db.Select("uid", "email", "username", "role", "status").
err := r.db.Select("id", "email", "username", "avatar", "role", "status", "exp").
First(&user, userID).Error
if err != nil {
return nil, err
@ -89,7 +88,7 @@ func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
// FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作
func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
var user model.User
err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "deleted_at").
err := r.db.Unscoped().Select("id", "email", "username", "role", "status", "deleted_at").
First(&user, userID).Error
if err != nil {
return nil, err
@ -99,12 +98,12 @@ func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
// UpdateStatus 更新用户状态含软删除用户locked 状态变更需要)
func (r *UserRepo) UpdateStatus(uid uint, status string) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("status", status).Error
return r.db.Unscoped().Model(&model.User{}).Where("id = ?", uid).Update("status", status).Error
}
// UpdateRole 更新用户角色(仅 owner 调用)
func (r *UserRepo) UpdateRole(uid uint, role string) error {
return r.db.Model(&model.User{}).Where("uid = ?", uid).Update("role", role).Error
return r.db.Model(&model.User{}).Where("id = ?", uid).Update("role", role).Error
}
// Update 全量更新用户信息
@ -114,12 +113,12 @@ func (r *UserRepo) Update(user *model.User) error {
// SoftDelete GORM 软删除(设 DeletedAt用于 locked 状态释放邮箱
func (r *UserRepo) SoftDelete(uid uint) error {
return r.db.Where("uid = ?", uid).Delete(&model.User{}).Error
return r.db.Where("id = ?", uid).Delete(&model.User{}).Error
}
// Restore 恢复软删除(清 DeletedAt
func (r *UserRepo) Restore(uid uint) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error
return r.db.Unscoped().Model(&model.User{}).Where("id = ?", uid).Update("deleted_at", nil).Error
}
// buildSearchQuery 构建搜索/筛选的公共查询条件
@ -142,7 +141,7 @@ func (r *UserRepo) buildSearchQuery(keyword, role, status string) *gorm.DB {
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
var users []model.User
err := r.buildSearchQuery(keyword, role, status).
Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
Order("id ASC").Offset(offset).Limit(limit).Find(&users).Error
return users, err
}

View File

@ -28,12 +28,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
middleware.RequirePageRole(model.RoleOwner))
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
}
// --- 管理后台 API ---
adminAPI := r.Group("/api/admin")
adminAPI.Use(d.authMdw.Required())
adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminAPI.Use(d.authMdw.BannedWriteGuard())
adminAPI.Use(middleware.CSRF(cfg))
{
adminAPI.GET("/users", d.adminCtrl.ListUsers)
@ -48,6 +53,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
siteSettingsAPI := r.Group("/api/admin/site-settings")
siteSettingsAPI.Use(d.authMdw.Required())
siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner))
siteSettingsAPI.Use(d.authMdw.BannedWriteGuard())
siteSettingsAPI.Use(middleware.CSRF(cfg))
{
siteSettingsAPI.GET("", d.siteSettingCtrl.GetSettings)
@ -60,6 +66,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPostAPI := r.Group("/api/admin/posts")
adminPostAPI.Use(d.authMdw.Required())
adminPostAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminPostAPI.Use(d.authMdw.BannedWriteGuard())
adminPostAPI.Use(middleware.CSRF(cfg))
{
adminPostAPI.POST("/:id/approve", d.adminPostCtrl.Approve)
@ -68,4 +75,28 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock)
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
}
// --- 域能管理 APIadmin+system_operation 仅 owner ---
adminEnergyAPI := r.Group("/api/admin/energy")
adminEnergyAPI.Use(d.authMdw.Required())
adminEnergyAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminEnergyAPI.Use(d.authMdw.BannedWriteGuard())
adminEnergyAPI.Use(middleware.CSRF(cfg))
{
adminEnergyAPI.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy)
adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs)
adminEnergyAPI.GET("/fund-balance", d.adminEnergyCtrl.GetFundBalance)
adminEnergyAPI.GET("/fund-logs", d.adminEnergyCtrl.ListFundLogs)
}
// --- 评论管理 APIadmin+ ---
adminCommentAPI := r.Group("/api/admin/comments")
adminCommentAPI.Use(d.authMdw.Required())
adminCommentAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminCommentAPI.Use(d.authMdw.BannedWriteGuard())
adminCommentAPI.Use(middleware.CSRF(cfg))
{
adminCommentAPI.GET("", d.adminCommentCtrl.ListComments)
adminCommentAPI.DELETE("/:id", d.adminCommentCtrl.Delete)
}
}

View File

@ -12,23 +12,28 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 设置页 API需登录 ---
settingsAPI := r.Group("/api/settings")
settingsAPI.Use(d.authMdw.Required())
settingsAPI.Use(d.authMdw.BannedWriteGuard())
settingsAPI.Use(middleware.CSRF(cfg))
{
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
settingsAPI.PUT("/password", d.settingsCtrl.ChangePassword)
settingsAPI.POST("/delete-account", d.settingsCtrl.DeleteAccount)
settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword)
settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount)
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
// 登录管理
settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
// 通知偏好
settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
}
// --- 消息中心 API需登录 ---
messagesAPI := r.Group("/api/messages")
messagesAPI.Use(d.authMdw.Required())
messagesAPI.Use(d.authMdw.BannedWriteGuard())
messagesAPI.Use(middleware.CSRF(cfg))
{
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
@ -36,6 +41,16 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
}
// --- 积分/等级 API需登录 ---
levelAPI := r.Group("/api/level")
levelAPI.Use(d.authMdw.Required())
levelAPI.Use(d.authMdw.BannedWriteGuard())
levelAPI.Use(middleware.CSRF(cfg))
{
levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn)
levelAPI.GET("/info", d.levelCtrl.GetLevel)
}
// --- 认证 API ---
api := r.Group("/api")
api.Use(middleware.CSRF(cfg))
@ -55,11 +70,9 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 帖子 API需登录 ---
postsAPI := r.Group("/api/posts")
postsAPI.Use(d.authMdw.Required())
postsAPI.Use(d.authMdw.BannedWriteGuard())
postsAPI.Use(middleware.CSRF(cfg))
{
postsAPI.POST("", d.postCtrl.Create)
postsAPI.PUT("/:id", d.postCtrl.Update)
postsAPI.DELETE("/:id", d.postCtrl.Delete)
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
}
@ -67,6 +80,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// --- 创作中心 API需登录 ---
studioAPI := r.Group("/api/studio")
studioAPI.Use(d.authMdw.Required())
studioAPI.Use(d.authMdw.BannedWriteGuard())
studioAPI.Use(middleware.CSRF(cfg))
{
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
@ -76,4 +90,109 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
}
// --- 域能 API需登录 ---
energyAPI := r.Group("/api/energy")
energyAPI.Use(d.authMdw.Required())
energyAPI.Use(d.authMdw.BannedWriteGuard())
energyAPI.Use(middleware.CSRF(cfg))
{
energyAPI.POST("/energize", d.energyCtrl.Energize)
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
}
// --- 评论 API部分需登录 ---
// 公开读取
commentsPublic := r.Group("/api/posts/:id/comments")
{
commentsPublic.GET("", d.commentCtrl.ListRootComments)
commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
}
// 需要登录的操作
commentsAPI := r.Group("/api")
commentsAPI.Use(d.authMdw.Required())
commentsAPI.Use(d.authMdw.BannedWriteGuard())
commentsAPI.Use(middleware.CSRF(cfg))
{
commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
}
// --- 赞/踩 API ---
// 公开:查询反应状态(未登录返回 none + 点赞数)
r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction)
// 需登录:切换赞/踩
reactionAPI := r.Group("/api/posts/:id")
reactionAPI.Use(d.authMdw.Required())
reactionAPI.Use(d.authMdw.BannedWriteGuard())
reactionAPI.Use(middleware.CSRF(cfg))
{
reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
}
// --- 关注 API ---
// 公开Optional 认证以获取当前用户关注状态,未登录返回 false
followPublic := r.Group("/api/users/:uid")
followPublic.Use(d.authMdw.Optional())
{
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
followPublic.GET("/followers", d.followCtrl.Followers)
followPublic.GET("/following", d.followCtrl.Following)
}
// 需登录:切换关注
followAPI := r.Group("/api/users/:uid")
followAPI.Use(d.authMdw.Required())
followAPI.Use(d.authMdw.BannedWriteGuard())
followAPI.Use(middleware.CSRF(cfg))
{
followAPI.POST("/follow", d.followCtrl.Toggle)
}
// --- 收藏夹 API ---
// 公开:文章收藏状态(未登录返回 false
r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
// 公开:查看公开收藏夹内容
r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
// 需登录:收藏操作
folderAPI := r.Group("/api/folders")
folderAPI.Use(d.authMdw.Required())
folderAPI.Use(d.authMdw.BannedWriteGuard())
folderAPI.Use(middleware.CSRF(cfg))
{
folderAPI.GET("", d.favoriteCtrl.ListFolders)
folderAPI.POST("", d.favoriteCtrl.CreateFolder)
folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
}
// 需登录切换收藏Toggle
favToggleAPI := r.Group("/api/posts/:id")
favToggleAPI.Use(d.authMdw.Required())
favToggleAPI.Use(d.authMdw.BannedWriteGuard())
favToggleAPI.Use(middleware.CSRF(cfg))
{
favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
}
// --- 空间/隐私设置 API需登录 ---
spaceAPI := r.Group("/api/space")
spaceAPI.Use(d.authMdw.Required())
spaceAPI.Use(d.authMdw.BannedWriteGuard())
spaceAPI.Use(middleware.CSRF(cfg))
{
spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy)
}
// --- Studio 趋势 API需登录 ---
trendsAPI := r.Group("/api/studio")
trendsAPI.Use(d.authMdw.Required())
{
trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
}
}

View File

@ -4,6 +4,7 @@ import (
"log"
"time"
"metazone.cc/metalab/internal/cache"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/controller"
adminCtrl "metazone.cc/metalab/internal/controller/admin"
@ -19,22 +20,37 @@ import (
type dependencies struct {
authCtrl *controller.AuthController
authMdw *middleware.AuthMiddleware
rateLimiter *middleware.RateLimiter
maintenanceMdw *middleware.MaintenanceMiddleware
settingsCtrl *controller.SettingsController
messageCtrl *controller.MessageController
postCtrl *controller.PostController
postService *service.PostService
spaceCtrl *controller.SpaceController
studioCtrl *controller.StudioController
adminPostCtrl *adminCtrl.AdminPostController
adminCtrl *adminCtrl.AdminController
auditCtrl *adminCtrl.AuditController
siteSettingCtrl *adminCtrl.SiteSettingController
levelCtrl *controller.LevelController
energyCtrl *controller.EnergyController
adminEnergyCtrl *adminCtrl.AdminEnergyController
energyService *service.EnergyService
commentCtrl *controller.CommentController
commentService *service.CommentService
adminCommentCtrl *adminCtrl.AdminCommentController
reactionCtrl *controller.ReactionController
followCtrl *controller.FollowController
favoriteCtrl *controller.FavoriteController
favoriteService *service.FavoriteService
trendsService *service.TrendsService
homeCache *cache.HomePageCache
}
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
*repository.UserRepo, *session.Manager, *service.AuthService,
*middleware.RateLimiter, *controller.AuthController,
*service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController,
*service.AvatarService, *middleware.AuthMiddleware,
) {
userRepo := repository.NewUserRepo(db)
@ -51,7 +67,7 @@ func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSet
if cfg.Redis.Enabled && redisClient != nil {
// Redis 模式 + 自动降级保护
redisStore := session.NewRedisStore(redisClient, idleTimeout, rememberTimeout)
sessionStore = session.NewFallbackStore(redisStore, memStore, cleanupInterval)
sessionStore = session.NewFallbackStore(redisStore, memStore)
log.Println("[Router] 会话存储: Redis带内存降级保护")
} else {
// 纯内存模式
@ -62,9 +78,19 @@ func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSet
sessionMgr := session.NewManager(sessionStore, userRepo, cfg)
authService := service.NewAuthService(userRepo, sessionMgr, cfg, siteSettings)
rateLimiter := middleware.NewRateLimiter()
authCtrl := controller.NewAuthController(authService, sessionMgr, rateLimiter, cfg)
authCtrl := controller.NewAuthController(authService, sessionMgr, rateLimiter, cfg, siteSettings)
avatarSvc := service.NewAvatarService(userRepo)
authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, sessionMgr))
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw
}
// buildDepsLevel 构建等级/签到相关依赖
func buildDepsLevel(db *gorm.DB, notifSvc *service.NotificationService, authMdw *middleware.AuthMiddleware) (
*repository.LevelRepo, *service.LevelService, *controller.LevelController,
) {
levelRepo := repository.NewLevelRepo(db)
levelSvc := service.NewLevelService(levelRepo, levelRepo, levelRepo, notifSvc)
levelCtrl := controller.NewLevelController(levelSvc)
authMdw.WithLevelService(levelSvc)
return levelRepo, levelSvc, levelCtrl
}

View File

@ -1,6 +1,9 @@
package router
import (
"time"
"metazone.cc/metalab/internal/cache"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/controller"
adminCtrl "metazone.cc/metalab/internal/controller/admin"
@ -13,17 +16,36 @@ import (
)
func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) *dependencies {
userRepo, sessionMgr, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings, redisClient)
userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw := buildDepsCore(db, cfg, siteSettings, redisClient)
auditRepo := repository.NewAuditRepo(db)
notificationRepo := repository.NewNotificationRepo(db)
notificationService := service.NewNotificationService(notificationRepo)
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService)
// 域能系统
energyRepo := repository.NewEnergyRepo(db)
fundRepo := repository.NewFundRepo(db)
energyRepo.SetFundStore(fundRepo) // 为跨仓库 Transaction 注入 FundStore
energyService := service.NewEnergyService(energyRepo, nil, notificationService)
energyService.SetFundRepo(fundRepo) // 只读查询
// 等级/签到/任务系统(链式注入域能服务)
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw)
levelSvc.WithEnergyService(energyService)
// EnergyService 也需要 LevelService用于赋能获得经验通过 setter 注入
energyService.SetExpAdder(levelSvc)
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
auditService.WithEnergyRefundService(energyService)
auditService.WithAvatarFileCleaner(avatarSvc)
auditController := adminCtrl.NewAuditController(auditService, userRepo)
messageController := controller.NewMessageController(notificationService)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc)
settingsCtrl.WithEnergyService(energyService)
settingsCtrl.SetNotifyPrefReader(authService)
siteSettingController := adminCtrl.NewSiteSettingController(
service.NewSiteSettingService(siteSettings),
&service.AuditDefaults{
@ -33,25 +55,78 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
)
postRepo := repository.NewPostRepo(db)
postService := service.NewPostService(postRepo, siteSettings, notificationService)
postService := service.NewPostService(postRepo, siteSettings, notificationService, userRepo)
shortcodeSvc := service.NewShortcodeService()
postCtrl := controller.NewPostController(postService, shortcodeSvc)
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, postRepo, sessionMgr))
studioCtrl := controller.NewStudioController(postService)
studioCtrl.WithEnergyService(energyService)
// 域能控制器
energyCtrl := controller.NewEnergyController(energyService)
adminEnergyCtrl := adminCtrl.NewAdminEnergyController(energyService)
// 评论系统
commentRepo := repository.NewCommentRepo(db)
commentService := service.NewCommentService(commentRepo, notificationService)
commentCtrl := controller.NewCommentController(commentService)
// 后台评论管理
adminCommentCtrl := adminCtrl.NewAdminCommentController(commentService)
// 赞/踩系统
reactionRepo := repository.NewReactionRepo(db)
reactionService := service.NewReactionService(reactionRepo)
reactionCtrl := controller.NewReactionController(reactionService)
// 关注系统
followRepo := repository.NewFollowRepo(db)
followService := service.NewFollowService(followRepo)
followService.SetNotifier(notificationService) // 关注通知
followCtrl := controller.NewFollowController(followService)
// 收藏夹系统(需在 SpaceController 之前初始化)
favoriteRepo := repository.NewFavoriteRepo(db)
favoriteService := service.NewFavoriteService(favoriteRepo)
favoriteCtrl := controller.NewFavoriteController(favoriteService)
// 用户空间
spaceService := service.NewSpaceService(userRepo, postRepo)
spaceCtrl := controller.NewSpaceController(spaceService)
spaceCtrl.WithFollowService(followService)
spaceCtrl.WithFavoriteService(favoriteService)
// Studio 趋势图
trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo, postRepo)
studioCtrl.WithTrendsService(trendsService)
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
// 首页内存缓存30s TTL文章变更时失效
homeCache := cache.NewHomePageCache(30 * time.Second)
postService.WithHomePageInvalidator(func() { homeCache.Invalidate() })
return &dependencies{
authCtrl: authCtrl, authMdw: authMdw, maintenanceMdw: maintenanceMdw,
authCtrl: authCtrl, authMdw: authMdw, rateLimiter: rateLimiter, maintenanceMdw: maintenanceMdw,
settingsCtrl: settingsCtrl,
messageCtrl: messageController, postCtrl: postCtrl, spaceCtrl: spaceCtrl,
studioCtrl: studioCtrl,
messageCtrl: messageController, postCtrl: postCtrl, postService: postService, spaceCtrl: spaceCtrl,
studioCtrl: studioCtrl,
adminPostCtrl: adminPostCtrl,
adminCtrl: adminController, auditCtrl: auditController,
adminCtrl: adminController, auditCtrl: auditController,
siteSettingCtrl: siteSettingController,
levelCtrl: levelCtrl,
energyCtrl: energyCtrl,
adminEnergyCtrl: adminEnergyCtrl,
energyService: energyService,
commentCtrl: commentCtrl,
commentService: commentService,
adminCommentCtrl: adminCommentCtrl,
reactionCtrl: reactionCtrl,
followCtrl: followCtrl,
favoriteCtrl: favoriteCtrl,
favoriteService: favoriteService,
trendsService: trendsService,
homeCache: homeCache,
}
}

View File

@ -22,9 +22,22 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
})
{
pages.GET("/", func(c *gin.Context) {
posts, total, ok := d.homeCache.Get()
if !ok {
var err error
posts, total, err = d.postService.List("", 1, 6)
if err != nil {
posts = nil
total = 0
} else {
d.homeCache.Set(posts, total)
}
}
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
"Title": "首页",
"ExtraCSS": "/static/css/home.css",
"Title": "首页",
"ExtraCSS": "/static/css/home.css",
"Posts": posts,
"PostCount": total,
}))
})
@ -40,9 +53,10 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// 帖子页面
pages.GET("/posts", d.postCtrl.ListPage)
pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage)
pages.GET("/posts/:id", d.postCtrl.ShowPage)
pages.GET("/posts/:id/edit", d.authMdw.Required(), d.postCtrl.EditPage)
pages.GET("/posts/:id/edit", d.authMdw.Required(), func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/studio/write?id="+c.Param("id"))
})
// 创作中心(需登录)
studioPages := pages.Group("/studio")

View File

@ -8,12 +8,20 @@ import (
type AdminService struct {
userRepo userAdminStore
postRepo postAdminStatStore
sessionManager *session.Manager
}
// PostStats 帖子统计结果(管理首页用)
type PostStats struct {
Total int64 // 全部未删除帖子数
Approved int64 // 已发布数
Pending int64 // 待审核数pending + 待审修订)
}
// NewAdminService 构造函数
func NewAdminService(userRepo userAdminStore, sm *session.Manager) *AdminService {
return &AdminService{userRepo: userRepo, sessionManager: sm}
func NewAdminService(userRepo userAdminStore, postRepo postAdminStatStore, sm *session.Manager) *AdminService {
return &AdminService{userRepo: userRepo, postRepo: postRepo, sessionManager: sm}
}
// ListUsersParams 用户列表查询参数
@ -112,6 +120,23 @@ func (s *AdminService) CountUsers() (int64, error) {
return s.userRepo.CountSearchUsers("", "", "")
}
// GetPostStats 获取帖子统计(管理首页用)
func (s *AdminService) GetPostStats() (*PostStats, error) {
total, err := s.postRepo.CountPostsByStatus("")
if err != nil {
return nil, err
}
approved, err := s.postRepo.CountPostsByStatus(model.PostStatusApproved)
if err != nil {
return nil, err
}
pending, err := s.postRepo.CountPending()
if err != nil {
return nil, err
}
return &PostStats{Total: total, Approved: approved, Pending: pending}, nil
}
// GetStoreMetrics 返回会话存储状态信息(管理首页用)
func (s *AdminService) GetStoreMetrics() session.StoreMetrics {
return s.sessionManager.GetStoreMetrics()

View File

@ -0,0 +1,128 @@
package service
import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// review 通用审核操作
func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error {
// 1. 查审核记录
submission, err := s.auditRepo.FindByID(submissionID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return common.ErrAuditNotFound
}
return err
}
// 2. 必须 pending
if submission.Status != model.AuditStatusPending {
return common.ErrAuditNotPending
}
// 3. 查审核人信息
reviewer, err := s.userRepo.FindByID(reviewerID)
if err != nil {
return common.ErrUserNotFound
}
// 4. 标记审核结果
submission.ReviewedBy = &reviewerID
submission.ReviewerName = &reviewer.Username
if approved {
submission.Status = model.AuditStatusApproved
// 更新 User 表对应字段
if err := s.applyApproval(submission); err != nil {
return err
}
// 通知用户审核通过
if s.notifier != nil {
s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID)
}
} else {
submission.Status = model.AuditStatusRejected
submission.RejectReason = &reason
// 改名审核被拒 → 仅非首次改名(已扣费)才退还域能
// 首次改名免费,被拒不退还
shouldRefund := false
if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil {
if s.levelSvc != nil {
hasCompleted, _ := s.levelSvc.HasCompletedTask(submission.UserID, "username")
shouldRefund = hasCompleted
}
if shouldRefund {
_ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID)
}
}
// 通知用户审核被拒(仅实际退款时追加域能返还提示)
if s.notifier != nil {
reasonMsg := reason
if shouldRefund {
reasonMsg += "。扣除的域能已被返还"
}
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
}
// 头像审核被拒 → 删除提交的新头像文件
if submission.AuditType == model.AuditTypeAvatar && s.avatarCleaner != nil {
_ = s.avatarCleaner.DeleteAvatarFile(submission.NewValue)
}
}
return s.auditRepo.Update(submission)
}
// applyApproval 将审核通过的内容写入 User 表
// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占)
func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
user, err := s.userRepo.FindByID(submission.UserID)
if err != nil {
return common.ErrUserNotFound
}
switch submission.AuditType {
case model.AuditTypeUsername:
exists, err := s.userRepo.ExistsByUsername(submission.NewValue)
if err != nil {
return err
}
if exists {
return common.ErrUsernameTaken
}
user.Username = submission.NewValue
case model.AuditTypeAvatar:
oldAvatar := user.Avatar
user.Avatar = submission.NewValue
if err := s.userRepo.Update(user); err != nil {
return err
}
// 审核通过,删除旧头像文件
if s.avatarCleaner != nil {
_ = s.avatarCleaner.DeleteAvatarFile(oldAvatar)
}
return nil
case model.AuditTypeBio:
user.Bio = submission.NewValue
}
if err := s.userRepo.Update(user); err != nil {
return err
}
// 触发首次任务奖励(静默失败不影响主流程)
if s.levelSvc != nil {
switch submission.AuditType {
case model.AuditTypeUsername:
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "username")
case model.AuditTypeAvatar:
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "avatar")
case model.AuditTypeBio:
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "bio")
}
}
return nil
}

View File

@ -4,8 +4,6 @@ import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// auditRepo AuditService 所需的最小仓储接口ISP
@ -33,32 +31,65 @@ type siteSettingsChecker interface {
IsAuditTypeEnabled(auditType string, defaultVal bool) bool
}
// auditNotifier 审核服务所需的通知接口ISP审核通过/拒绝时发送通知)
// auditNotifier AuditService 所需的通知接口ISP审核通过/拒绝时发送通知)
type auditNotifier interface {
NotifyAuditApproved(userID uint, auditType string, relatedID uint)
NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint)
}
// auditTaskCompleter 审核通过时触发首次任务奖励的接口
type auditTaskCompleter interface {
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
HasCompletedTask(userID uint, taskType string) (bool, error)
}
// energyRenameRefunder 改名审核被拒时返还域能的接口ISP
type energyRenameRefunder interface {
RefundOnRenameReject(userID uint) error
}
// avatarFileCleaner 头像审核通过/拒绝时清理头像文件的接口ISP
type avatarFileCleaner interface {
CleanOldAvatars(userID uint)
DeleteAvatarFile(url string) error
}
// AuditService 审核业务逻辑
type AuditService struct {
auditRepo auditRepo
userRepo userProfileStore
settings siteSettingsChecker
defaultCfg config.AuditConfig
notifier auditNotifier
auditRepo auditRepo
userRepo userProfileStore
settings siteSettingsChecker
defaultCfg config.AuditConfig
notifier auditNotifier
levelSvc auditTaskCompleter
energyRefundSvc energyRenameRefunder
avatarCleaner avatarFileCleaner
}
// NewAuditService 构造函数
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier) *AuditService {
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier, levelSvc auditTaskCompleter) *AuditService {
return &AuditService{
auditRepo: auditRepo,
userRepo: userRepo,
settings: settings,
defaultCfg: cfg,
notifier: notifier,
levelSvc: levelSvc,
}
}
// WithEnergyRefundService 链式注入域能退款服务
func (s *AuditService) WithEnergyRefundService(svc energyRenameRefunder) *AuditService {
s.energyRefundSvc = svc
return s
}
// WithAvatarFileCleaner 链式注入头像文件清理服务
func (s *AuditService) WithAvatarFileCleaner(svc avatarFileCleaner) *AuditService {
s.avatarCleaner = svc
return s
}
// ShouldAudit 判断指定用户是否需要走审核流程
// 规则admin 及以上角色免审
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
@ -150,79 +181,6 @@ func (s *AuditService) Reject(reviewerID uint, submissionID uint, reason string)
return s.review(reviewerID, submissionID, false, reason)
}
// review 通用审核操作
func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error {
// 1. 查审核记录
submission, err := s.auditRepo.FindByID(submissionID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return common.ErrAuditNotFound
}
return err
}
// 2. 必须 pending
if submission.Status != model.AuditStatusPending {
return common.ErrAuditNotPending
}
// 3. 查审核人信息
reviewer, err := s.userRepo.FindByID(reviewerID)
if err != nil {
return common.ErrUserNotFound
}
// 4. 标记审核结果
submission.ReviewedBy = &reviewerID
submission.ReviewerName = &reviewer.Username
if approved {
submission.Status = model.AuditStatusApproved
// 更新 User 表对应字段
if err := s.applyApproval(submission); err != nil {
return err
}
// 通知用户审核通过
if s.notifier != nil {
s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID)
}
} else {
submission.Status = model.AuditStatusRejected
submission.RejectReason = &reason
// 通知用户审核被拒
if s.notifier != nil {
reasonMsg := reason
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
}
}
return s.auditRepo.Update(submission)
}
// applyApproval 将审核通过的内容写入 User 表
// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占)
func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
user, err := s.userRepo.FindByID(submission.UserID)
if err != nil {
return common.ErrUserNotFound
}
switch submission.AuditType {
case model.AuditTypeUsername:
exists, err := s.userRepo.ExistsByUsername(submission.NewValue)
if err != nil {
return err
}
if exists {
return common.ErrUsernameTaken
}
user.Username = submission.NewValue
case model.AuditTypeAvatar:
user.Avatar = submission.NewValue
case model.AuditTypeBio:
user.Bio = submission.NewValue
}
return s.userRepo.Update(user)
}
// List 分页查询审核列表
func (s *AuditService) List(auditType, status string, page, pageSize int) (*AuditListResult, error) {
p := common.Pagination{Page: page, PageSize: pageSize}

View File

@ -0,0 +1,129 @@
package service
import (
"regexp"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
var pwLower = regexp.MustCompile(`[a-z]`)
var pwUpper = regexp.MustCompile(`[A-Z]`)
var pwDigit = regexp.MustCompile(`\d`)
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
var dummyHash []byte
func init() {
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
if err != nil {
panic("failed to generate bcrypt dummy hash: " + err.Error())
}
dummyHash = hash
}
// validatePassword 密码强度:至少 8 位 + 包含大写字母 + 包含小写字母 + 包含数字
func validatePassword(pw string) error {
if len(pw) < 8 || !pwLower.MatchString(pw) || !pwUpper.MatchString(pw) || !pwDigit.MatchString(pw) {
return common.ErrWeakPassword
}
return nil
}
// ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session强制重新登录
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
return common.ErrIncorrectPassword
}
if err := validatePassword(newPassword); err != nil {
return err
}
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
if err != nil {
return err
}
user.PasswordHash = hash
if err := s.userRepo.Update(user); err != nil {
return err
}
// 删除所有 session强制所有设备重新登录
return s.invalidateSessions(userID)
}
// DeleteAccount 用户自主注销
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
if user.Role == model.RoleOwner {
return common.ErrOwnerCannotDelete
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return common.ErrIncorrectPassword
}
user.Status = model.StatusDeleted
user.DeleteReason = reason
if err := s.userRepo.Update(user); err != nil {
return err
}
return s.invalidateSessions(userID)
}
// invalidateSessions 销毁某用户的所有 session内部方法
func (s *AuthService) invalidateSessions(userID uint) error {
return s.sessionManager.DestroyByUID(userID)
}
// InvalidateSessions 公开方法:销毁用户所有 session供 admin 等外部调用)
func (s *AuthService) InvalidateSessions(userID uint) error {
return s.sessionManager.DestroyByUID(userID)
}
// ConfirmRestore 二次确认恢复已注销账号
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrInvalidCred
}
if user.Status != model.StatusDeleted {
return nil, common.ErrUserNotFound
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
return nil, common.ErrInvalidCred
}
// 恢复账号
user.Status = model.StatusActive
user.DeletedAt = gorm.DeletedAt{}
now := time.Now()
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
return nil, err
}
return user, nil
}

View File

@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"regexp"
"time"
"unicode/utf8"
@ -11,7 +12,6 @@ import (
"metazone.cc/metalab/internal/session"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// AuthService 认证业务逻辑
@ -27,31 +27,9 @@ func NewAuthService(userRepo userAuthStore, sm *session.Manager, cfg *config.Con
return &AuthService{userRepo: userRepo, sessionManager: sm, cfg: cfg, siteSettings: siteSettings}
}
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
var pwDigit = regexp.MustCompile(`\d`)
// usernamePattern 用户名合法字符:中文、英文大小写、数字、下划线、连字符
var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`)
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
var dummyHash []byte
func init() {
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
if err != nil {
panic("failed to generate bcrypt dummy hash: " + err.Error())
}
dummyHash = hash
}
// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字
func validatePassword(pw string) error {
if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) {
return common.ErrWeakPassword
}
return nil
}
// IsRegistrationEnabled 检查是否允许新用户注册
func (s *AuthService) IsRegistrationEnabled() bool {
return s.siteSettings.IsRegistrationEnabled()
@ -148,12 +126,6 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User
return nil, common.ErrUserLocked
}
// 封禁
if user.Status == model.StatusBanned {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrUserBanned
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
return nil, common.ErrInvalidCred
}
@ -169,35 +141,6 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User
return user, nil
}
// ConfirmRestore 二次确认恢复已注销账号
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrInvalidCred
}
if user.Status != model.StatusDeleted {
return nil, common.ErrUserNotFound
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
return nil, common.ErrInvalidCred
}
// 恢复账号
user.Status = model.StatusActive
user.DeletedAt = gorm.DeletedAt{}
now := time.Now()
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
return nil, err
}
return user, nil
}
// CheckEmail 检查邮箱是否已被注册
func (s *AuthService) CheckEmail(email string) (bool, error) {
return s.userRepo.ExistsByEmail(email)
@ -208,71 +151,6 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
return s.userRepo.FindByID(userID)
}
// ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session强制重新登录
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
return common.ErrIncorrectPassword
}
if err := validatePassword(newPassword); err != nil {
return err
}
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
if err != nil {
return err
}
user.PasswordHash = hash
if err := s.userRepo.Update(user); err != nil {
return err
}
// 删除所有 session强制所有设备重新登录
return s.invalidateSessions(userID)
}
// DeleteAccount 用户自主注销
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
if user.Role == model.RoleOwner {
return common.ErrOwnerCannotDelete
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return common.ErrIncorrectPassword
}
user.Status = model.StatusDeleted
user.DeleteReason = reason
if err := s.userRepo.Update(user); err != nil {
return err
}
return s.invalidateSessions(userID)
}
// invalidateSessions 销毁某用户的所有 session内部方法
func (s *AuthService) invalidateSessions(userID uint) error {
return s.sessionManager.DestroyByUID(userID)
}
// InvalidateSessions 公开方法:销毁用户所有 session供 admin 等外部调用)
func (s *AuthService) InvalidateSessions(userID uint) error {
return s.sessionManager.DestroyByUID(userID)
}
// UpdateProfile 修改个人资料
func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
user, err := s.userRepo.FindByID(userID)
@ -315,3 +193,29 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
}
return s.userRepo.Update(user)
}
// GetNotifyPrefs 获取用户通知偏好
func (s *AuthService) GetNotifyPrefs(userID uint) (map[string]bool, error) {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return nil, common.ErrUserNotFound
}
return ParseNotifyPrefs(user.NotifyPrefs), nil
}
// UpdateNotifyPref 更新单个通知偏好项
func (s *AuthService) UpdateNotifyPref(userID uint, key string, enabled bool) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
prefs := ParseNotifyPrefs(user.NotifyPrefs)
prefs[key] = enabled
data, err := json.Marshal(prefs)
if err != nil {
return err
}
user.NotifyPrefs = string(data)
return s.userRepo.Update(user)
}

View File

@ -57,6 +57,9 @@ func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType s
return "", err
}
// 直接更新场景:删除旧头像文件,仅保留新文件
s.CleanOldAvatars(userID)
user, err := s.userRepo.FindByID(userID)
if err != nil {
return "", fmt.Errorf("用户不存在")
@ -134,10 +137,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
return "", fmt.Errorf("创建存储目录失败")
}
// 8. 删除该用户的旧头像,仅保留最新
s.cleanOldAvatars(userID)
// 9. 原子写入:先写临时文件,再 rename 到最终路径
// 8. 原子写入:先写临时文件,再 rename 到最终路径
// 注意:此处不删除旧头像文件,审核场景下审核通过前旧头像仍需正常显示
ts := time.Now().Unix()
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
@ -153,8 +154,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
}
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)
func (s *AvatarService) cleanOldAvatars(userID uint) {
// CleanOldAvatars 删除指定用户的所有旧头像文件,调用方负责确保新文件不受影响
func (s *AvatarService) CleanOldAvatars(userID uint) {
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
entries, err := os.ReadDir(s.storageDir)
if err != nil {
@ -167,6 +168,19 @@ func (s *AvatarService) cleanOldAvatars(userID uint) {
}
}
// DeleteAvatarFile 根据头像 URL 删除对应的磁盘文件
func (s *AvatarService) DeleteAvatarFile(url string) error {
if url == "" {
return nil
}
filename := filepath.Base(url)
if filename == "." || filename == "/" {
return nil
}
fullPath := filepath.Join(s.storageDir, filename)
return os.Remove(fullPath)
}
// subImage 从原图中裁切指定正方形区域
func subImage(img image.Image, x, y, size int) image.Image {
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))

View File

@ -0,0 +1,244 @@
package service
import (
"errors"
"fmt"
"regexp"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
var mentionPattern = regexp.MustCompile(`@(\S{1,16})`)
// CommentService 评论业务逻辑
type CommentService struct {
repo commentStore
notifier commentNotifier
}
// NewCommentService 构造函数
func NewCommentService(repo commentStore, notifier commentNotifier) *CommentService {
return &CommentService{repo: repo, notifier: notifier}
}
// CreateRoot 创建顶级评论
func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*model.Comment, error) {
if strings.TrimSpace(body) == "" {
return nil, common.ErrCommentEmpty
}
postAuthorID, err := s.repo.FindPostAuthorID(postID)
if err != nil {
return nil, fmt.Errorf("查询文章失败: %w", err)
}
comment := &model.Comment{
PostID: postID,
RootID: 0,
Body: body,
UserID: userID,
}
if err := s.repo.Create(comment); err != nil {
return nil, fmt.Errorf("创建评论失败: %w", err)
}
// RootID 指向自身UPDATE 而非 INSERT
comment.RootID = comment.ID
if err := s.repo.UpdateRootID(comment.ID, comment.ID); err != nil {
return nil, fmt.Errorf("更新根评论ID失败: %w", err)
}
// 文章评论数+1
_ = s.repo.IncrCommentsCount(postID)
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, postID, userID, commenterName, body)
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论",
fmt.Sprintf("%s 评论了你的文章", commenterName),
&comment.PostID, &comment.ID)
}
return comment, nil
}
// CreateReply 创建回复
func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*model.Comment, error) {
if strings.TrimSpace(body) == "" {
return nil, common.ErrCommentEmpty
}
parent, err := s.repo.FindByID(parentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrCommentNotFound
}
return nil, fmt.Errorf("查询父评论失败: %w", err)
}
comment := &model.Comment{
PostID: parent.PostID,
RootID: parent.RootID,
ParentID: &parentID,
Body: body,
UserID: userID,
ReplyToUID: parent.UserID,
}
if err := s.repo.Create(comment); err != nil {
return nil, fmt.Errorf("创建回复失败: %w", err)
}
// 文章评论数+1
_ = s.repo.IncrCommentsCount(parent.PostID)
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID {
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复",
fmt.Sprintf("%s 回复了你的评论", commenterName),
&parent.PostID, &comment.ID)
}
return comment, nil
}
// Delete 软删除评论requesterID 为请求者isAdmin 表示是否为管理员)
func (s *CommentService) Delete(commentID uint, requesterID uint, isAdmin bool) error {
comment, err := s.repo.FindByID(commentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrCommentNotFound
}
return fmt.Errorf("查询评论失败: %w", err)
}
if comment.IsDeleted {
return nil
}
// 权限检查:仅本人或管理员可删除
if !isAdmin && comment.UserID != requesterID {
return common.ErrPermissionDenied
}
if err := s.repo.SoftDelete(commentID); err != nil {
return fmt.Errorf("删除评论失败: %w", err)
}
_ = s.repo.DecrCommentsCount(comment.PostID)
return nil
}
// ListRootComments 分页获取文章顶级评论
func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
comments, total, err := s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
if err != nil {
return nil, 0, err
}
// 批量加载有效@提及映射,前端据此决定是否为链接
if len(comments) > 0 {
_ = s.repo.LoadMentionsForComments(comments)
}
return comments, total, nil
}
// ListReplies 获取某条顶级评论的全部回复
func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
list, err := s.repo.FindReplies(rootID)
if err != nil {
return nil, fmt.Errorf("查询回复失败: %w", err)
}
if list == nil {
list = []model.Comment{}
}
// 批量加载有效@提及映射
if len(list) > 0 {
_ = s.repo.LoadMentionsForComments(list)
}
return list, nil
}
// ListAllComments 后台评论列表(分页+搜索)
func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
rows, total, err := s.repo.ListAllComments(keyword, showDeleted, p.Offset(), p.PageSize)
if rows == nil {
rows = []model.AdminCommentRow{}
}
return rows, total, err
}
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗已关注优先最多5条
func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 5, followerUID)
if err != nil {
return nil, fmt.Errorf("搜索用户失败: %w", err)
}
users := make([]model.UserSearchResult, len(rows))
for i, row := range rows {
users[i] = model.UserSearchResult{
UID: row.UID,
Username: row.Username,
Level: model.GetLevelByExp(row.Exp),
Avatar: row.Avatar,
}
}
return users, nil
}
// parseMentions 解析@用户,存储到 comment_mentions 表,并向被@用户发送通知
func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID uint, commenterName string, body string) {
matches := mentionPattern.FindAllStringSubmatch(body, -1)
seen := make(map[string]bool)
notified := make(map[uint]bool) // 防止重复通知同一用户
for _, m := range matches {
username := m[1]
if seen[username] {
continue
}
seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
if len(rows) == 0 {
continue
}
mentionedUID := rows[0].UID
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: mentionedUID,
OriginalUsername: username,
})
// 向被@用户发送通知(排除自己@自己、重复通知)
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
notified[mentionedUID] = true
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
"新提及",
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
&postID, &commentID)
}
}
}

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