Compare commits

..

45 Commits

Author SHA1 Message Date
5a8b0ca79c feat: 实现 FallbackStore 会话存储降级保护
- 新增 session/fallback_store.go,包装 RedisStore + MemoryStore 双后端
- 后台 goroutine 周期性 ping Redis,自动降级/恢复
- Store 接口新增 Metrics() 方法,MemoryStore/RedisStore/FallbackStore 均已实现
- deps_core.go 改造为始终创建 MemoryStore 兜底,Redis 启用时包装为 FallbackStore
- Manager 新增 GetStoreMetrics() 暴露存储状态
2026-05-31 11:38:20 +08:00
d6d03a7c3c feat: 实现 Redis 会话存储,支持内存/Redis 双模式自动切换
- 新增 go-redis/v9 依赖
- 新增 config.yaml redis 配置段 + RedisConfig 结构体 + REDIS_PASSWORD 环境变量覆盖
- 新增 common/redis.go Redis 客户端初始化
- 完整实现 session/redis_store.go,实现 Store 接口全部 7 个方法
- Redis TTL 自动过期,无须后台清理 goroutine
- deps_core.go 根据 cfg.Redis.Enabled 自动选择 RedisStore/MemoryStore
- router.go + main.go 透传 redisClient 参数
2026-05-31 11:28:27 +08:00
55c408d86c fix: 审计问题全量修复 + RateLimiter 原子化重构 + CDN 本地化收紧
- 安全:移除 CSP 中 esm.sh/cdnjs.cloudflare.com,highlight.js 主题已本地化 73 个文件
- 安全:StatusBanned 分支补 dummy hash 防时序攻击
- 安全:手写 constantTimeEq 替换为 crypto/subtle.ConstantTimeCompare
- Bug:锁定操作消息已删除→已锁定
- YAGNI:删除 12 个空预留模板目录
- KISS:删除 common.CheckPassword 薄封装,统一用 bcrypt 调用
- KISS:删除 tokenCtrl 别名字段,api.go 统一用 authCtrl
- RateLimiter:check()+recordFail 闭包模式重构为 try() 原子操作,消除竞态
- RateLimiter:新增 ClearIP() 方法,注册成功时清除 IP 计数
- 文档:修正 audit_service.go 注释编号跳跃(3→5→4)
- 文档:修正 deps_core.go 过清理→过期清理
2026-05-31 11:13:32 +08:00
e1fb28e8fb fix: 修复登录重定向失效 + 404页面友好化
- ShowPage 未登录用户看未发布帖子时引导登录(携带 redirect 参数),替代直接 404
- login.js 无 redirect 参数时回退到同站 Referer 页面
- 404 页面重设计:SVG 图标 + 中文提示 + 返回首页和浏览帖子双按钮
- posts.css 新增 404 页面按钮样式
2026-05-31 03:39:18 +08:00
8b075f7387 perf: WebP 二分压缩下限从 50 改为 1,匹配原图实际质量
- encodeWebPBinary 质量区间从 50~80 扩大为 1~80
- 低质量原图自动匹配低质量 WebP,不再无故回退原格式
- 回退路径中 WebP 不再重编码,避免低质量 WebP 被 quality 80 撑大
- 回退路径仅对 JPEG/PNG 重编码剥离元数据
2026-05-31 03:16:55 +08:00
48186085d8 fix: 回退存储路径也解码重编码,彻底剥离 EXIF/元数据
- fallback 路径不再直接写原始上传字节,而是解码后重编码
- JPEG: decode → jpeg.Encode(quality 92),剥离 EXIF/GPS/注释
- PNG: decode → png.Encode,剥离元数据块
- WebP: decode → webp.Encode(quality 80),剥离元数据
- GIF 例外:重编码会丢失动画帧,仅解码验证后原样存储
- 导入 jpeg/png 从空白导入改为命名导入,以使用 Encode 函数
2026-05-31 03:12:10 +08:00
d8e58b5f8c perf: 文章图片 WebP 转换改为二分查找质量,目标 ≤ 原文件大小
- 不再仅试 quality 80,改为二分查找 (50~80) 找最高质量 ≤ 原文件大小
- 新增 encodeWebPBinary 辅助函数:先试 80,太大则二分降到 50
- 降到 50 仍超限时回退原格式,避免产生马赛克级质量
- 与头像的激进二分(最低 quality 1)区分,文章图片保底 quality 50
2026-05-31 03:08:33 +08:00
6ea4e5365e fix: 文章图片上传增加内容解码验证,防伪扩展名和图片投毒
- 所有格式(JPEG/PNG/GIF/WebP)上传后强制解码验证是否为有效图片
- 解码失败直接拒绝,不再回退原样存储
- 消除 .php 改名为 .jpg 绕过检查的安全漏洞
- JPEG/PNG 解码与 WebP 编码合并为一次解码,避免重复读
2026-05-31 03:04:45 +08:00
5fbd010b71 feat: 文章图片上传 JPEG/PNG 自动转 WebP 压缩存储
- JPEG/PNG 上传后自动转为 WebP quality 80 存储,保留原尺寸不 resize
- 仅当 WebP 比原文件小时才采用,否则回退原格式
- GIF/WebP 保持不变直存
- 移除对 common.SaveUploadedFile 的依赖,改用 io.ReadAll + os.WriteFile
2026-05-31 02:59:35 +08:00
da7560b918 perf: 客户端裁剪直接缩放到 512×512 再上传,进一步节省带宽
- cropOnClient 输出尺寸从 min(size, 2048) 改为固定 512px
- 服务端收到的已是 512×512,解码后 resize 和 WebP 编码近乎瞬时
- 上传体积从数十 KB~数 MB 降至 ~30-60KB
2026-05-31 02:43:58 +08:00
4887093c3a feat: 头像上传选区限制 3840×3840 + 客户端裁剪压缩
- 后端:3840×2160 限制改为 3840×3840(方形限制适应方形裁剪),并移到裁剪结果上检查而非原图
- 前端:原图短边超过 3840 时自动抬高最小缩放,确保选区永不超过 3840×3840
- 前端:裁剪确认后用 Canvas 预裁剪 + JPEG 压缩再上传,大幅节省带宽
- 前端:裁剪框标题栏新增实时像素尺寸提示
- 新增 .crop-info 样式
2026-05-31 02:42:27 +08:00
09a9394c30 fix: 修复静态资源哈希 key 与模板 URL 路径不匹配导致版本号全为 0
- 重构 walkStaticDir 统一处理三个 static 目录
- URL key 基于文件相对于 static 目录的路径,不含主题目录名
2026-05-31 02:18:20 +08:00
eabceecaae refactor: 静态资源哈希算法从 SHA-1 改为 CRC32
- CRC32 更快且语义更匹配缓存破坏场景
- 输出自然为 8 位 hex,无需截取
2026-05-31 02:06:41 +08:00
d3e5cbe18b refactor: 静态资源缓存破坏改为文件级哈希,重启不刷新未修改文件的缓存
- 启动时计算每个 JS/CSS 的 SHA-1 前 8 位作为版本号
- 文件不变则哈希不变,浏览器继续用缓存,重启零压力
- 替换全局 AssetVersion 为 assetV 模板函数,按文件路径查询哈希
- 移除 BuildPageData 中的全局版本号注入
2026-05-31 01:53:17 +08:00
b8658712bb fix: 静态资源加缓存破坏版本号,解决浏览器缓存旧 JS 导致功能不生效
- 启动时生成 AssetVersion(base36 时间戳),注入模板 {{.V}}
- 所有 JS/CSS 引用加 ?v={{.V}},重启即刷新缓存
- 涉及前端、管理后台共 12 个模板文件
2026-05-31 01:46:47 +08:00
0bcaadceb4 fix: 注销恢复流程文案统一为'撤销注销',消除歧义
- '恢复'易误解为故障恢复,改为'撤销注销'明确表达取消注销操作
- 涉及后端错误提示、API 响应、前端弹窗及按钮文案共 9 处
2026-05-31 01:26:04 +08:00
a62039e6bb fix: 登录/注册支持 redirect 参数,未登录跳转自动携带来源页
- login.js/register.js 登录成功后优先跳转 redirect 参数指定页面
- 新增 common.RedirectToLogin 辅助函数,自动携带当前路径
- 替换所有 9 处硬编码 /auth/login 重定向
2026-05-31 01:23:09 +08:00
9d84d6bf25 feat: 临时会话超时从 24 小时缩短为 2 小时
- idle_timeout: 1440 → 120 分钟
- 前端已有 10 分钟心跳续期机制,写文章等长时间操作不会丢失会话
2026-05-31 00:51:44 +08:00
213b2a571e feat: 登录管理设备列表显示会话类型和剩余有效期
- 临时会话显示橙色'临时会话'徽章
- 非当前设备显示剩余有效时间(临时/长期均显示)
- 当前设备不显示剩余时间(持续续期,显示无意义)
- 新增 formatTTL 模板函数,分钟数转人类可读格式
- sessionConfig 接口增加 GetRememberTimeout 方法
2026-05-31 00:36:25 +08:00
e244bccb5c fix: 移除服务端登录会话去重逻辑
- 去重会合并标准模式/无痕模式的独立会话,不符合预期
- 防重复提交由前端按钮禁用保证即可,服务端无需干预
2026-05-31 00:29:47 +08:00
d5d33c3ace fix: 修复登录重复创建会话 + 备注输入框不可交互
- login.js 增加防重复提交锁,请求期间禁用按钮
- Manager.Create 增加幂等检查,同用户同IP同UA 10秒内复用已有会话
- 移除当前设备备注输入框的 readonly 限制,改为 data-current 标记
- 修复 readonly 状态下输入框完全透明无法点击的问题
2026-05-31 00:28:00 +08:00
47ef3f2a99 fix: 重写设置页模板,修复 sessions tab 重复和结构损坏
- 移除 sessions tab 重复3次的冗余内容
- 修复 profile 内容碎片混入 sessions 区块的问题
- 恢复正确的三tab结构(profile / sessions / account)
2026-05-31 00:18:47 +08:00
b63dc9075f fix: 修复 MemoryStore.Set 重复追加 byUID 索引导致会话列表重复 2026-05-31 00:08:08 +08:00
2180c27b16 fix: 修复设置页模板中文引号与 HTML 属性冲突导致渲染报错 2026-05-31 00:06:24 +08:00
d203447eb3 feat: 设置页新增登录管理 TAB
- Session 结构体新增 IP、UserAgent、Remark 字段,登录/注册时记录设备信息
- Store 接口新增 ListByUID、DeleteByUIDExclude 方法,MemoryStore 完整实现
- SessionManager 新增 ListByUID、DestroyOtherByUID、UpdateRemark 方法
- 新增 UA 轻量解析器(session/ua.go),提取浏览器/OS/设备类型
- 新增 settings_api_sessions.go,提供列表/踢出/一键踢出/备注 4 个 API
- 控制器层声明 sessionManager 最小接口(ISP),SettingsController 注入依赖
- Auth 中间件注入 sid 到 gin context,支持识别当前会话
- 设置页左侧导航增加登录管理入口,tab 白名单新增 sessions
- 前端实现设备列表展示(浏览器/OS/设备图标/IP/时间)、当前设备标识、备注输入、踢出按钮、一键踢出
- settings.css 新增会话管理全套样式及响应式适配
2026-05-30 23:58:37 +08:00
e3853071d6 chore: 清理 JWT 向后兼容残留,移除全部死代码
- 删除 5 个空壳占位文件:auth_token.go/auth_parser.go/repository.go(middleware)/token_service.go/provider.go
- 移除 Config.JWT 字段、JWTConfig 结构体、JWT_SECRET 环境变量绑定
- 移除 config.yaml 中的 jwt 配置段
- 移除 model.User.TokenVersion 字段(session DestroyByUID 替代)
- 移除 service/repository.go 中已废弃的 userTokenStore 接口
2026-05-30 23:42:27 +08:00
d9ba1cdf28 refactor: 移除 Session 架构下已无用的 TokenVersion 即时吊销机制
- 删除 UserRepo.IncrementTokenVersion/FindTokenVersion 方法
- 从 userAuthStore/userAdminStore 接口移除 IncrementTokenVersion
- 删除 middleware.tokenVersionStore 接口(中间件不再查询 token_version)
- auth_service: DeleteAccount/InvalidateSessions 仅调用 DestroyByUID
- admin_service: UpdateUserStatus/ResetUserToken 仅调用 DestroyByUID
- FindByIDForAuth/FindByIDUnscoped Select 移除 token_version 列
- 新架构下登出/改密/封禁统一走 sessionManager.DestroyByUID,无需 token_version
2026-05-30 23:38:42 +08:00
daf87f895b refactor: JWT 无状态认证替换为服务端 Session,修复记住我掉线问题
- 新增 internal/session 包:Session 结构体、Store 接口、MemoryStore(内存+后台清理)、RedisStore 预留
- Cookie 从 3 个精简为 1 个 mlb_sid(HttpOnly),移除 JWT access/refresh cookie
- 会话过期采用滑动窗口:每次请求自动续期,记住我 30 天无操作过期
- AuthMiddleware/AuthAdmin/Maintenance 中间件改用 SessionManager.Validate()
- AuthService Login/Register/ConfirmRestore 去除 token 生成,返回 *model.User
- Controller 层在登录/注册后调用 SessionManager.Create 创建会话
- AdminService UpdateUserStatus/ResetUserToken 同步销毁 session 实现即时退登
- 改密/注销时通过 DestroyByUID 删除所有 session(替换 TokenVersion 检查)
- config.yaml 新增 session 配置段(idle_timeout/remember_timeout/cleanup_interval)
- TokenService/auth_parser/auth_token 标记废弃,移除 JWT 到期字段依赖
2026-05-30 23:36:52 +08:00
53f84bfcc4 fix: 修复编辑模式下 Vditor 不加载稿件内容
- 创作中心编辑文章时,after 回调中显式调用 vditor.setValue(initialMD)
- 修复 Vditor 3.x wysiwyg 模式下 value 选项可能不渲染内容的问题
2026-05-30 21:29:29 +08:00
67444434b9 feat: 对接稿件审核通知系统 + 消息页面系统消息区域
- 新增通知类型 post_approved / post_rejected
- PostService.Approve/Reject 完成后通知作者(通知失败不影响主流程)
- NotificationService 新增 NotifyPostApproved/NotifyPostRejected 方法
- 消息页面顶部增加系统消息标识和分隔线,之下为全部消息列表
- 稿件审核通过通知卡片点击跳转到 /posts/{id} 页面
- 稿件审核拒绝通知卡片点击跳转到 /studio/posts 创作中心
- 新增 postNotifier 接口遵循 ISP 原则
2026-05-30 21:24:30 +08:00
fb94ce60ae fix: 维护期间登录一律返回统一提示,不泄露账号状态
- 维护检查提前到 FindByEmail 之后、所有状态检查之前
- 非站长账号(不存在/已封禁/已锁定等)统一走模拟 bcrypt 防时序 → 返回维护提示
- 站长角色正常通过,走完整登录流程
- 旧实现:维护检查在 banned/locked/deleted 之后,会泄露账号状态
2026-05-30 21:10:41 +08:00
c1357cf4af fix: 维护条对站长显示“正处于维护状态”提醒 + BuildPageData 注入 IsOwner
- BuildPageData 新增 IsOwner 变量,所有前端页面可获取站长角色
- Nav 维护条:站长显示“社区正处于维护状态”(防止忘记关闭),非站长显示“仅站长可访问”
- 维护中间件 API 拦截验证:所有非 /api/auth/ 的 API 对非 Owner 返回 403,正确
2026-05-30 21:06:32 +08:00
fe683900bb fix: 维护状态下登录页隐藏“立即注册”链接 2026-05-30 21:01:54 +08:00
1b4a74c23d fix: 维护开关初始状态显示与实际不符
- maintenance.enabled 默认 false,但 JS 用 registration 同逻辑导致首次显示为 ON
- 分离 maintenance 判断:仅当保存过 true 时才显示 ON
- registration 保持原逻辑(默认 true,仅当 !== false 时 ON)
2026-05-30 20:58:05 +08:00
6229c81f6e feat: 新增维护模式 + 修复注册关闭页面标题显示问题
- 修复注册关闭时标题“创建账号”和副标题仍显示的问题,三态互斥(维护中 > 关闭注册 > 正常)
- 新增 maintenance.enabled 站点设置,默认关闭,仅 Owner 可见可调节
- 维护中间件:全局拦截,维护期间仅站长可访问,白名单放行登录相关路径
- 登录服务:维护期间仅 Owner 角色可登录,Register 优先检查维护状态
- Nav 模板新增维护通知条(黄色横幅)
- 管理后台站点设置新增“启用维护模式”开关
- BuildPageData 自动注入 IsMaintenance 到所有页面模板
2026-05-30 20:54:23 +08:00
fb2eff5f23 feat: 注册关闭时页面隐藏表单并显示友好提示,保留登录入口
- RegisterPage 传入 RegistrationEnabled 标志到模板
- 注册关闭时隐藏输入框和按钮,显示“当前暂不开放注册”提示
- 保留“已有账号?立即登录”链接
- API 层已有拦截(AuthService.Register 返回 403),无需修改
- authUseCase 接口新增 IsRegistrationEnabled 方法(ISP)
2026-05-30 20:38:55 +08:00
90d17ed4b1 feat: 管理后台待审稿件置顶优先显示
- FindAdminPageable 新增 pageResultsAdmin,使用 CASE WHEN 排序
- pending 和有 PendingBody 的 approved 帖子排在最前
- 其余帖子按创建时间倒序排在后面
2026-05-30 20:33:14 +08:00
8bb87cd685 fix: 去掉独立修订表格,消除帖子重复显示
- 移除管理后台独立的"修订待审核"表格区域
- 修订标识和按钮已在主列表中内联展示,无需额外表格
- PostsPage 不再额外查询 Revisions 列表
2026-05-30 20:31:19 +08:00
95311704fe fix: 修订状态标识仅对作者可见,不公开显示 2026-05-30 20:28:59 +08:00
eabc1e7d3d feat: 已发布帖子编辑后进修订审核,通过前保持展示旧内容
- Post 模型新增 PendingTitle/PendingBody 字段,分离已发布内容与待审修订
- Update: approved 帖子编辑写入 Pending 字段,不覆盖正文,不改变发布状态
- Approve: 有 PendingBody 时复制到正式字段后清空,无 PendingBody 走首次审核
- Reject: 有 PendingBody 时仅清空待审修订(保持已发布),无 PendingBody 走普通退回
- 新增 ListPendingRevisions 查询,管理后台独立展示"修订待审核"区域
- 详情页展示"修订审核中"标识,编辑页加载待审内容
- 通过修订后直接覆盖,不保留历史版本
2026-05-30 20:24:43 +08:00
89fa50a013 fix: 锁定机制不再改变帖子状态,锁定与退回独立
- Post 模型新增 IsLocked 字段,与 Status 解耦
- Lock 仅设 IsLocked=true(不改 Status),已通过帖子锁定后仍公开可见
- Unlock 仅设 IsLocked=false(不改 Status)
- Update/EditPage 编辑检查改用 IsLocked 而非 Status==locked
- Reject 与 Lock 独立,可单独退回或锁定+退回组合使用
- 新增 ErrPostCannotLock 错误哨兵
- 前端模板编辑按钮/锁定标识基于 IsLocked 渲染
2026-05-30 20:08:41 +08:00
7659aee468 feat: 新增创作中心(/studio),支持数据概览、内容管理、草稿箱、写文章
- 新增 StudioController,依赖 studioUseCase 接口,遵循 ISP 接口隔离
- PostRepo 新增 FindByUserIDAndStatus 方法,按用户+状态分页查询
- PostService 新增 ListByUser、GetOverview 及 StudioOverview 统计
- /studio 页面路由(概览/管理/草稿箱/写文章/数据分析)+ /api/studio API
- 复用 Vditor 编辑器,studio-editor.js 对接 /api/studio/posts 端点
- 新增 studio.css(B站风格三栏布局+统计卡片+状态筛选tabs)
- 模板引擎注册 add/subtract 函数,分页模板中直接使用
2026-05-30 19:55:29 +08:00
bacfd4945d feat: 编辑器与帖子展示布局优化 + 新增个人空间
- 编辑器投稿页布局优化:标题与编辑器高度动态适配,工具栏与内容区视觉对齐
- 稿件展示页布局优化:MD 渲染区与评论区视觉分离,代码块主题微调
- CSRF 中间件:图像上传端点豁免,解决 Vditor 拖拽/粘贴上传 403
- Post 状态映射、GetGinUser、SaveUploadedFile 提取至 common 包,遵循审计建议
- 新增个人空间功能:/space(需登录)重定向到 /space/:uid,/space/:uid 公开访问
- 空间页模板:参考 B 站布局,展示头像、用户名、个性签名、UID、加入时间及稿件列表
- 导航栏:用户名链接指向 /space,新增「设置」入口
- 分层实现:SpaceController → SpaceService → PostRepo.FindByUserID,严格 ISP 接口隔离
2026-05-30 19:06:10 +08:00
40f0529cbf docs: 更新 README,移除暂停开发通知,补充 Vditor 编辑器与帖子功能
- 移除状态 badge(status-hiatus)和暂停说明
- 技术栈新增 Vditor 3.11 编辑器
- 当前功能新增:帖子 CRUD、代码高亮、Shortcode 卡片、草稿、审核工作流
- 计划中去掉已完成的帖子系统,新增评论系统
2026-05-30 15:59:06 +08:00
6ec12010f3 feat: Tiptap替换为Vditor + MD存储重构 + Shortcode卡片扩展
- 编辑器:Tiptap 替换为 Vditor 3.11.2(wysiwyg 模式,本地托管 5.8MB,去 CDN)
- 存储:Body 字段从 HTML 改为 Markdown,去除 BodyHTML,新增 Excerpt 列表摘要
- 安全:去除 bluemonday 依赖(MD 纯文本无 XSS 风险),go.mod 已清理
- Shortcode:新增 [zone:type:params] 扩展语法,支持活动/游戏/投票/资源卡片
- 图片上传:POST /api/posts/upload-image 直接返回 Vditor 原生响应格式
- 草稿:localStorage 键名改为 draft_post_body_md,与旧 HTML 草稿隔离
- 详情页:Vditor.method.min.js 客户端 MD → HTML 渲染,去 highlight.js CDN
- 样式:去 Tiptap 样式 ~190 行,精简工具栏 CSS,新增卡片样式
- 文档:vditor-migration-plan.md 完整记录迁移决策、架构、用法
2026-05-30 15:56:22 +08:00
221 changed files with 14572 additions and 1589 deletions

View File

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

View File

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

View File

@ -3,12 +3,14 @@ package main
import ( import (
"log" "log"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/router" "metazone.cc/metalab/internal/router"
"metazone.cc/metalab/internal/theme" "metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -48,6 +50,9 @@ func main() {
r := gin.Default() r := gin.Default()
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变)
common.ComputeAssetHashes("templates/MetaLab-2026")
// 模板加载(前端主题 + 管理后台) // 模板加载(前端主题 + 管理后台)
tmpl, err := theme.LoadTemplates( tmpl, err := theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""}, theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
@ -65,6 +70,18 @@ func main() {
r.Static("/uploads/avatars", "./storage/uploads/avatars") r.Static("/uploads/avatars", "./storage/uploads/avatars")
r.Static("/uploads/posts", "./storage/uploads/posts") r.Static("/uploads/posts", "./storage/uploads/posts")
// Redis 客户端(可选)
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 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
} else {
log.Println("Redis 未启用,使用内存存储")
}
// 站点设置管理器DB 持久化 + 内存缓存) // 站点设置管理器DB 持久化 + 内存缓存)
siteSettings, err := config.NewSiteSettings(db) siteSettings, err := config.NewSiteSettings(db)
if err != nil { if err != nil {
@ -72,7 +89,7 @@ func main() {
} }
// 路由注册 // 路由注册
router.Setup(r, db, cfg, siteSettings) router.Setup(r, db, cfg, siteSettings, redisClient)
// 启动 // 启动
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port) log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)

View File

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

View File

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

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

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

View File

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

7
go.mod
View File

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

20
go.sum
View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ var (
ErrInvalidCred = errors.New("邮箱或密码错误") ErrInvalidCred = errors.New("邮箱或密码错误")
ErrUserNotFound = errors.New("用户不存在") ErrUserNotFound = errors.New("用户不存在")
ErrUserBanned = errors.New("该账号已被封禁") ErrUserBanned = errors.New("该账号已被封禁")
ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复") ErrUserDeleted = errors.New("该账号正在注销,登录即撤销注销")
// 统一返回,避免泄露用户存在性、软删除状态等内部信息 // 统一返回,避免泄露用户存在性、软删除状态等内部信息
ErrUserLocked = errors.New("邮箱或密码错误") ErrUserLocked = errors.New("邮箱或密码错误")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字") ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
@ -32,9 +32,10 @@ var (
// 密码 & 注销相关 // 密码 & 注销相关
ErrIncorrectPassword = errors.New("当前密码错误") ErrIncorrectPassword = errors.New("当前密码错误")
ErrLoginBeforeDelete = errors.New("请先登录再注销") ErrLoginBeforeDelete = errors.New("请先登录再注销")
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号") ErrNeedsConfirmRestore = errors.New("需要二次确认撤销注销")
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限") ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
ErrRegistrationDisabled = errors.New("注册功能已关闭") ErrRegistrationDisabled = errors.New("注册功能已关闭")
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
// 帖子相关 // 帖子相关
ErrPostNotFound = errors.New("帖子不存在") ErrPostNotFound = errors.New("帖子不存在")
@ -42,5 +43,6 @@ var (
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核") ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过") ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁") ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
) )

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

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

View File

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

View File

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

View File

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

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

@ -0,0 +1,28 @@
package common
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"metazone.cc/metalab/internal/config"
)
// NewRedisClient 创建 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,
})
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)
}
return client, nil
}

View File

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

View File

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

View File

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

View File

@ -94,7 +94,7 @@ func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
if req.Status == model.StatusActive { if req.Status == model.StatusActive {
action = "解封" action = "解封"
} else if req.Status == model.StatusLocked { } else if req.Status == model.StatusLocked {
action = "已删除" action = "已锁定"
} }
common.OkMessage(c, action+"成功") common.OkMessage(c, action+"成功")
} }

View File

@ -37,25 +37,27 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
return return
} }
if posts == nil { totalPages := common.PageCount(total, pageSize)
posts = []model.Post{} prevPage := page - 1
if prevPage < 1 {
prevPage = 1
}
nextPage := page + 1
if nextPage > totalPages {
nextPage = totalPages
} }
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
totalPages := p.TotalPages(total)
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{ c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "内容管理", "Title": "内容管理",
"Posts": posts, "Posts": posts,
"Total": total, "Total": total,
"Page": p.Page, "Page": page,
"TotalPages": totalPages, "TotalPages": totalPages,
"PrevPage": p.PrevPage(), "PrevPage": prevPage,
"NextPage": p.NextPage(totalPages), "NextPage": nextPage,
"Keyword": keyword, "Keyword": keyword,
"Status": status, "Status": status,
"StatusNames": model.PostStatusDisplayNames, "StatusNames": common.PostStatusDisplayNames,
"ExtraCSS": "/admin/static/css/posts.css", "ExtraCSS": "/admin/static/css/posts.css",
})) }))
} }
@ -115,7 +117,7 @@ func (ctrl *AdminPostController) Lock(c *gin.Context) {
} }
if err := ctrl.postService.Lock(uint(id)); err != nil { if err := ctrl.postService.Lock(uint(id)); err != nil {
if errors.Is(err, common.ErrPostNotFound) { if errors.Is(err, common.ErrPostNotFound) || errors.Is(err, common.ErrPostCannotLock) {
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
} else { } else {
common.Error(c, http.StatusInternalServerError, "锁定失败") common.Error(c, http.StatusInternalServerError, "锁定失败")
@ -155,7 +157,11 @@ func (ctrl *AdminPostController) Restore(c *gin.Context) {
} }
if err := ctrl.postService.Restore(uint(id)); err != nil { 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, "恢复失败") common.Error(c, http.StatusInternalServerError, "恢复失败")
}
return return
} }

View File

@ -33,9 +33,10 @@ type auditStatusProvider interface {
FindByID(id uint) (*model.User, error) FindByID(id uint) (*model.User, error)
} }
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP6 个方法) // adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP7 个方法)
type adminPostUseCase interface { type adminPostUseCase interface {
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
Approve(postID uint) error Approve(postID uint) error
Reject(postID uint, reason string) error Reject(postID uint, reason string) error
Lock(postID uint) error Lock(postID uint) error

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/session"
"metazone.cc/metalab/internal/theme" "metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -13,17 +14,17 @@ import (
// AuthController 认证相关页面 + API // AuthController 认证相关页面 + API
type AuthController struct { type AuthController struct {
authService authUseCase authService authUseCase
tokenService tokenRefresher sessionManager *session.Manager
rateLimiter rateLimiter rateLimiter rateLimiter
cfg *config.Config cfg *config.Config
} }
// NewAuthController 构造函数 // NewAuthController 构造函数
func NewAuthController(authService authUseCase, tokenSvc tokenRefresher, limiter rateLimiter, cfg *config.Config) *AuthController { func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config) *AuthController {
return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg} return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg}
} }
// RegisterPage 注册页面(已登录用户重定向到首页,注册关闭时重定向到登录页 // RegisterPage 注册页面(已登录用户重定向到首页)
func (ac *AuthController) RegisterPage(c *gin.Context) { func (ac *AuthController) RegisterPage(c *gin.Context) {
if _, exists := c.Get("uid"); exists { if _, exists := c.Get("uid"); exists {
c.Redirect(http.StatusFound, "/") c.Redirect(http.StatusFound, "/")
@ -38,6 +39,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
"Title": "注册", "Title": "注册",
"ExtraCSS": "/static/css/auth.css", "ExtraCSS": "/static/css/auth.css",
"Guidelines": guidelines, "Guidelines": guidelines,
"RegistrationEnabled": ac.authService.IsRegistrationEnabled(),
})) }))
} }

View File

@ -3,29 +3,29 @@ package controller
import ( import (
"metazone.cc/metalab/internal/middleware" "metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
) )
// authUseCase AuthController 对 AuthService 的最小依赖ISP4 个方法) // authUseCase AuthController 对 AuthService 的最小依赖ISP5 个方法)
// 注意Login/Register/ConfirmRestore 不再返回 JWTsession 由 controller 通过 SessionManager 创建
type authUseCase interface { type authUseCase interface {
Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) Register(req model.RegisterRequest, regIP string) (*model.User, error)
Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) Login(req model.LoginRequest, loginIP string) (*model.User, error)
ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error)
CheckEmail(email string) (bool, error) CheckEmail(email string) (bool, error)
IsRegistrationEnabled() bool
} }
// tokenRefresher AuthController 对 TokenService 的最小依赖ISP1 个方法) // rateLimiter AuthController 对 RateLimiter 的最小依赖ISP4 个方法)
type tokenRefresher interface {
RefreshAccessToken(refreshTokenStr string) (string, *model.User, error)
}
// rateLimiter AuthController 对 RateLimiter 的最小依赖ISP3 个方法)
type rateLimiter interface { type rateLimiter interface {
AllowAccount(email string) (middleware.RateLimitResult, func()) AllowAccount(email string) middleware.RateLimitResult
AllowIP(ip string) (middleware.RateLimitResult, func()) AllowIP(ip string) middleware.RateLimitResult
Clear(email, ip string) Clear(email, ip string)
ClearIP(ipKey string)
} }
// postUseCase PostController 对 PostService 的最小依赖ISP6 个方法) // postUseCase PostController 对 PostService 的最小依赖ISP7 个方法)
type postUseCase interface { type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error) Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error) GetByID(id uint) (*model.Post, error)
@ -33,4 +33,31 @@ type postUseCase interface {
Update(postID uint, title, body string) error Update(postID uint, title, body string) error
Delete(postID uint) error Delete(postID uint) error
SubmitForAudit(postID uint) error SubmitForAudit(postID uint) error
IsPostAccessible(userID uint, role string, postUserID uint) bool
}
// studioUseCase StudioController 对 PostService 的最小依赖ISP8 个方法)
type studioUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
Update(postID uint, title, body string) error
Delete(postID uint) error
SubmitForAudit(postID uint) error
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
GetOverview(userID uint) (*service.StudioOverview, error)
IsPostAccessible(userID uint, role string, postUserID uint) bool
}
// spaceUseCase SpaceController 对 SpaceService 的最小依赖ISP2 个方法)
type spaceUseCase interface {
GetSpaceUser(uid uint) (*model.User, error)
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
}
// sessionManager 登录管理对会话管理的最小依赖ISP4 个方法)
type sessionManager interface {
ListByUID(uid uint) ([]*session.Session, error)
Destroy(sid string) error
DestroyOtherByUID(uid uint, currentSID string) error
UpdateRemark(sid string, uid uint, remark string) error
} }

View File

@ -14,8 +14,7 @@ import (
func (mc *MessageController) MessagesPage(c *gin.Context) { func (mc *MessageController) MessagesPage(c *gin.Context) {
uidVal, exists := c.Get("uid") uidVal, exists := c.Get("uid")
if !exists { if !exists {
c.Redirect(http.StatusFound, "/auth/login") common.RedirectToLogin(c)
c.Abort()
return return
} }
uid := uidVal.(uint) uid := uidVal.(uint)

View File

@ -1,10 +1,14 @@
package controller package controller
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"html/template" "image"
"mime/multipart" "image/jpeg"
"image/png"
_ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画)
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -14,18 +18,21 @@ import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// PostController 帖子控制器SSR 页面 + API // PostController 帖子控制器SSR 页面 + API
type PostController struct { type PostController struct {
postService postUseCase postService postUseCase
shortcodeSvc *service.ShortcodeService
} }
// NewPostController 构造函数 // NewPostController 构造函数
func NewPostController(ps postUseCase) *PostController { func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostController {
return &PostController{postService: ps} return &PostController{postService: ps, shortcodeSvc: scs}
} }
// ListPage 帖子列表页 // ListPage 帖子列表页
@ -43,22 +50,24 @@ func (ctrl *PostController) ListPage(c *gin.Context) {
return return
} }
if posts == nil { totalPages := common.PageCount(total, pageSize)
posts = []model.Post{} prevPage := page - 1
if prevPage < 1 {
prevPage = 1
}
nextPage := page + 1
if nextPage > totalPages {
nextPage = totalPages
} }
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
totalPages := p.TotalPages(total)
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子", "Title": "社区帖子",
"Posts": posts, "Posts": posts,
"Total": total, "Total": total,
"Page": p.Page, "Page": page,
"TotalPages": totalPages, "TotalPages": totalPages,
"PrevPage": p.PrevPage(), "PrevPage": prevPage,
"NextPage": p.NextPage(totalPages), "NextPage": nextPage,
"Keyword": keyword, "Keyword": keyword,
"ExtraCSS": "/static/css/posts.css", "ExtraCSS": "/static/css/posts.css",
})) }))
@ -82,36 +91,44 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
return return
} }
// 获取当前用户信息 uid, role, _ := common.GetGinUser(c)
var uid uint
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态) // 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
if post.Status != model.PostStatusApproved { if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
isAuthor := uid == post.UserID // 未登录用户:引导登录后回到当前帖子
isModerator := model.HasMinRole(role, model.RoleModerator) if uid == 0 {
common.RedirectToLogin(c)
if !isAuthor && !isModerator { return
}
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到", "Title": "未找到",
})) }))
return 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{ c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
"Title": post.Title, "Title": post.Title,
"Post": post, "Post": post,
"PostBodyHTML": template.HTML(post.BodyHTML),
"UID": uid, "UID": uid,
"StatusNames": model.PostStatusDisplayNames, "StatusNames": common.PostStatusDisplayNames,
"ExtraCSS": "/static/css/posts.css", "ExtraCSS": "/static/css/posts.css",
"OgTitle": post.Title,
"OgDescription": post.Excerpt,
"OgImage": ogImage,
"OgURL": ogURL,
})) }))
} }
@ -134,29 +151,27 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
} }
post, err := ctrl.postService.GetByID(uint(id)) post, err := ctrl.postService.GetByID(uint(id))
if err != nil || post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked { if err != nil || post.Status == model.PostStatusPending || post.IsLocked {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到或无法编辑", "Title": "未找到或无法编辑",
})) }))
return return
} }
// 权限检查:仅作者和 moderator+ 可编辑 uid, role, _ := common.GetGinUser(c)
var uid uint if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到", "Title": "未找到",
})) }))
return return
} }
// 已通过帖子有待审修订时,编辑页展示待审内容
if post.PendingBody != "" {
post.Title = post.PendingTitle
post.Body = post.PendingBody
}
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
"Title": "编辑帖子", "Title": "编辑帖子",
"Post": post, "Post": post,
@ -166,8 +181,7 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
// Create API 发帖 // Create API 发帖
func (ctrl *PostController) Create(c *gin.Context) { func (ctrl *PostController) Create(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, _, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -190,8 +204,7 @@ func (ctrl *PostController) Create(c *gin.Context) {
// Update API 编辑帖子 // Update API 编辑帖子
func (ctrl *PostController) Update(c *gin.Context) { func (ctrl *PostController) Update(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, role, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -209,16 +222,7 @@ func (ctrl *PostController) Update(c *gin.Context) {
return return
} }
// 权限检查:取帖子归属 if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
roleObj, _ := c.Get("role")
role, _ := roleObj.(string)
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
common.Error(c, http.StatusForbidden, "无权编辑此帖子")
return return
} }
@ -236,8 +240,7 @@ func (ctrl *PostController) Update(c *gin.Context) {
// Delete API 删除帖子 // Delete API 删除帖子
func (ctrl *PostController) Delete(c *gin.Context) { func (ctrl *PostController) Delete(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, role, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -249,16 +252,7 @@ func (ctrl *PostController) Delete(c *gin.Context) {
return return
} }
post, err := ctrl.postService.GetByID(uint(id)) if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
roleObj, _ := c.Get("role")
role, _ := roleObj.(string)
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
common.Error(c, http.StatusForbidden, "无权删除此帖子")
return return
} }
@ -270,10 +264,24 @@ func (ctrl *PostController) Delete(c *gin.Context) {
common.OkMessage(c, "删除成功") 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 提交审核 // Submit API 提交审核
func (ctrl *PostController) Submit(c *gin.Context) { func (ctrl *PostController) Submit(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, _, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -285,12 +293,12 @@ func (ctrl *PostController) Submit(c *gin.Context) {
return return
} }
// 权限检查:仅帖子作者可提交审核
post, err := ctrl.postService.GetByID(uint(id)) post, err := ctrl.postService.GetByID(uint(id))
if err != nil { if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在") common.Error(c, http.StatusNotFound, "帖子不存在")
return return
} }
if post.UserID != uid { if post.UserID != uid {
common.Error(c, http.StatusForbidden, "无权操作此帖子") common.Error(c, http.StatusForbidden, "无权操作此帖子")
return return
@ -320,18 +328,11 @@ func (ctrl *PostController) ListAPI(c *gin.Context) {
return return
} }
if posts == nil {
posts = []model.Post{}
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
common.Ok(c, model.PostListResult{ common.Ok(c, model.PostListResult{
Items: posts, Items: posts,
Total: total, Total: total,
Page: p.Page, Page: page,
TotalPages: p.TotalPages(total), TotalPages: common.PageCount(total, pageSize),
}) })
} }
@ -351,22 +352,15 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) {
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见 // 权限控制:非 approved 帖子仅作者和 moderator+ 可见
if post.Status != model.PostStatusApproved { if post.Status != model.PostStatusApproved {
var uid uint uid, role, _ := common.GetGinUser(c)
var role string if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
isAuthor := uid == post.UserID
isModerator := model.HasMinRole(role, model.RoleModerator)
if !isAuthor && !isModerator {
common.Error(c, http.StatusNotFound, "帖子不存在") common.Error(c, http.StatusNotFound, "帖子不存在")
return return
} }
} }
ctrl.applyShortcode(post)
common.Ok(c, post) common.Ok(c, post)
} }
@ -376,9 +370,9 @@ var allowedImageExt = map[string]bool{
} }
// UploadImage 上传帖子图片需登录multipart/form-data // UploadImage 上传帖子图片需登录multipart/form-data
// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize
func (ctrl *PostController) UploadImage(c *gin.Context) { func (ctrl *PostController) UploadImage(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, _, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -405,6 +399,13 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
return return
} }
// 读取全部数据(后续 WebP 转换需要)
data, err := io.ReadAll(file)
if err != nil {
common.Error(c, http.StatusInternalServerError, "读取文件失败")
return
}
// 存储路径 // 存储路径
storageDir := "storage/uploads/posts" storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil { if err := os.MkdirAll(storageDir, 0755); err != nil {
@ -412,40 +413,114 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
return return
} }
// 唯一文件名uid_timestamp.ext
ts := time.Now().UnixMilli() ts := time.Now().UnixMilli()
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext) var savePath, url string
savePath := filepath.Join(storageDir, filename)
if err := saveUploadedFile(file, savePath); err != nil { 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, "保存图片失败") common.Error(c, http.StatusInternalServerError, "保存图片失败")
return return
} }
url = "/uploads/posts/" + filename
}
url := "/uploads/posts/" + filename common.VditorUploadOk(c, map[string]string{header.Filename: url})
common.Ok(c, gin.H{"url": url})
} }
// saveUploadedFile 将 multipart.File 写入目标路径 // applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
func saveUploadedFile(file multipart.File, dst string) error { func (ctrl *PostController) applyShortcode(post *model.Post) {
out, err := os.Create(dst) if ctrl.shortcodeSvc != nil {
if err != nil { result := ctrl.shortcodeSvc.Process(post.Body)
return err post.Body = result.ProcessedBody
} }
defer out.Close() }
// 限制读取 5MB 防止内存放大 // encodeWebPBinary 二分查找 WebP 质量,找 ≤ targetBytes 的最高质量quality 1~80
buf := make([]byte, 32*1024) // 返回 nil 表示最低质量仍超限(几乎不可能发生),应由调用方回退原格式
for { func encodeWebPBinary(img image.Image, targetBytes int) []byte {
n, err := file.Read(buf) var buf bytes.Buffer
if n > 0 { if err := webp.Encode(&buf, img, &webp.Options{Quality: 80}); err != nil {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
}
if err != nil {
break
}
}
return nil return nil
}
if buf.Len() <= targetBytes {
return buf.Bytes()
}
low, high := 1, 80
var best []byte
for low <= high {
mid := (low + high) / 2
buf.Reset()
if err := webp.Encode(&buf, img, &webp.Options{Quality: float32(mid)}); err != nil {
return nil
}
if buf.Len() <= targetBytes {
best = make([]byte, buf.Len())
copy(best, buf.Bytes())
low = mid + 1
} else {
high = mid - 1
}
}
return best
} }

View File

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

View File

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

View File

@ -3,9 +3,11 @@ package controller
import ( import (
"io" "io"
"net/http" "net/http"
"time"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -32,30 +34,37 @@ type auditSubmittable interface {
GetPendingTypes(userID uint) ([]string, error) GetPendingTypes(userID uint) ([]string, error)
} }
// sessionConfig 登录管理对配置的最小依赖ISP2 个方法)
type sessionConfig interface {
GetIdleTimeout() int // 分钟
GetRememberTimeout() int // 分钟
}
// SettingsController 个人设置控制器 // SettingsController 个人设置控制器
type SettingsController struct { type SettingsController struct {
authService profileProvider authService profileProvider
avatarService avatarProvider avatarService avatarProvider
auditService auditSubmittable auditService auditSubmittable
sessionMgr sessionManager
sessionCfg sessionConfig
} }
// NewSettingsController 构造函数 // NewSettingsController 构造函数
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController { func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService} return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg}
} }
// SettingsPage 个人设置页面(需登录) // SettingsPage 个人设置页面(需登录)
func (sc *SettingsController) SettingsPage(c *gin.Context) { func (sc *SettingsController) SettingsPage(c *gin.Context) {
uidVal, exists := c.Get("uid") uidVal, exists := c.Get("uid")
if !exists { if !exists {
c.Redirect(http.StatusFound, "/auth/login") common.RedirectToLogin(c)
c.Abort()
return return
} }
uid := uidVal.(uint) uid := uidVal.(uint)
tab := c.Param("tab") tab := c.Param("tab")
if tab != "profile" && tab != "account" { if tab != "profile" && tab != "account" && tab != "sessions" {
c.String(http.StatusNotFound, "页面不存在") c.String(http.StatusNotFound, "页面不存在")
return return
} }
@ -69,7 +78,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
// 查询待审核类型(用于前端显示审核提示) // 查询待审核类型(用于前端显示审核提示)
pendingTypes, _ := sc.auditService.GetPendingTypes(uid) pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{ data := gin.H{
"Title": "个人设置", "Title": "个人设置",
"ExtraCSS": "/static/css/settings.css", "ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab, "ActiveTab": tab,
@ -78,7 +87,45 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
"StatusName": model.StatusDisplayNames[user.Status], "StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes, "PendingTypes": pendingTypes,
"AuditTypes": model.AuditTypeNames, "AuditTypes": model.AuditTypeNames,
})) }
// 登录管理 tab 需要额外数据
if tab == "sessions" {
sessions, _ := sc.sessionMgr.ListByUID(uid)
if sessions == nil {
sessions = []*session.Session{}
}
currentSID, _ := c.Cookie(common.SessionCookieName)
// 解析每个 session 的设备信息
type sessionView struct {
*session.Session
DeviceInfo session.DeviceInfo
IsCurrent bool
TTLMinutes int // 剩余有效分钟数(仅非当前设备显示)
}
var views []sessionView
for _, s := range sessions {
timeout := sc.sessionCfg.GetIdleTimeout() // 临时会话超时(分钟)
if s.RememberMe {
timeout = sc.sessionCfg.GetRememberTimeout() // 记住我超时(分钟)
}
elapsed := int(time.Since(s.LastAccess).Minutes())
ttl := timeout - elapsed
if ttl < 0 {
ttl = 0
}
views = append(views, sessionView{
Session: s,
DeviceInfo: session.ParseUA(s.UserAgent),
IsCurrent: s.ID == currentSID,
TTLMinutes: ttl,
})
}
data["Sessions"] = views
data["CurrentSID"] = currentSID
}
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
} }
// AuditStatus 查询当前用户的待审核类型(需登录) // AuditStatus 查询当前用户的待审核类型(需登录)

View File

@ -0,0 +1,103 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// SpaceController 用户空间控制器
type SpaceController struct {
spaceService spaceUseCase
}
// NewSpaceController 构造函数
func NewSpaceController(spaceService spaceUseCase) *SpaceController {
return &SpaceController{spaceService: spaceService}
}
// MySpace 自己的空间(/space— 需登录,重定向到 /space/{uid}
func (ctrl *SpaceController) MySpace(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.RedirectToLogin(c)
return
}
c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10))
}
// ShowSpace 查看他人或自己的空间(/space/:uid— 无需认证
func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
uidStr := c.Param("uid")
uid, err := strconv.ParseUint(uidStr, 10, 64)
if err != nil {
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
"Title": "用户不存在",
"Error": "用户不存在",
"ExtraCSS": "/static/css/space.css",
}))
return
}
spaceUser, err := ctrl.spaceService.GetSpaceUser(uint(uid))
if err != nil || spaceUser == nil {
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
"Title": "用户不存在",
"Error": "用户不存在",
"ExtraCSS": "/static/css/space.css",
}))
return
}
// 分页
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
}
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
}
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",
}))
}

View File

@ -0,0 +1,368 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// StudioController 创作中心控制器SSR 页面 + API
type StudioController struct {
postService studioUseCase
}
// NewStudioController 构造函数
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",
}))
}
// 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, "已提交审核")
}

View File

@ -1,48 +1,82 @@
package middleware package middleware
import ( import (
"net/http"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验 // AuthMiddleware 认证中间件(结构体模式,持有 SessionManager
type AuthMiddleware struct { type AuthMiddleware struct {
cfg *config.Config cfg *config.Config
userRepo tokenVersionStore sessionManager *session.Manager
} }
// NewAuthMiddleware 构造函数 // NewAuthMiddleware 构造函数
func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMiddleware { func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware {
return &AuthMiddleware{cfg: cfg, userRepo: userRepo} return &AuthMiddleware{cfg: cfg, sessionManager: sm}
} }
// Required 登录认证中间件:校验 JWT → 检查 token_version → 注入用户信息 // Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
// 验证失败 → 清除 cookie → 返回 401
func (am *AuthMiddleware) Required() gin.HandlerFunc { func (am *AuthMiddleware) Required() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
claims, err := am.authenticateToken(c) sid, err := c.Cookie(common.SessionCookieName)
if err != nil { if err != nil || sid == "" {
common.ClearAuthCookies(c, am.cfg) common.ClearSessionCookie(c, am.cfg)
c.AbortWithStatusJSON(401, gin.H{ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false, "message": "登录已过期,请重新登录", "success": false, "message": "登录已过期,请重新登录",
}) })
return return
} }
injectUserContext(c, claims)
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": "登录已过期,请重新登录",
})
return
}
injectSessionContext(c, s)
c.Next() c.Next()
} }
} }
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行 // Optional 可选认证:已登录则注入用户信息,未登录也放行
func (am *AuthMiddleware) Optional() gin.HandlerFunc { func (am *AuthMiddleware) Optional() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
claims, err := am.authenticateToken(c) sid, err := c.Cookie(common.SessionCookieName)
if err != nil { if err != nil || sid == "" {
c.Next() c.Next()
return return
} }
injectUserContext(c, claims)
s, err := am.sessionManager.Validate(sid)
if err != nil || s == nil {
common.ClearSessionCookie(c, am.cfg)
c.Next()
return
}
injectSessionContext(c, s)
c.Next() c.Next()
} }
} }
// injectSessionContext 将会话中的用户信息注入 gin context
func injectSessionContext(c *gin.Context, s *session.Session) {
if s == nil {
return
}
c.Set("uid", s.UserID)
c.Set("email", s.Email)
c.Set("username", s.Username)
c.Set("role", s.Role)
c.Set("sid", s.ID)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,12 +21,14 @@ func NewRateLimiter() *RateLimiter {
return rl return rl
} }
// AllowAccount 检查账户维度是否允许登录尝试 // AllowAccount 原子检查+递增帐户维度。未被封禁则递增失败计数并返回允许。
func (rl *RateLimiter) AllowAccount(email string) (RateLimitResult, func()) { // 调用方在操作成功(如登录成功)时应调用 Clear 清除计数。
return rl.check(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true) func (rl *RateLimiter) AllowAccount(email string) RateLimitResult {
return rl.try(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true)
} }
// AllowIP 检查 IP 维度是否允许登录尝试 // AllowIP 原子检查+递增 IP 维度。未被封禁则递增失败计数并返回允许。
func (rl *RateLimiter) AllowIP(ip string) (RateLimitResult, func()) { // 调用方在操作成功时应调用 Clear 清除计数。
return rl.check(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false) func (rl *RateLimiter) AllowIP(ip string) RateLimitResult {
return rl.try(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
} }

View File

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

View File

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

View File

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

View File

@ -9,16 +9,14 @@ import (
func SecurityHeaders() gin.HandlerFunc { func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Content-Security-Policy // Content-Security-Policy
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js) // script-src/style-src: 本站 + inlinehighlight.js 主题已本地化在 static/vditor/ 下)
// style-src: 本站 + esm.sh (Tiptap CSS) + cdnjs (highlight.js 主题) // img-src: 本站 + data: URI头像裁切+ blob:(粘贴图片)
// connect-src: 本站 + esm.sh (source map 请求)
// img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片)
c.Header("Content-Security-Policy", c.Header("Content-Security-Policy",
"default-src 'self'; "+ "default-src 'self'; "+
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+ "script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+ "style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: blob:; "+ "img-src 'self' data: blob:; "+
"connect-src 'self' https://esm.sh") "connect-src 'self'")
// 禁止 MIME 类型嗅探 // 禁止 MIME 类型嗅探
c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Content-Type-Options", "nosniff")

View File

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

View File

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

View File

@ -4,6 +4,8 @@ package model
const ( const (
NotifyAuditApproved = "audit_approved" NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected" NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected"
) )
// Notification 消息/通知模型 // Notification 消息/通知模型
@ -20,8 +22,10 @@ type Notification struct {
// NotifyTypeNames 通知类型 → 中文名 // NotifyTypeNames 通知类型 → 中文名
var NotifyTypeNames = map[string]string{ var NotifyTypeNames = map[string]string{
NotifyAuditApproved: "系统通知", NotifyAuditApproved: "资料审核",
NotifyAuditRejected: "系统通知", NotifyAuditRejected: "资料审核",
NotifyPostApproved: "稿件审核",
NotifyPostRejected: "稿件审核",
} }
// NotificationListResult 消息列表查询结果 // NotificationListResult 消息列表查询结果

View File

@ -10,10 +10,13 @@ import (
type Post struct { type Post struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"` Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` Body string `gorm:"type:text" json:"body"` // Markdown content
BodyHTML string `gorm:"type:text" json:"body_html"` Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
UserID uint `gorm:"index;not null" json:"user_id"` UserID uint `gorm:"index;not null" json:"user_id"`
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"` Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
IsLocked bool `gorm:"default:false" json:"is_locked"`
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"` RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
AllowComment bool `gorm:"default:true" json:"allow_comment"` AllowComment bool `gorm:"default:true" json:"allow_comment"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@ -34,45 +37,3 @@ const (
PostStatusRejected = "rejected" PostStatusRejected = "rejected"
PostStatusLocked = "locked" PostStatusLocked = "locked"
) )
// PostStatusDisplayNames 状态 → 中文名
var PostStatusDisplayNames = map[string]string{
PostStatusDraft: "草稿",
PostStatusPending: "待审核",
PostStatusApproved: "已发布",
PostStatusRejected: "已退回",
PostStatusLocked: "已锁定",
}
// PostListResult 帖子列表查询结果
type PostListResult struct {
Items []Post `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
}
// PostCreateRequest 发帖请求
type PostCreateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostUpdateRequest 编辑请求
type PostUpdateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostRejectRequest 退回请求
type PostRejectRequest struct {
Reason string `json:"reason" binding:"required,min=1,max=500"`
}
// PostListQuery 列表查询参数
type PostListQuery struct {
Keyword string `form:"keyword"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}

View File

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

View File

@ -14,7 +14,6 @@ type User struct {
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"` Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"` Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
// 安全与审计 // 安全与审计
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP

View File

@ -45,19 +45,58 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
return &post, nil return &post, nil
} }
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题 // buildQuery 构建帖子查询公共部分(联表 + 未删除 + 关键词 + 可选状态过滤
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) { func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
query := r.db.Table("posts"). query := r.db.Table("posts").
Select("posts.*, users.username as author_name"). Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id"). Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL"). Where("posts.deleted_at IS NULL")
Where("posts.status = ?", model.PostStatusApproved)
if keyword != "" { if keyword != "" {
like := "%" + keyword + "%" like := "%" + keyword + "%"
query = query.Where("posts.title LIKE ?", like) query = query.Where("posts.title LIKE ?", like)
} }
if status != "" {
query = query.Where("posts.status = ?", status)
}
return query
}
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题)
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery(keyword, model.PostStatusApproved)
return r.pageResults(query, offset, limit)
}
// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶)
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery(keyword, status)
return r.pageResultsAdmin(query, offset, limit)
}
// FindByUserID 分页查询某用户发布的帖子(仅 approved含作者用户名
func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery("", model.PostStatusApproved).
Where("posts.user_id = ?", userID)
return r.pageResults(query, offset, limit)
}
// FindByUserIDAndStatus 分页查询某用户指定状态的帖子(空 status=全状态)
func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery("", status).
Where("posts.user_id = ?", userID)
return r.pageResults(query, offset, limit)
}
// FindPendingRevisions 查询有待审修订的帖子approved 且 pending_body 不为空)
func (r *PostRepo) FindPendingRevisions(keyword string, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery(keyword, model.PostStatusApproved).
Where("posts.pending_body != ''")
return r.pageResults(query, offset, limit)
}
// pageResults 执行 Count + Offset/Limit + ORDER BY
func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) {
var total int64 var total int64
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
@ -72,28 +111,15 @@ func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post
return posts, total, nil return posts, total, nil
} }
// FindAdminPageable 管理后台分页查询(全状态筛选) // pageResultsAdmin 管理后台分页待审稿件pending 或 修订待审)置顶优先
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) { func (r *PostRepo) pageResultsAdmin(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL")
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("posts.title LIKE ?", like)
}
if status != "" {
query = query.Where("posts.status = ?", status)
}
var total int64 var total int64
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var posts []model.Post var posts []model.Post
err := query.Order("posts.created_at DESC"). err := query.Order(`CASE WHEN posts.status = 'pending' OR (posts.status = 'approved' AND posts.pending_body != '') THEN 0 ELSE 1 END ASC, posts.created_at DESC`).
Offset(offset).Limit(limit).Find(&posts).Error Offset(offset).Limit(limit).Find(&posts).Error
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -113,5 +139,12 @@ func (r *PostRepo) SoftDelete(id uint) error {
// Restore 恢复软删除 // Restore 恢复软删除
func (r *PostRepo) Restore(id uint) error { func (r *PostRepo) Restore(id uint) error {
return r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error result := r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
} }

View File

@ -74,28 +74,11 @@ func (r *UserRepo) ExistsByEmailExclude(email string, excludeUID uint) (bool, er
return count > 0, err return count > 0, err
} }
// IncrementTokenVersion 递增用户令牌版本,使所有已签发的 JWT 即时失效 // FindByIDForAuth 认证专用查询:返回 uid/email/username/role/status
func (r *UserRepo) IncrementTokenVersion(userID uint) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", userID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
}
// FindTokenVersion 按 UID 查询当前令牌版本(轻量查询,仅 SELECT token_version
func (r *UserRepo) FindTokenVersion(userID uint) (int, error) {
var version int
err := r.db.Model(&model.User{}).
Select("token_version").
Where("uid = ?", userID).
Scan(&version).Error
return version, err
}
// FindByIDForAuth 认证专用查询:返回 uid/email/username/role/status/token_version
// 比 FindByID 轻量(不查 avatar、bio 等无用字段),避免全量 SELECT * // 比 FindByID 轻量(不查 avatar、bio 等无用字段),避免全量 SELECT *
// 使用默认 scope排除软删除locked 用户不可持有有效 JWT
func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) { func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
var user model.User var user model.User
err := r.db.Select("uid", "email", "username", "role", "status", "token_version"). err := r.db.Select("uid", "email", "username", "role", "status").
First(&user, userID).Error First(&user, userID).Error
if err != nil { if err != nil {
return nil, err return nil, err
@ -106,7 +89,7 @@ func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
// FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作 // FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作
func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) { func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
var user model.User var user model.User
err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "token_version", "deleted_at"). err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "deleted_at").
First(&user, userID).Error First(&user, userID).Error
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -19,6 +19,11 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
settingsAPI.PUT("/password", d.settingsCtrl.ChangePassword) settingsAPI.PUT("/password", d.settingsCtrl.ChangePassword)
settingsAPI.POST("/delete-account", d.settingsCtrl.DeleteAccount) settingsAPI.POST("/delete-account", d.settingsCtrl.DeleteAccount)
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus) 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)
} }
// --- 消息中心 API需登录 --- // --- 消息中心 API需登录 ---
@ -35,12 +40,12 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
api := r.Group("/api") api := r.Group("/api")
api.Use(middleware.CSRF(cfg)) api.Use(middleware.CSRF(cfg))
{ {
api.POST("/auth/check-email", d.tokenCtrl.CheckEmail) api.POST("/auth/check-email", d.authCtrl.CheckEmail)
api.POST("/auth/register", d.tokenCtrl.Register) api.POST("/auth/register", d.authCtrl.Register)
api.POST("/auth/login", d.tokenCtrl.Login) api.POST("/auth/login", d.authCtrl.Login)
api.POST("/auth/confirm-restore", d.tokenCtrl.ConfirmRestore) api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore)
api.POST("/auth/logout", d.tokenCtrl.Logout) api.POST("/auth/logout", d.authCtrl.Logout)
api.POST("/auth/refresh", d.tokenCtrl.RefreshToken) api.POST("/auth/refresh", d.authCtrl.RefreshToken)
// 帖子公开 API无需登录 // 帖子公开 API无需登录
api.GET("/posts", d.postCtrl.ListAPI) api.GET("/posts", d.postCtrl.ListAPI)
@ -58,4 +63,17 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
postsAPI.POST("/:id/submit", d.postCtrl.Submit) postsAPI.POST("/:id/submit", d.postCtrl.Submit)
postsAPI.POST("/upload-image", d.postCtrl.UploadImage) postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
} }
// --- 创作中心 API需登录 ---
studioAPI := r.Group("/api/studio")
studioAPI.Use(d.authMdw.Required())
studioAPI.Use(middleware.CSRF(cfg))
{
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI)
studioAPI.POST("/posts", d.studioCtrl.Create)
studioAPI.PUT("/posts/:id", d.studioCtrl.Update)
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
}
} }

View File

@ -1,41 +1,70 @@
package router package router
import ( import (
"log"
"time"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/controller" "metazone.cc/metalab/internal/controller"
adminCtrl "metazone.cc/metalab/internal/controller/admin" adminCtrl "metazone.cc/metalab/internal/controller/admin"
"metazone.cc/metalab/internal/middleware" "metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/repository" "metazone.cc/metalab/internal/repository"
"metazone.cc/metalab/internal/service" "metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
) )
type dependencies struct { type dependencies struct {
authCtrl *controller.AuthController authCtrl *controller.AuthController
authMdw *middleware.AuthMiddleware authMdw *middleware.AuthMiddleware
maintenanceMdw *middleware.MaintenanceMiddleware
settingsCtrl *controller.SettingsController settingsCtrl *controller.SettingsController
messageCtrl *controller.MessageController messageCtrl *controller.MessageController
postCtrl *controller.PostController postCtrl *controller.PostController
spaceCtrl *controller.SpaceController
studioCtrl *controller.StudioController
adminPostCtrl *adminCtrl.AdminPostController adminPostCtrl *adminCtrl.AdminPostController
adminCtrl *adminCtrl.AdminController adminCtrl *adminCtrl.AdminController
auditCtrl *adminCtrl.AuditController auditCtrl *adminCtrl.AuditController
siteSettingCtrl *adminCtrl.SiteSettingController siteSettingCtrl *adminCtrl.SiteSettingController
tokenCtrl *controller.AuthController
} }
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) ( func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
*repository.UserRepo, *service.TokenService, *service.AuthService, *repository.UserRepo, *session.Manager, *service.AuthService,
*middleware.RateLimiter, *controller.AuthController, *middleware.RateLimiter, *controller.AuthController,
*service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController, *service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController,
) { ) {
userRepo := repository.NewUserRepo(db) userRepo := repository.NewUserRepo(db)
tokenSvc := service.NewTokenService(cfg, userRepo)
authService := service.NewAuthService(userRepo, tokenSvc, cfg, siteSettings) idleTimeout := time.Duration(cfg.Session.IdleTimeout) * time.Minute
rememberTimeout := time.Duration(cfg.Session.RememberTimeout) * time.Minute
cleanupInterval := time.Duration(cfg.Session.CleanupInterval) * time.Second
// 总是创建 MemoryStore 作为兜底
memStore := session.NewMemoryStore(idleTimeout, rememberTimeout)
memStore.StartCleanup(cleanupInterval)
var sessionStore session.Store
if cfg.Redis.Enabled && redisClient != nil {
// Redis 模式 + 自动降级保护
redisStore := session.NewRedisStore(redisClient, idleTimeout, rememberTimeout)
sessionStore = session.NewFallbackStore(redisStore, memStore, cleanupInterval)
log.Println("[Router] 会话存储: Redis带内存降级保护")
} else {
// 纯内存模式
sessionStore = memStore
log.Println("[Router] 会话存储: 内存")
}
sessionMgr := session.NewManager(sessionStore, userRepo, cfg)
authService := service.NewAuthService(userRepo, sessionMgr, cfg, siteSettings)
rateLimiter := middleware.NewRateLimiter() rateLimiter := middleware.NewRateLimiter()
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg) authCtrl := controller.NewAuthController(authService, sessionMgr, rateLimiter, cfg)
avatarSvc := service.NewAvatarService(userRepo) avatarSvc := service.NewAvatarService(userRepo)
authMdw := middleware.NewAuthMiddleware(cfg, userRepo) authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo)) adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, sessionMgr))
return userRepo, tokenSvc, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController
} }

View File

@ -4,14 +4,16 @@ import (
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/controller" "metazone.cc/metalab/internal/controller"
adminCtrl "metazone.cc/metalab/internal/controller/admin" adminCtrl "metazone.cc/metalab/internal/controller/admin"
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/repository" "metazone.cc/metalab/internal/repository"
"metazone.cc/metalab/internal/service" "metazone.cc/metalab/internal/service"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
) )
func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) *dependencies { func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) *dependencies {
userRepo, _, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings) userRepo, sessionMgr, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings, redisClient)
auditRepo := repository.NewAuditRepo(db) auditRepo := repository.NewAuditRepo(db)
notificationRepo := repository.NewNotificationRepo(db) notificationRepo := repository.NewNotificationRepo(db)
@ -21,7 +23,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
messageController := controller.NewMessageController(notificationService) messageController := controller.NewMessageController(notificationService)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService) settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg)
siteSettingController := adminCtrl.NewSiteSettingController( siteSettingController := adminCtrl.NewSiteSettingController(
service.NewSiteSettingService(siteSettings), service.NewSiteSettingService(siteSettings),
&service.AuditDefaults{ &service.AuditDefaults{
@ -31,14 +33,25 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
) )
postRepo := repository.NewPostRepo(db) postRepo := repository.NewPostRepo(db)
postService := service.NewPostService(postRepo, siteSettings) postService := service.NewPostService(postRepo, siteSettings, notificationService)
postCtrl := controller.NewPostController(postService) shortcodeSvc := service.NewShortcodeService()
postCtrl := controller.NewPostController(postService, shortcodeSvc)
adminPostCtrl := adminCtrl.NewAdminPostController(postService) adminPostCtrl := adminCtrl.NewAdminPostController(postService)
studioCtrl := controller.NewStudioController(postService)
// 用户空间
spaceService := service.NewSpaceService(userRepo, postRepo)
spaceCtrl := controller.NewSpaceController(spaceService)
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
return &dependencies{ return &dependencies{
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl, authCtrl: authCtrl, authMdw: authMdw, maintenanceMdw: maintenanceMdw,
messageCtrl: messageController, postCtrl: postCtrl, adminPostCtrl: adminPostCtrl, settingsCtrl: settingsCtrl,
messageCtrl: messageController, postCtrl: postCtrl, spaceCtrl: spaceCtrl,
studioCtrl: studioCtrl,
adminPostCtrl: adminPostCtrl,
adminCtrl: adminController, auditCtrl: auditController, adminCtrl: adminController, auditCtrl: auditController,
siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl, siteSettingCtrl: siteSettingController,
} }
} }

View File

@ -34,11 +34,26 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage) pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage)
pages.GET("/messages", d.messageCtrl.MessagesPage) pages.GET("/messages", d.messageCtrl.MessagesPage)
// 用户空间(静态 /space 必须在 :uid 之前注册)
pages.GET("/space", d.spaceCtrl.MySpace)
pages.GET("/space/:uid", d.spaceCtrl.ShowSpace)
// 帖子页面 // 帖子页面
pages.GET("/posts", d.postCtrl.ListPage) pages.GET("/posts", d.postCtrl.ListPage)
pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage) pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage)
pages.GET("/posts/:id", d.postCtrl.ShowPage) 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(), d.postCtrl.EditPage)
// 创作中心(需登录)
studioPages := pages.Group("/studio")
studioPages.Use(d.authMdw.Required())
{
studioPages.GET("", d.studioCtrl.OverviewPage)
studioPages.GET("/posts", d.studioCtrl.PostsPage)
studioPages.GET("/drafts", d.studioCtrl.DraftsPage)
studioPages.GET("/write", d.studioCtrl.WritePage)
studioPages.GET("/analytics", d.studioCtrl.AnalyticsPage)
}
} }
// 认证页面(已登录自动跳走) // 认证页面(已登录自动跳走)

View File

@ -5,15 +5,19 @@ import (
"metazone.cc/metalab/internal/middleware" "metazone.cc/metalab/internal/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Setup 注册所有路由 // Setup 注册所有路由
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) { func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) {
r.Use(middleware.SecurityHeaders()) r.Use(middleware.SecurityHeaders())
r.Use(middleware.InjectSiteInfo(siteSettings)) r.Use(middleware.InjectSiteInfo(siteSettings))
deps := buildDeps(db, cfg, siteSettings) deps := buildDeps(db, cfg, siteSettings, redisClient)
// 维护模式中间件(全局,在路由匹配之前)
r.Use(deps.maintenanceMdw.Handler())
setupFrontendRoutes(r, cfg, deps) setupFrontendRoutes(r, cfg, deps)
setupAPIRoutes(r, cfg, deps) setupAPIRoutes(r, cfg, deps)

View File

@ -3,14 +3,17 @@ package service
import ( import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
) )
type AdminService struct { type AdminService struct {
userRepo userAdminStore userRepo userAdminStore
sessionManager *session.Manager
} }
// NewAdminService 构造函数 // NewAdminService 构造函数
func NewAdminService(userRepo userAdminStore) *AdminService { func NewAdminService(userRepo userAdminStore, sm *session.Manager) *AdminService {
return &AdminService{userRepo: userRepo} return &AdminService{userRepo: userRepo, sessionManager: sm}
} }
// ListUsersParams 用户列表查询参数 // ListUsersParams 用户列表查询参数
@ -92,15 +95,15 @@ func (s *AdminService) UpdateUserStatus(operatorUID, targetUID uint, newStatus s
return err return err
} }
} }
// 修改状态必须立刻让 JWT 失效 // 修改状态必须立刻让已有 session 失效
return s.userRepo.IncrementTokenVersion(target.ID) return s.sessionManager.DestroyByUID(target.ID)
}) })
} }
// ResetUserToken 强制下线(递增 token_version // ResetUserToken 强制下线:销毁所有 session
func (s *AdminService) ResetUserToken(operatorUID, targetUID uint) error { func (s *AdminService) ResetUserToken(operatorUID, targetUID uint) error {
return s.checkAndOperate(operatorUID, targetUID, func(_ *model.User, target *model.User) error { return s.checkAndOperate(operatorUID, targetUID, func(_ *model.User, target *model.User) error {
return s.userRepo.IncrementTokenVersion(target.ID) return s.sessionManager.DestroyByUID(target.ID)
}) })
} }

View File

@ -172,7 +172,7 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
return common.ErrUserNotFound return common.ErrUserNotFound
} }
// 5. 标记审核结果 // 4. 标记审核结果
submission.ReviewedBy = &reviewerID submission.ReviewedBy = &reviewerID
submission.ReviewerName = &reviewer.Username submission.ReviewerName = &reviewer.Username
if approved { if approved {

View File

@ -8,6 +8,7 @@ import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
@ -16,14 +17,14 @@ import (
// AuthService 认证业务逻辑 // AuthService 认证业务逻辑
type AuthService struct { type AuthService struct {
userRepo userAuthStore userRepo userAuthStore
tokenService tokenProvider sessionManager *session.Manager
cfg *config.Config cfg *config.Config
siteSettings *config.SiteSettings siteSettings *config.SiteSettings
} }
// NewAuthService 构造函数 // NewAuthService 构造函数
func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.Config, siteSettings *config.SiteSettings) *AuthService { func NewAuthService(userRepo userAuthStore, sm *session.Manager, cfg *config.Config, siteSettings *config.SiteSettings) *AuthService {
return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg, siteSettings: siteSettings} return &AuthService{userRepo: userRepo, sessionManager: sm, cfg: cfg, siteSettings: siteSettings}
} }
var pwLetter = regexp.MustCompile(`[a-zA-Z]`) var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
@ -33,7 +34,6 @@ var pwDigit = regexp.MustCompile(`\d`)
var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`) var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`)
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对 // dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
// 在 init() 中按当前 bcrypt 成本生成,确保时序与真实校验一致
var dummyHash []byte var dummyHash []byte
func init() { func init() {
@ -52,43 +52,50 @@ func validatePassword(pw string) error {
return nil return nil
} }
// IsRegistrationEnabled 检查是否允许新用户注册
func (s *AuthService) IsRegistrationEnabled() bool {
return s.siteSettings.IsRegistrationEnabled()
}
// Register 注册 // Register 注册
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT // 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → 返回 usersession 由 controller 创建)
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除 func (s *AuthService) Register(req model.RegisterRequest, regIP string) (*model.User, error) {
// regIP: 注册 IP 地址 // 0. 维护期间禁止注册
func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) { if s.siteSettings.IsMaintenanceEnabled() {
// 0. 检查注册开关 return nil, common.ErrMaintenanceMode
}
// 1. 检查注册开关
if !s.siteSettings.IsRegistrationEnabled() { if !s.siteSettings.IsRegistrationEnabled() {
return "", "", nil, common.ErrRegistrationDisabled return nil, common.ErrRegistrationDisabled
} }
// 1. 密码强度 // 2. 密码强度
if err := validatePassword(req.Password); err != nil { if err := validatePassword(req.Password); err != nil {
return "", "", nil, err return nil, err
} }
// 2. 检查邮箱 // 3. 检查邮箱
exists, err := s.userRepo.ExistsByEmail(req.Email) exists, err := s.userRepo.ExistsByEmail(req.Email)
if err != nil { if err != nil {
return "", "", nil, err return nil, err
} }
if exists { if exists {
return "", "", nil, common.ErrEmailExists return nil, common.ErrEmailExists
} }
// 3. 哈希密码 // 4. 哈希密码
hash, err := common.HashPassword(req.Password, s.cfg.Bcrypt.Cost) hash, err := common.HashPassword(req.Password, s.cfg.Bcrypt.Cost)
if err != nil { if err != nil {
return "", "", nil, err return nil, err
} }
// 4. 生成唯一用户名 // 5. 生成唯一用户名
username, err := common.GenerateUsername(s.userRepo, 20) username, err := common.GenerateUsername(s.userRepo, 20)
if err != nil { if err != nil {
return "", "", nil, err return nil, err
} }
// 5. 创建用户 // 6. 创建用户
user := &model.User{ user := &model.User{
Email: req.Email, Email: req.Email,
PasswordHash: hash, PasswordHash: hash,
@ -98,59 +105,57 @@ func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string,
RegIP: regIP, RegIP: regIP,
} }
if err := s.userRepo.Create(user); err != nil { if err := s.userRepo.Create(user); err != nil {
return "", "", nil, err return nil, err
} }
// 6. 生成 access JWT return user, nil
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
return "", "", nil, err
}
// 7. 生成 refresh JWT不勾选"记住我"也用 session cookie关浏览器即清除
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
} }
// Login 登录 // Login 登录
// 流程:按邮箱查找 → 已注销则验证密码后要求二次确认 → 检查状态 → 验证密码 → 记录登录 IP/时间 → access JWT + refresh JWT // 流程:按邮箱查找 → 维护期间非站长统一拦截 → 状态检查 → 验证密码 → 记录登录信息 → 返回 user
// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对 func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User, error) {
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除
// loginIP: 登录 IP 地址
// 若用户处于 deleted 状态且密码正确 → 返回 ErrNeedsConfirmRestore不自动恢复不签发 token
func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email) user, err := s.userRepo.FindByEmail(req.Email)
// 维护期间:非站长一律返回统一提示(模拟 bcrypt 防时序攻击)
if s.siteSettings.IsMaintenanceEnabled() {
if err != nil { if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrInvalidCred return nil, common.ErrMaintenanceMode
}
if !model.HasMinRole(user.Role, model.RoleOwner) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrMaintenanceMode
}
} }
// 已注销deleted→ 验证密码后要求二次确认,不自动恢复 if err != nil {
if user.Status == model.StatusDeleted {
if !common.CheckPassword(req.Password, user.PasswordHash) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrInvalidCred return nil, common.ErrInvalidCred
} }
return "", "", nil, common.ErrNeedsConfirmRestore
// 已注销deleted→ 验证密码后要求二次确认
if user.Status == model.StatusDeleted {
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrInvalidCred
}
return nil, common.ErrNeedsConfirmRestore
} }
// 永久锁定 // 永久锁定
if user.Status == model.StatusLocked { if user.Status == model.StatusLocked {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrUserLocked return nil, common.ErrUserLocked
} }
// 封禁 // 封禁
if user.Status == model.StatusBanned { if user.Status == model.StatusBanned {
return "", "", nil, common.ErrUserBanned _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return nil, common.ErrUserBanned
} }
if !common.CheckPassword(req.Password, user.PasswordHash) { if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
return "", "", nil, common.ErrInvalidCred return nil, common.ErrInvalidCred
} }
// 记录登录 IP 和时间 // 记录登录 IP 和时间
@ -158,40 +163,26 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, str
user.LastLoginIP = loginIP user.LastLoginIP = loginIP
user.LastLoginAt = &now user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil { if err := s.userRepo.Update(user); err != nil {
return "", "", nil, err return nil, err
} }
// 生成 access JWT return user, nil
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
return "", "", nil, err
}
// 生成 refresh JWT不勾选"记住我"也生成Cookie 用 session 模式)
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
} }
// ConfirmRestore 二次确认恢复已注销账号 // ConfirmRestore 二次确认恢复已注销账号
// 流程:按邮箱查找 → 验证密码 → 恢复为 active → 记录登录 IP/时间 → 签发 token func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email) user, err := s.userRepo.FindByEmail(req.Email)
if err != nil { if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrInvalidCred return nil, common.ErrInvalidCred
} }
// 仅 deleted 状态允许恢复
if user.Status != model.StatusDeleted { if user.Status != model.StatusDeleted {
return "", "", nil, common.ErrUserNotFound return nil, common.ErrUserNotFound
} }
if !common.CheckPassword(req.Password, user.PasswordHash) { if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
return "", "", nil, common.ErrInvalidCred return nil, common.ErrInvalidCred
} }
// 恢复账号 // 恢复账号
@ -201,51 +192,38 @@ func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (st
user.LastLoginIP = loginIP user.LastLoginIP = loginIP
user.LastLoginAt = &now user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil { if err := s.userRepo.Update(user); err != nil {
return "", "", nil, err return nil, err
} }
accessToken, err := s.tokenService.BuildAccessToken(user) return user, nil
if err != nil {
return "", "", nil, err
}
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
} }
// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查) // CheckEmail 检查邮箱是否已被注册
func (s *AuthService) CheckEmail(email string) (bool, error) { func (s *AuthService) CheckEmail(email string) (bool, error) {
return s.userRepo.ExistsByEmail(email) return s.userRepo.ExistsByEmail(email)
} }
// GetProfile 获取当前用户资料(供 SettingsController 使用) // GetProfile 获取当前用户资料
func (s *AuthService) GetProfile(userID uint) (*model.User, error) { func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
return s.userRepo.FindByID(userID) return s.userRepo.FindByID(userID)
} }
// ChangePassword 修改密码 // ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 吊销所有 JWT(强制重新登录) // 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录)
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error { func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
user, err := s.userRepo.FindByID(userID) user, err := s.userRepo.FindByID(userID)
if err != nil { if err != nil {
return common.ErrUserNotFound return common.ErrUserNotFound
} }
// 验证当前密码 if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
if !common.CheckPassword(currentPassword, user.PasswordHash) {
return common.ErrIncorrectPassword return common.ErrIncorrectPassword
} }
// 新密码强度校验
if err := validatePassword(newPassword); err != nil { if err := validatePassword(newPassword); err != nil {
return err return err
} }
// 哈希新密码
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost) hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
if err != nil { if err != nil {
return err return err
@ -256,25 +234,22 @@ func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword s
return err return err
} }
// 强制所有设备重新登录(安全性) // 删除所有 session强制所有设备重新登录
return s.InvalidateSessions(userID) return s.invalidateSessions(userID)
} }
// DeleteAccount 用户自主注销 // DeleteAccount 用户自主注销
// 流程:检查角色 → 验证密码 → 记录原因 → 设置 deleted 状态 → 吊销所有 JWT → 7 天冷却期内登录需二次确认恢复
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error { func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
user, err := s.userRepo.FindByID(userID) user, err := s.userRepo.FindByID(userID)
if err != nil { if err != nil {
return common.ErrUserNotFound return common.ErrUserNotFound
} }
// 站长不允许自主注销(避免权限体系死锁)
if user.Role == model.RoleOwner { if user.Role == model.RoleOwner {
return common.ErrOwnerCannotDelete return common.ErrOwnerCannotDelete
} }
// 验证当前密码(防止 CSRF 或未授权操作) if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
if !common.CheckPassword(password, user.PasswordHash) {
return common.ErrIncorrectPassword return common.ErrIncorrectPassword
} }
@ -285,22 +260,21 @@ func (s *AuthService) DeleteAccount(userID uint, password, reason string) error
return err return err
} }
// 递增 token_version即时吊销所有 JWT 强制退登 return s.invalidateSessions(userID)
return s.InvalidateSessions(userID)
} }
// InvalidateSessions 销某用户所有 JWT递增 token_version强制所有设备重新登录 // invalidateSessions 销某用户所有 session内部方法
// 适用场景:修改密码、账号被盗、管理员强制下线 func (s *AuthService) invalidateSessions(userID uint) error {
return s.sessionManager.DestroyByUID(userID)
}
// InvalidateSessions 公开方法:销毁用户所有 session供 admin 等外部调用)
func (s *AuthService) InvalidateSessions(userID uint) error { func (s *AuthService) InvalidateSessions(userID uint) error {
return s.userRepo.IncrementTokenVersion(userID) return s.sessionManager.DestroyByUID(userID)
} }
// UpdateProfile 修改个人资料(用户名 + 个性签名) // UpdateProfile 修改个人资料
// 用户名校验非空、1-16 字符、白名单、去重
// 个性签名校验0-128 字符纯文本Go 模板自动 HTML 转义防 XSS
// 任一字段无变更时跳过该字段的写库操作
func (s *AuthService) UpdateProfile(userID uint, username, bio string) error { func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
// 查当前用户
user, err := s.userRepo.FindByID(userID) user, err := s.userRepo.FindByID(userID)
if err != nil { if err != nil {
return common.ErrUserNotFound return common.ErrUserNotFound
@ -308,7 +282,6 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
needsUpdate := false needsUpdate := false
// 用户名:校验 + 更新
if n := utf8.RuneCountInString(username); n == 0 { if n := utf8.RuneCountInString(username); n == 0 {
return common.ErrUsernameInvalid return common.ErrUsernameInvalid
} else if n > 16 { } else if n > 16 {
@ -329,7 +302,6 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
needsUpdate = true needsUpdate = true
} }
// 个性签名:长度校验 + 更新
if utf8.RuneCountInString(bio) > 128 { if utf8.RuneCountInString(bio) > 128 {
return common.ErrBioTooLong return common.ErrBioTooLong
} }

View File

@ -26,7 +26,7 @@ const (
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升 avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
avatarMinHeight = 128 avatarMinHeight = 128
avatarMaxWidth = 3840 avatarMaxWidth = 3840
avatarMaxHeight = 2160 avatarMaxHeight = 3840
avatarOutputSize = 512 avatarOutputSize = 512
avatarTargetKB = 100 avatarTargetKB = 100
avatarStorageDir = "storage/uploads/avatars" avatarStorageDir = "storage/uploads/avatars"
@ -98,9 +98,7 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
} }
bounds := img.Bounds() bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy() w, h := bounds.Dx(), bounds.Dy()
if w > avatarMaxWidth || h > avatarMaxHeight { // 原图只检查最小尺寸,确保有足够像素供上采样
return "", fmt.Errorf("图片分辨率不能超过 3840x2160")
}
if w < avatarMinWidth || h < avatarMinHeight { if w < avatarMinWidth || h < avatarMinHeight {
return "", fmt.Errorf("图片分辨率不能低于 128x128") return "", fmt.Errorf("图片分辨率不能低于 128x128")
} }
@ -114,6 +112,13 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
cropped = centerCrop(img) cropped = centerCrop(img)
} }
// 裁切后检查最大尺寸(用户实际选区不应超过此限制)
cb := cropped.Bounds()
cw, ch := cb.Dx(), cb.Dy()
if cw > avatarMaxWidth || ch > avatarMaxHeight {
return "", fmt.Errorf("裁切区域分辨率不能超过 3840x3840")
}
// 5. 缩放至 512x512CatmullRom 高质量) // 5. 缩放至 512x512CatmullRom 高质量)
resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize)) resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize))
draw.CatmullRom.Scale(resized, resized.Bounds(), cropped, cropped.Bounds(), draw.Over, nil) draw.CatmullRom.Scale(resized, resized.Bounds(), cropped, cropped.Bounds(), draw.Over, nil)

View File

@ -110,3 +110,20 @@ func (s *NotificationService) NotifyAuditRejected(userID uint, auditType, reason
content, content,
&relatedID) &relatedID)
} }
// NotifyPostApproved 稿件审核通过通知
func (s *NotificationService) NotifyPostApproved(userID uint, postTitle string, postID uint) {
title := "稿件审核通过"
content := "你的稿件《" + postTitle + "》已通过审核,现已公开发布"
_ = s.Create(userID, model.NotifyPostApproved, title, content, &postID)
}
// NotifyPostRejected 稿件审核拒绝通知
func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason string, postID uint) {
title := "稿件审核未通过"
content := "你的稿件《" + postTitle + "》未通过审核"
if reason != "" {
content += ",理由:" + reason
}
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID)
}

View File

@ -4,40 +4,88 @@ import (
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"strings"
"unicode/utf8"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"github.com/microcosm-cc/bluemonday"
"gorm.io/gorm" "gorm.io/gorm"
) )
// sanitizePolicy Tiptap 输出 HTML 的消毒策略 // mdPatterns Markdown 语法正则(用于生成纯文本摘要)
// 前端 WYSIWYG 编辑器输出 HTML由 bluemonday UGC 策略消毒 var mdPatterns = []*regexp.Regexp{
// 放行 code/pre 的 class/data-language 属性以保留代码高亮和语言标签 // 代码块 ```...```
// 放行 span 元素及其 hljs-* class 以保留 highlight.js 语法高亮 regexp.MustCompile("(?s)```[^`]*```"),
// 放行 img 的 src/alt/title 属性以保留图片 // 行内代码 `...`
var sanitizePolicy = func() *bluemonday.Policy { regexp.MustCompile("`[^`]+`"),
p := bluemonday.UGCPolicy() // 图片 ![alt](url)
p.AllowElements("span") regexp.MustCompile(`!\[.*?\]\(.*?\)`),
p.AllowAttrs("class", "data-language").OnElements("code", "pre") // 链接 [text](url)
p.AllowAttrs("class").Matching(regexp.MustCompile("^hljs-")).OnElements("span") regexp.MustCompile(`\[([^\]]*)\]\(.*?\)`),
p.AllowAttrs("src", "alt", "title").OnElements("img") // 标题标记 #
return p regexp.MustCompile(`^#{1,6}\s+`),
}() // 粗体/斜体/删除线标记
regexp.MustCompile(`\*{1,3}|_{1,3}|~~`),
// 引用 >
regexp.MustCompile(`^>\s?`),
// 列表标记
regexp.MustCompile(`^[\s]*[-*+]\s+`),
regexp.MustCompile(`^[\s]*\d+\.\s+`),
// 分割线
regexp.MustCompile(`^[-*_]{3,}\s*$`),
}
// generateExcerpt 从 Markdown 生成纯文本摘要(最多 300 字符)
// MD 作为纯文本存储无 XSS 风险,前端由 Vditor 渲染
func generateExcerpt(md string) string {
text := md
// 按行处理,保留行结构
lines := strings.Split(text, "\n")
var cleaned []string
for _, line := range lines {
cleanedLine := line
for _, re := range mdPatterns {
if re == mdPatterns[2] {
// 链接保留文字: [text](url) → text
cleanedLine = re.ReplaceAllString(cleanedLine, "$1")
} else {
cleanedLine = re.ReplaceAllString(cleanedLine, "")
}
}
cleanedLine = strings.TrimSpace(cleanedLine)
if cleanedLine != "" {
cleaned = append(cleaned, cleanedLine)
}
}
result := strings.Join(cleaned, " ")
// 按 rune 截断,不切中文字符
const maxChars = 300
if utf8.RuneCountInString(result) > maxChars {
runes := []rune(result)
result = string(runes[:maxChars]) + "..."
}
return result
}
// PostService 帖子业务逻辑 // PostService 帖子业务逻辑
type PostService struct { type PostService struct {
repo postStore repo postStore
ss *config.SiteSettings ss *config.SiteSettings
notifier postNotifier
} }
// NewPostService 构造函数 // NewPostService 构造函数
func NewPostService(repo postStore, ss *config.SiteSettings) *PostService { func NewPostService(repo postStore, ss *config.SiteSettings, notifier postNotifier) *PostService {
return &PostService{ return &PostService{
repo: repo, repo: repo,
ss: ss, ss: ss,
notifier: notifier,
} }
} }
@ -70,16 +118,10 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
return post, nil return post, nil
} }
// sanitizeHTML 对 Tiptap 输出的 HTML 进行消毒
// 前端 WYSIWYG 编辑器直接输出 HTML后端仅做安全消毒
func (s *PostService) sanitizeHTML(body string) string {
return sanitizePolicy.Sanitize(body)
}
// Create 创建帖子 // Create 创建帖子
// body 为 Vditor 输出的 Markdown后端直接存储不做 HTML 消毒
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) { func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
bodyHTML := s.sanitizeHTML(body)
status := model.PostStatusApproved status := model.PostStatusApproved
if s.auditEnabled() { if s.auditEnabled() {
status = model.PostStatusDraft status = model.PostStatusDraft
@ -88,7 +130,7 @@ func (s *PostService) Create(userID uint, title, body string) (*model.Post, erro
post := &model.Post{ post := &model.Post{
Title: title, Title: title,
Body: body, Body: body,
BodyHTML: bodyHTML, Excerpt: generateExcerpt(body),
UserID: userID, UserID: userID,
Status: status, Status: status,
AllowComment: true, AllowComment: true,
@ -104,18 +146,66 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
return s.findPostWithAuthor(id) return s.findPostWithAuthor(id)
} }
// List 公开帖子列表(仅 approved // listPageable 分页查询公共逻辑:参数规范化 + nil 转空切片
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) { func (s *PostService) listPageable(page, pageSize int, fn func(offset, limit int) ([]model.Post, int64, error)) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize} p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination() p.DefaultPagination()
return s.repo.FindPageable(keyword, p.Offset(), p.PageSize) posts, total, err := fn(p.Offset(), p.PageSize)
if posts == nil {
posts = []model.Post{}
}
return posts, total, err
}
// List 公开帖子列表(仅 approved
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
return s.repo.FindPageable(keyword, offset, limit)
})
} }
// ListAdmin 管理后台帖子列表(全状态) // ListAdmin 管理后台帖子列表(全状态)
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) { func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize} return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
p.DefaultPagination() return s.repo.FindAdminPageable(keyword, status, offset, limit)
return s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize) })
}
// ListByUser 查询某用户指定状态的帖子(空 status=全状态)
func (s *PostService) ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error) {
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
return s.repo.FindByUserIDAndStatus(userID, status, offset, limit)
})
}
// StudioOverview 创作中心数据概览
type StudioOverview struct {
TotalPosts int64 `json:"total_posts"`
DraftCount int64 `json:"draft_count"`
PendingCount int64 `json:"pending_count"`
ApprovedCount int64 `json:"approved_count"`
RejectedCount int64 `json:"rejected_count"`
}
// GetOverview 获取创作中心数据概览
func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) {
// 获取各状态文章数量(分页传 1 条只取 total
_, totalPosts, err := s.repo.FindByUserIDAndStatus(userID, "", 0, 1)
if err != nil {
return nil, err
}
_, draftCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusDraft, 0, 1)
_, pendingCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusPending, 0, 1)
_, approvedCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusApproved, 0, 1)
_, rejectedCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusRejected, 0, 1)
return &StudioOverview{
TotalPosts: totalPosts,
DraftCount: draftCount,
PendingCount: pendingCount,
ApprovedCount: approvedCount,
RejectedCount: rejectedCount,
}, nil
} }
// Update 编辑帖子(权限在 controller 层检查) // Update 编辑帖子(权限在 controller 层检查)
@ -125,15 +215,23 @@ func (s *PostService) Update(postID uint, title, body string) error {
return err return err
} }
if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked { if post.Status == model.PostStatusPending || post.IsLocked {
return common.ErrPostCannotEdit return common.ErrPostCannotEdit
} }
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
if post.Status == model.PostStatusApproved {
post.PendingTitle = title
post.PendingBody = body
post.RejectReason = "" // 清空旧退回理由
return s.repo.Update(post)
}
// 草稿/已退回:直接修改原文(现有逻辑)
post.Title = title post.Title = title
post.Body = body post.Body = body
post.BodyHTML = s.sanitizeHTML(body) post.Excerpt = generateExcerpt(body)
// rejected 状态编辑后自动重置为 draft
if post.Status == model.PostStatusRejected { if post.Status == model.PostStatusRejected {
post.Status = model.PostStatusDraft post.Status = model.PostStatusDraft
post.RejectReason = "" post.RejectReason = ""
@ -160,21 +258,51 @@ func (s *PostService) SubmitForAudit(postID uint) error {
return s.repo.Update(post) return s.repo.Update(post)
} }
// Approve 审核通过pending → approved // Approve 审核通过pending → approved;或修订审核通过复制 Pending 到正式字段
func (s *PostService) Approve(postID uint) error { func (s *PostService) Approve(postID uint) error {
post, err := s.findPost(postID) post, err := s.findPost(postID)
if err != nil { if err != nil {
return err return err
} }
wasRevision := post.PendingBody != ""
// 修订审核:将待审内容覆盖到正式字段
if wasRevision {
post.Title = post.PendingTitle
post.Body = post.PendingBody
post.Excerpt = generateExcerpt(post.PendingBody)
post.PendingTitle = ""
post.PendingBody = ""
post.Status = model.PostStatusApproved
post.RejectReason = ""
if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者修订审核通过
if s.notifier != nil {
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
}
return nil
}
// 首次审核通过
if post.Status != model.PostStatusPending { if post.Status != model.PostStatusPending {
return common.ErrPostCannotApprove return common.ErrPostCannotApprove
} }
post.Status = model.PostStatusApproved post.Status = model.PostStatusApproved
post.RejectReason = "" post.RejectReason = ""
return s.repo.Update(post) if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者审核通过
if s.notifier != nil {
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
}
return nil
} }
// Reject 退回pending/approved → rejected // Reject 退回pending/approved → rejected;修订退回则清空 Pending 保持 approved
func (s *PostService) Reject(postID uint, reason string) error { func (s *PostService) Reject(postID uint, reason string) error {
post, err := s.findPost(postID) post, err := s.findPost(postID)
if err != nil { if err != nil {
@ -183,35 +311,82 @@ func (s *PostService) Reject(postID uint, reason string) error {
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved { if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
return common.ErrPostCannotReject return common.ErrPostCannotReject
} }
// 修订退回:清空待审修订,保持已发布内容不变
if post.PendingBody != "" {
post.PendingTitle = ""
post.PendingBody = ""
post.RejectReason = reason
if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者修订被退回
if s.notifier != nil {
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
}
return nil
}
// 普通退回(首次审核)
post.Status = model.PostStatusRejected post.Status = model.PostStatusRejected
post.RejectReason = reason post.RejectReason = reason
return s.repo.Update(post) if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者审核被退回
if s.notifier != nil {
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
}
return nil
} }
// Lock 锁定:任意状态 → locked管理员可随时锁定 // Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
func (s *PostService) Lock(postID uint) error { func (s *PostService) Lock(postID uint) error {
post, err := s.findPost(postID) post, err := s.findPost(postID)
if err != nil { if err != nil {
return err return err
} }
post.Status = model.PostStatusLocked // 待审核帖子不允许锁定(审核流程中)
if post.Status == model.PostStatusPending {
return common.ErrPostCannotLock
}
post.IsLocked = true
return s.repo.Update(post) return s.repo.Update(post)
} }
// Unlock 解锁:locked → 草稿 // Unlock 解锁:清除 IsLocked 标志,恢复可编辑
func (s *PostService) Unlock(postID uint) error { func (s *PostService) Unlock(postID uint) error {
post, err := s.findPost(postID) post, err := s.findPost(postID)
if err != nil { if err != nil {
return err return err
} }
if post.Status != model.PostStatusLocked { if !post.IsLocked {
return common.ErrPostCannotUnlock return common.ErrPostCannotUnlock
} }
post.Status = model.PostStatusDraft post.IsLocked = false
return s.repo.Update(post) return s.repo.Update(post)
} }
// Restore 恢复软删除 // Restore 恢复软删除
func (s *PostService) Restore(postID uint) error { func (s *PostService) Restore(postID uint) error {
return s.repo.Restore(postID) err := s.repo.Restore(postID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrPostNotFound
}
return err
}
return nil
}
// ListPendingRevisions 查询有待审修订的帖子approved 且 PendingBody 不为空)
func (s *PostService) ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error) {
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
return s.repo.FindPendingRevisions(keyword, offset, limit)
})
}
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
} }

View File

@ -1,9 +0,0 @@
package service
import "metazone.cc/metalab/internal/model"
// tokenProvider AuthService 对 TokenService 的最小依赖ISP2 个方法)
type tokenProvider interface {
BuildAccessToken(user *model.User) (string, error)
BuildRefreshToken(user *model.User, rememberMe bool) (string, error)
}

View File

@ -3,23 +3,16 @@ package service
import "metazone.cc/metalab/internal/model" import "metazone.cc/metalab/internal/model"
// userAuthStore AuthService 所需的最小仓储接口ISP7 个方法) // userAuthStore AuthService 所需的最小仓储接口ISP7 个方法)
// 同时满足 common.UsernameCheckerExistsByUsername可传入 GenerateUsername
type userAuthStore interface { type userAuthStore interface {
ExistsByEmail(email string) (bool, error) ExistsByEmail(email string) (bool, error)
Create(user *model.User) error Create(user *model.User) error
FindByEmail(email string) (*model.User, error) FindByEmail(email string) (*model.User, error)
FindByID(id uint) (*model.User, error) FindByID(id uint) (*model.User, error)
Update(user *model.User) error Update(user *model.User) error
IncrementTokenVersion(userID uint) error
ExistsByUsername(username string) (bool, error) ExistsByUsername(username string) (bool, error)
} }
// userTokenStore TokenService 所需的最小仓储接口ISP1 个方法) // userAdminStore AdminService 所需的最小仓储接口ISP7 个方法)
type userTokenStore interface {
FindByIDForAuth(userID uint) (*model.User, error)
}
// userAdminStore AdminService 所需的最小仓储接口ISP8 个方法)
type userAdminStore interface { type userAdminStore interface {
CountSearchUsers(keyword, role, status string) (int64, error) CountSearchUsers(keyword, role, status string) (int64, error)
SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error)
@ -28,17 +21,34 @@ type userAdminStore interface {
SoftDelete(uid uint) error SoftDelete(uid uint) error
Restore(uid uint) error Restore(uid uint) error
ExistsByEmailExclude(email string, excludeUID uint) (bool, error) ExistsByEmailExclude(email string, excludeUID uint) (bool, error)
IncrementTokenVersion(userID uint) error
} }
// postStore PostService 所需的最小仓储接口ISP8 个方法) // postStore PostService 所需的最小仓储接口
type postStore interface { type postStore interface {
Create(post *model.Post) error Create(post *model.Post) error
FindByID(id uint) (*model.Post, error) FindByID(id uint) (*model.Post, error)
FindByIDWithAuthor(id uint) (*model.Post, error) FindByIDWithAuthor(id uint) (*model.Post, error)
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error)
FindPendingRevisions(keyword string, offset, limit int) ([]model.Post, int64, error)
Update(post *model.Post) error Update(post *model.Post) error
SoftDelete(id uint) error SoftDelete(id uint) error
Restore(id uint) error Restore(id uint) error
} }
// spaceUserStore SpaceService 所需的最小用户仓储接口
type spaceUserStore interface {
FindByID(id uint) (*model.User, error)
}
// spacePostStore SpaceService 所需的最小帖子仓储接口
type spacePostStore interface {
FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error)
}
// postNotifier PostService 所需的通知服务接口
type postNotifier interface {
NotifyPostApproved(userID uint, postTitle string, postID uint)
NotifyPostRejected(userID uint, postTitle, reason string, postID uint)
}

View File

@ -0,0 +1,147 @@
package service
import (
"fmt"
"html"
"strings"
"metazone.cc/metalab/internal/model"
)
// =============================================================================
// ShortcodeService — Markdown shortcode 解析与渲染
//
// 职责:
// 1. 从 Markdown body 中提取所有 [zone:type:params] 语法
// 2. 将合法的 shortcode 替换为前端可识别的占位 HTML<div class="zone-card">
// 3. 未注册的 shortcode 保持不变(不报错,避免破坏用户输入)
//
// 扩展新类型:
// 1. 在 model/shortcode.go 中注册新 ShortcodeType 常量
// 2. 在本文件的 renderPlaceholder() 中添加 case 分支
// 3. 在 templates/.../shortcode.js 中添加前端卡片渲染逻辑
// =============================================================================
// ShortcodeService 无状态,纯函数集合
type ShortcodeService struct{}
// NewShortcodeService 构造函数
func NewShortcodeService() *ShortcodeService {
return &ShortcodeService{}
}
// =============================================================================
// 主入口:解析 + 替换
// =============================================================================
// Process 从 MD body 中提取 shortcode 并替换为占位 HTML
// 返回的 ProcessedBody 可直接传给模板渲染Go template 会自动对 HTML 实体转义,
// 因此占位 div 需要用 template.HTML 类型标记为安全)
func (s *ShortcodeService) Process(body string) model.ShortcodeResult {
result := model.ShortcodeResult{
ProcessedBody: body,
}
// 一次遍历替换所有合法的 shortcode
result.ProcessedBody = model.ShortcodePattern.ReplaceAllStringFunc(body, func(match string) string {
sc, ok := parseShortcode(match)
if !ok {
return match // 格式不合法,保留原文
}
if !model.IsValidType(string(sc.Type)) {
return match // 类型未注册,保留原文
}
// 生成占位 HTML
placeholder := renderPlaceholder(sc)
if placeholder == "" {
return match
}
result.Shortcodes = append(result.Shortcodes, sc)
return placeholder
})
return result
}
// =============================================================================
// 解析
// =============================================================================
// parseShortcode 从 "[zone:type:params]" 字符串中解析出 Shortcode
// 返回 false 表示格式不合法
func parseShortcode(raw string) (model.Shortcode, bool) {
matches := model.ShortcodePattern.FindStringSubmatch(raw)
if len(matches) != 3 {
return model.Shortcode{}, false
}
sc := model.Shortcode{
Raw: raw,
Type: model.ShortcodeType(matches[1]),
Params: strings.TrimSpace(matches[2]),
}
if sc.Params == "" {
return model.Shortcode{}, false
}
return sc, true
}
// =============================================================================
// 占位 HTML 渲染器(每个类型一个 case
// =============================================================================
// renderPlaceholder 根据 shortcode 类型生成前端占位 HTML
//
// 占位 div 结构约定:
// <div class="zone-card" data-zone-type="类型" data-zone-id="参数"
// data-zone-extra="附加信息">
// <span class="zone-card-loading">卡片加载中...</span>
// </div>
//
// 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。
// "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。
func renderPlaceholder(sc model.Shortcode) string {
switch sc.Type {
case model.ShortcodeEvent:
// [zone:event:活动ID]
// 前端根据活动ID获取标题、时间、封面等信息
return fmt.Sprintf(
`<div class="zone-card" data-zone-type="event" data-zone-id="%s"><span class="zone-card-loading">活动卡片加载中...</span></div>`,
html.EscapeString(sc.Params),
)
case model.ShortcodeGame:
// [zone:game:游戏slug]
// 前端根据游戏slug获取名称、封面、标签等信息
return fmt.Sprintf(
`<div class="zone-card" data-zone-type="game" data-zone-id="%s"><span class="zone-card-loading">游戏卡片加载中...</span></div>`,
html.EscapeString(sc.Params),
)
case model.ShortcodePoll:
// [zone:poll:投票ID]
// 前端根据投票ID获取标题、选项、截止时间等信息
// ※后端 API 开发中
return fmt.Sprintf(
`<div class="zone-card" data-zone-type="poll" data-zone-id="%s"><span class="zone-card-loading">投票卡片加载中...</span></div>`,
html.EscapeString(sc.Params),
)
case model.ShortcodeResource:
// [zone:resource:资源ID]
// 前端根据资源ID获取名称、描述、下载链接等信息
// ※后端 API 开发中
return fmt.Sprintf(
`<div class="zone-card" data-zone-type="resource" data-zone-id="%s"><span class="zone-card-loading">资源卡片加载中...</span></div>`,
html.EscapeString(sc.Params),
)
default:
return ""
}
}

View File

@ -0,0 +1,25 @@
package service
import "metazone.cc/metalab/internal/model"
// SpaceService 用户空间服务
type SpaceService struct {
userRepo spaceUserStore
postRepo spacePostStore
}
// NewSpaceService 构造函数
func NewSpaceService(userRepo spaceUserStore, postRepo spacePostStore) *SpaceService {
return &SpaceService{userRepo: userRepo, postRepo: postRepo}
}
// GetSpaceUser 获取用户信息
func (s *SpaceService) GetSpaceUser(uid uint) (*model.User, error) {
return s.userRepo.FindByID(uid)
}
// GetPostsByUser 获取用户发布的帖子(分页)
func (s *SpaceService) GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error) {
offset := (page - 1) * pageSize
return s.postRepo.FindByUserID(uid, offset, pageSize)
}

View File

@ -1,122 +0,0 @@
package service
import (
"log"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"github.com/golang-jwt/jwt/v5"
)
// TokenService JWT 令牌签发与刷新
// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用
type TokenService struct {
cfg *config.Config
userRepo userTokenStore
}
// NewTokenService 构造函数
func NewTokenService(cfg *config.Config, userRepo userTokenStore) *TokenService {
return &TokenService{cfg: cfg, userRepo: userRepo}
}
// BuildAccessToken 构建 access JWT含 uid/email/username/role/ver/exp/iat
// ver = user.TokenVersion用于即时吊销版本号不匹配则拒绝
func (ts *TokenService) BuildAccessToken(user *model.User) (string, error) {
expire := time.Duration(ts.cfg.JWT.AccessExpire) * time.Minute
now := time.Now()
expAt := now.Add(expire)
log.Printf("[BuildAccessToken] AccessExpire=%d min → expire=%v | now=%v | exp=%v (%d) | iat=%d ver=%d",
ts.cfg.JWT.AccessExpire, expire, now, expAt, expAt.Unix(), now.Unix(), user.TokenVersion)
claims := jwt.MapClaims{
"uid": user.ID,
"email": user.Email,
"username": user.Username,
"role": user.Role,
"ver": user.TokenVersion,
"exp": expAt.Unix(),
"iat": now.Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(ts.cfg.JWT.Secret))
}
// BuildRefreshToken 构建 refresh JWT含 purpose:"refresh" 防止 access 冒充)
// rememberMe=true → 使用 remember_expire30天false → 使用 refresh_expire7天session 模式)
// ver = user.TokenVersion刷新时校验版本号不匹配则拒绝
func (ts *TokenService) BuildRefreshToken(user *model.User, rememberMe bool) (string, error) {
var expireHours int
if rememberMe {
expireHours = ts.cfg.JWT.RememberExpire
} else {
expireHours = ts.cfg.JWT.RefreshExpire
}
expire := time.Duration(expireHours) * time.Hour
now := time.Now()
claims := jwt.MapClaims{
"uid": user.ID,
"email": user.Email,
"username": user.Username,
"role": user.Role,
"ver": user.TokenVersion,
"exp": now.Add(expire).Unix(),
"iat": now.Unix(),
"purpose": "refresh",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(ts.cfg.JWT.Secret))
}
// RefreshAccessToken 用 refresh token 换取新的 access token
// 校验 token_version若 DB 中版本已递增,拒绝刷新 → 即时吊销
func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *model.User, error) {
if refreshTokenStr == "" {
return "", nil, common.ErrTokenInvalid
}
token, err := jwt.Parse(refreshTokenStr, func(t *jwt.Token) (interface{}, error) {
return []byte(ts.cfg.JWT.Secret), nil
})
if err != nil || !token.Valid {
return "", nil, common.ErrTokenExpired
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", nil, common.ErrTokenInvalid
}
// 只接受 refresh 用途的 token防止 access token 被用于刷新
if purpose, _ := claims["purpose"].(string); purpose != "refresh" {
return "", nil, common.ErrTokenInvalid
}
uid := uint(claims["uid"].(float64))
tokenVer := int(claims["ver"].(float64))
// 即时吊销检查 + 获取最新用户数据(角色/状态可能在 JWT 签发后已变更)
// 用 FindByIDForAuth 而非从 claims 重建,确保 access token 承载最新数据
user, err := ts.userRepo.FindByIDForAuth(uid)
if err != nil {
return "", nil, common.ErrTokenRevoked
}
if tokenVer != user.TokenVersion {
log.Printf("[RefreshAccessToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, user.TokenVersion)
return "", nil, common.ErrTokenRevoked
}
// 防止封禁用户通过 refresh 续期
if user.Status == model.StatusBanned {
return "", nil, common.ErrUserBanned
}
accessToken, err := ts.BuildAccessToken(user)
if err != nil {
return "", nil, err
}
return accessToken, user, nil
}

View File

@ -0,0 +1,140 @@
package session
import (
"context"
"log"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
// FallbackStore 会话存储降级保护层
// Redis 正常时委托给 RedisStore异常时自动降级到 MemoryStore
// 后台健康检测 goroutine 周期性检测 Redis 状态并自动恢复
type FallbackStore struct {
redis *RedisStore
memory Store
client *redis.Client
healthy atomic.Bool // Redis 当前是否健康
degraded atomic.Bool // 是否处于降级模式(曾因异常而切换到 Memory
}
// NewFallbackStore 创建降级保护存储
// checkInterval: 健康检测间隔,建议与 session cleanup_interval 一致
func NewFallbackStore(redis *RedisStore, memory Store, checkInterval time.Duration) *FallbackStore {
fs := &FallbackStore{
redis: redis,
memory: memory,
client: redis.client,
}
fs.healthy.Store(true)
// 后台健康检测 goroutine
go fs.healthLoop(checkInterval)
log.Println("[FallbackStore] 已启动 Redis 健康检测")
return fs
}
// healthLoop 定期 ping Redis自动升降级
func (fs *FallbackStore) healthLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
prev := fs.healthy.Load()
ok := fs.ping()
fs.healthy.Store(ok)
if ok && !prev {
// Redis 恢复
fs.degraded.Store(false)
log.Println("[FallbackStore] Redis 已恢复,切回 Redis 存储")
} else if !ok && prev {
// Redis 掉线,标记降级
fs.degraded.Store(true)
log.Println("[FallbackStore] Redis 不可用,已降级为内存存储")
}
}
}
// ping 检查 Redis 连接状态(带超时)
func (fs *FallbackStore) ping() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return fs.client.Ping(ctx).Err() == nil
}
// current 返回当前应使用的 StoreRedis 健康用 Redis否则用 Memory
func (fs *FallbackStore) current() Store {
if fs.healthy.Load() {
return fs.redis
}
return fs.memory
}
// ---- Store 接口实现(委托给 current()----
func (fs *FallbackStore) Get(id string) (*Session, error) {
return fs.current().Get(id)
}
func (fs *FallbackStore) Set(s *Session) error {
return fs.current().Set(s)
}
func (fs *FallbackStore) Delete(id string) error {
return fs.current().Delete(id)
}
func (fs *FallbackStore) DeleteByUID(uid uint) error {
return fs.current().DeleteByUID(uid)
}
func (fs *FallbackStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
return fs.current().DeleteByUIDExclude(uid, excludeSID)
}
func (fs *FallbackStore) ListByUID(uid uint) ([]*Session, error) {
return fs.current().ListByUID(uid)
}
func (fs *FallbackStore) Cleanup() int {
return fs.current().Cleanup()
}
func (fs *FallbackStore) StartCleanup(interval time.Duration) {
// MemoryStore 已在外部启动 Cleanup此处无需重复
}
// ---- Metrics供管理面板使用----
// Metrics 返回存储状态信息
func (fs *FallbackStore) Metrics() StoreMetrics {
healthy := fs.healthy.Load()
isDegraded := fs.degraded.Load()
m := StoreMetrics{
Type: "redis",
Healthy: healthy,
}
if !healthy {
m.Type = "memory"
m.Degraded = true
} else if isDegraded {
m.Degraded = true
m.DegradedNote = "Redis 已恢复,但存在历史降级记录(重启后清除)"
}
// 活跃会话数Redis 模式不统计代价高Memory 模式可从 memory store 获取
if !healthy {
if memMetrics := fs.memory.Metrics(); memMetrics.Type == "memory" {
m.ActiveCount = memMetrics.ActiveCount
}
}
return m
}

134
internal/session/manager.go Normal file
View File

@ -0,0 +1,134 @@
package session
import (
"log"
"time"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
)
// userStore SessionManager 对 UserRepo 的最小接口ISP1 个方法)
type userStore interface {
FindByIDForAuth(userID uint) (*model.User, error)
}
// Manager 会话管理器:创建、验证、销毁、续期
type Manager struct {
store Store
userRepo userStore
cfg *config.Config
}
// NewManager 构造函数
func NewManager(store Store, userRepo userStore, cfg *config.Config) *Manager {
return &Manager{store: store, userRepo: userRepo, cfg: cfg}
}
// Create 创建会话并写入存储
func (m *Manager) Create(user *model.User, rememberMe bool, ip, userAgent string) (string, error) {
sid, err := NewID()
if err != nil {
return "", err
}
now := time.Now()
s := &Session{
ID: sid,
UserID: user.ID,
Email: user.Email,
Username: user.Username,
Role: user.Role,
RememberMe: rememberMe,
IP: ip,
UserAgent: userAgent,
CreatedAt: now,
LastAccess: now,
}
if err := m.store.Set(s); err != nil {
return "", err
}
log.Printf("[SessionManager] Created sid=%s uid=%d rememberMe=%v ip=%s", sid[:16]+"...", user.ID, rememberMe, ip)
return sid, nil
}
// Validate 验证会话:读取 → 检查过期 → 检查用户状态 → 续期 → 返回
// 返回 nil 表示会话无效(不存在/过期/用户被封禁)
func (m *Manager) Validate(sid string) (*Session, error) {
if sid == "" {
return nil, nil
}
s, err := m.store.Get(sid)
if err != nil || s == nil {
return nil, nil
}
// 检查用户是否仍然有效(封禁/注销后拒绝已有会话)
user, err := m.userRepo.FindByIDForAuth(s.UserID)
if err != nil {
log.Printf("[SessionManager] Validate: user not found uid=%d err=%v", s.UserID, err)
m.store.Delete(sid)
return nil, nil
}
// 封禁用户直接拒绝
if user.Status == model.StatusBanned {
log.Printf("[SessionManager] Validate: user banned uid=%d", s.UserID)
m.store.Delete(sid)
return nil, nil
}
// 滑动窗口续期
s.Touch()
if err := m.store.Set(s); err != nil {
log.Printf("[SessionManager] Validate: touch failed sid=%s err=%v", sid[:16]+"...", err)
}
return s, nil
}
// Destroy 销毁单个会话(用户主动退出登录)
func (m *Manager) Destroy(sid string) error {
return m.store.Delete(sid)
}
// DestroyByUID 销毁某用户的所有会话(改密/注销/强制下线)
func (m *Manager) DestroyByUID(uid uint) error {
return m.store.DeleteByUID(uid)
}
// IdleTimeout 根据 rememberMe 返回对应的空闲超时时间
func (m *Manager) IdleTimeout(rememberMe bool) time.Duration {
if rememberMe {
return time.Duration(m.cfg.Session.RememberTimeout) * time.Minute
}
return time.Duration(m.cfg.Session.IdleTimeout) * time.Minute
}
// ListByUID 列出某用户的所有活跃会话
func (m *Manager) ListByUID(uid uint) ([]*Session, error) {
return m.store.ListByUID(uid)
}
// DestroyOtherByUID 销毁某用户除当前会话外的所有会话
func (m *Manager) DestroyOtherByUID(uid uint, currentSID string) error {
return m.store.DeleteByUIDExclude(uid, currentSID)
}
// UpdateRemark 更新指定会话的备注(需验证归属)
func (m *Manager) UpdateRemark(sid string, uid uint, remark string) error {
s, err := m.store.Get(sid)
if err != nil || s == nil {
return err
}
if s.UserID != uid {
return nil
}
s.Remark = remark
return m.store.Set(s)
}
// GetStoreMetrics 返回当前存储的状态信息
func (m *Manager) GetStoreMetrics() StoreMetrics {
return m.store.Metrics()
}

View File

@ -0,0 +1,213 @@
package session
import (
"log"
"sync"
"time"
)
// MemoryStore 基于内存的会话存储sync.Map + RWMutex适合单实例部署
type MemoryStore struct {
mu sync.RWMutex
sessions map[string]*Session // sid → session
byUID map[uint][]string // uid → []sid用于批量删除
idleTimeout time.Duration // 不记住我:空闲超时
rememberTimeout time.Duration // 记住我:空闲超时
}
// NewMemoryStore 创建内存存储实例
func NewMemoryStore(idleTimeout, rememberTimeout time.Duration) *MemoryStore {
return &MemoryStore{
sessions: make(map[string]*Session),
byUID: make(map[uint][]string),
idleTimeout: idleTimeout,
rememberTimeout: rememberTimeout,
}
}
// Get 获取会话(根据 RememberMe 选择对应的空闲超时检查过期)
func (ms *MemoryStore) Get(id string) (*Session, error) {
ms.mu.RLock()
s, ok := ms.sessions[id]
ms.mu.RUnlock()
if !ok {
return nil, nil
}
// 记住我 → 使用更长的超时
timeout := ms.idleTimeout
if s.RememberMe {
timeout = ms.rememberTimeout
}
if s.IsExpired(timeout) {
ms.Delete(id)
return nil, nil
}
return s, nil
}
// Set 写入会话(同步更新 byUID 索引)
func (ms *MemoryStore) Set(s *Session) error {
ms.mu.Lock()
defer ms.mu.Unlock()
if old, ok := ms.sessions[s.ID]; ok {
// sid 已存在:仅当 uid 变更时更新索引
if old.UserID != s.UserID {
ms.removeFromUIDIndex(old.UserID, s.ID)
ms.byUID[s.UserID] = append(ms.byUID[s.UserID], s.ID)
}
} else {
// 新 sid追加到索引
ms.byUID[s.UserID] = append(ms.byUID[s.UserID], s.ID)
}
ms.sessions[s.ID] = s
return nil
}
// Delete 删除会话(同步更新 byUID 索引)
func (ms *MemoryStore) Delete(id string) error {
ms.mu.Lock()
defer ms.mu.Unlock()
s, ok := ms.sessions[id]
if !ok {
return nil
}
ms.removeFromUIDIndex(s.UserID, id)
delete(ms.sessions, id)
return nil
}
// DeleteByUID 删除某用户的所有会话
func (ms *MemoryStore) DeleteByUID(uid uint) error {
ms.mu.Lock()
defer ms.mu.Unlock()
sids, ok := ms.byUID[uid]
if !ok {
return nil
}
for _, sid := range sids {
delete(ms.sessions, sid)
}
delete(ms.byUID, uid)
log.Printf("[MemoryStore] DeleteByUID uid=%d count=%d", uid, len(sids))
return nil
}
// DeleteByUIDExclude 删除某用户的所有会话,但保留 excludeSID
func (ms *MemoryStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
ms.mu.Lock()
defer ms.mu.Unlock()
sids, ok := ms.byUID[uid]
if !ok {
return nil
}
var kept []string
var removed int
for _, sid := range sids {
if sid == excludeSID {
kept = append(kept, sid)
continue
}
delete(ms.sessions, sid)
removed++
}
if len(kept) > 0 {
ms.byUID[uid] = kept
} else {
delete(ms.byUID, uid)
}
log.Printf("[MemoryStore] DeleteByUIDExclude uid=%d removed=%d kept=%d", uid, removed, len(kept))
return nil
}
// ListByUID 列出某用户的所有活跃会话(已过期的会被清理掉)
func (ms *MemoryStore) ListByUID(uid uint) ([]*Session, error) {
ms.mu.RLock()
sids, ok := ms.byUID[uid]
if !ok {
ms.mu.RUnlock()
return nil, nil
}
// 复制 sid 列表避免持锁期间修改
sidCopy := make([]string, len(sids))
copy(sidCopy, sids)
ms.mu.RUnlock()
var result []*Session
for _, sid := range sidCopy {
// Get 会检查过期并自动清理
s, err := ms.Get(sid)
if err != nil || s == nil {
continue
}
result = append(result, s)
}
return result, nil
}
// Cleanup 清理过期会话(根据 RememberMe 选择对应超时)
func (ms *MemoryStore) Cleanup() int {
ms.mu.Lock()
defer ms.mu.Unlock()
var expiredSids []string
for sid, s := range ms.sessions {
timeout := ms.idleTimeout
if s.RememberMe {
timeout = ms.rememberTimeout
}
if s.IsExpired(timeout) {
expiredSids = append(expiredSids, sid)
}
}
for _, sid := range expiredSids {
s := ms.sessions[sid]
ms.removeFromUIDIndex(s.UserID, sid)
delete(ms.sessions, sid)
}
if len(expiredSids) > 0 {
log.Printf("[MemoryStore] Cleanup removed=%d remaining=%d", len(expiredSids), len(ms.sessions))
}
return len(expiredSids)
}
// removeFromUIDIndex 从 byUID 索引中移除指定 sid调用者需持有写锁
func (ms *MemoryStore) removeFromUIDIndex(uid uint, sid string) {
sids := ms.byUID[uid]
for i, s := range sids {
if s == sid {
ms.byUID[uid] = append(sids[:i], sids[i+1:]...)
break
}
}
if len(ms.byUID[uid]) == 0 {
delete(ms.byUID, uid)
}
}
// StartCleanup 启动后台清理 goroutine应在 main 中调用)
func (ms *MemoryStore) StartCleanup(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
ms.Cleanup()
}
}()
}
// Metrics 返回内存存储状态
func (ms *MemoryStore) Metrics() StoreMetrics {
ms.mu.RLock()
count := len(ms.sessions)
ms.mu.RUnlock()
return StoreMetrics{
Type: "memory",
Healthy: true,
ActiveCount: count,
}
}

View File

@ -0,0 +1,240 @@
package session
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
// Redis key 前缀前缀,避免与其他业务 key 冲突
const (
keyPrefix = "metalab:session:"
userKeyPrefix = "metalab:user_sessions:"
)
// RedisStore 基于 Redis 的会话存储(生产环境推荐)
// 利用 Redis TTL 自动过期,无须后台清理 goroutine
type RedisStore struct {
client *redis.Client
idleTimeout time.Duration // 不记住我:空闲超时
rememberTimeout time.Duration // 记住我:空闲超时
}
// NewRedisStore 创建 Redis 会话存储实例
func NewRedisStore(client *redis.Client, idleTimeout, rememberTimeout time.Duration) *RedisStore {
return &RedisStore{
client: client,
idleTimeout: idleTimeout,
rememberTimeout: rememberTimeout,
}
}
// userKey 返回用户会话索引的 Redis key
func (rs *RedisStore) userKey(uid uint) string {
return userKeyPrefix + fmt.Sprint(uid)
}
// sessionKeys 将 sid 列表转为完整的 Redis session key 列表
func (rs *RedisStore) sessionKeys(sids []string) []string {
keys := make([]string, len(sids))
for i, sid := range sids {
keys[i] = keyPrefix + sid
}
return keys
}
// Get 根据会话 ID 获取会话(过期返回 nil
func (rs *RedisStore) Get(id string) (*Session, error) {
ctx := context.Background()
data, err := rs.client.Get(ctx, keyPrefix+id).Bytes()
if err == redis.Nil {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Redis GET session: %w", err)
}
var s Session
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("Redis 反序列化 session: %w", err)
}
return &s, nil
}
// Set 写入/更新会话并设置 TTL
func (rs *RedisStore) Set(s *Session) error {
ctx := context.Background()
data, err := json.Marshal(s)
if err != nil {
return fmt.Errorf("Redis 序列化 session: %w", err)
}
ttl := rs.idleTimeout
if s.RememberMe {
ttl = rs.rememberTimeout
}
pipe := rs.client.Pipeline()
// 1. 写入会话数据
pipe.Set(ctx, keyPrefix+s.ID, data, ttl)
// 2. 将 sid 加入用户会话索引Redis SET
// 不设置 user_sessions 的 TTL——同一用户可能有不同 TTL 的会话,
// 设置单一 TTL 会导致长生命周期会话的索引被提前过期
pipe.SAdd(ctx, rs.userKey(s.UserID), s.ID)
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("Redis SET session: %w", err)
}
return nil
}
// Delete 删除单个会话
func (rs *RedisStore) Delete(id string) error {
ctx := context.Background()
// 先读取 session 获取 UserID再从集合中移除
data, err := rs.client.Get(ctx, keyPrefix+id).Bytes()
if err == redis.Nil {
return nil
}
if err != nil {
return fmt.Errorf("Redis GET session for delete: %w", err)
}
var s Session
if err := json.Unmarshal(data, &s); err == nil {
if sremErr := rs.client.SRem(ctx, rs.userKey(s.UserID), id).Err(); sremErr != nil {
log.Printf("[RedisStore] Delete: SRem failed sid=%s uid=%d err=%v", id[:16]+"...", s.UserID, sremErr)
}
}
return rs.client.Del(ctx, keyPrefix+id).Err()
}
// DeleteByUID 删除某用户的所有会话(改密/注销/强制下线)
func (rs *RedisStore) DeleteByUID(uid uint) error {
ctx := context.Background()
uidKey := rs.userKey(uid)
sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil {
return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err)
}
if len(sids) == 0 {
return nil
}
pipe := rs.client.Pipeline()
pipe.Del(ctx, rs.sessionKeys(sids)...)
pipe.Del(ctx, uidKey)
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("Redis DeleteByUID: %w", err)
}
log.Printf("[RedisStore] DeleteByUID uid=%d count=%d", uid, len(sids))
return nil
}
// DeleteByUIDExclude 删除某用户的所有会话,但保留指定的会话 ID
func (rs *RedisStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
ctx := context.Background()
uidKey := rs.userKey(uid)
sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil {
return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err)
}
var toDelete []string
kept := 0
for _, sid := range sids {
if sid == excludeSID {
kept++
continue
}
toDelete = append(toDelete, sid)
}
if len(toDelete) == 0 {
return nil
}
pipe := rs.client.Pipeline()
pipe.Del(ctx, rs.sessionKeys(toDelete)...)
for _, sid := range toDelete {
pipe.SRem(ctx, uidKey, sid)
}
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("Redis DeleteByUIDExclude: %w", err)
}
log.Printf("[RedisStore] DeleteByUIDExclude uid=%d removed=%d kept=%d", uid, len(toDelete), kept)
return nil
}
// ListByUID 列出某用户的所有活跃会话
func (rs *RedisStore) ListByUID(uid uint) ([]*Session, error) {
ctx := context.Background()
uidKey := rs.userKey(uid)
sids, err := rs.client.SMembers(ctx, uidKey).Result()
if err != nil {
return nil, fmt.Errorf("Redis SMEMBERS user_sessions: %w", err)
}
if len(sids) == 0 {
return nil, nil
}
results, err := rs.client.MGet(ctx, rs.sessionKeys(sids)...).Result()
if err != nil {
return nil, fmt.Errorf("Redis MGET sessions: %w", err)
}
var sessions []*Session
for _, r := range results {
if r == nil {
continue
}
data, ok := r.(string)
if !ok {
continue
}
var s Session
if err := json.Unmarshal([]byte(data), &s); err != nil {
continue
}
sessions = append(sessions, &s)
}
return sessions, nil
}
// Cleanup Redis 自动 TTL 过期,无需手动清理
func (rs *RedisStore) Cleanup() int {
return 0
}
// StartCleanup Redis 模式无需后台清理 goroutine
func (rs *RedisStore) StartCleanup(interval time.Duration) {
// Redis TTL 自动过期,无需手动清理
}
// Metrics 返回 Redis 存储状态(活跃会话数统计代价高,返回 -1
func (rs *RedisStore) Metrics() StoreMetrics {
return StoreMetrics{
Type: "redis",
Healthy: true,
ActiveCount: -1,
}
}

View File

@ -0,0 +1,41 @@
package session
import (
"crypto/rand"
"encoding/hex"
"time"
)
// Session 服务端会话,替代 JWT 无状态令牌
type Session struct {
ID string // 会话唯一标识64 位 hex
UserID uint // 用户 ID
Email string // 邮箱
Username string // 用户名
Role string // 角色
RememberMe bool // 是否持久化(决定 cookie maxAge
IP string // 登录 IP
UserAgent string // 登录设备 User-Agent
Remark string // 用户备注(如"我的笔记本"
CreatedAt time.Time // 创建时间
LastAccess time.Time // 最后访问时间(滑动窗口)
}
// IsExpired 检查会话是否已过期
func (s *Session) IsExpired(idleTimeout time.Duration) bool {
return time.Since(s.LastAccess) > idleTimeout
}
// Touch 更新最后访问时间(续期)
func (s *Session) Touch() {
s.LastAccess = time.Now()
}
// NewID 生成 32 字节随机 hex 字符串作为会话 ID
func NewID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

37
internal/session/store.go Normal file
View File

@ -0,0 +1,37 @@
package session
// StoreMetrics 存储状态信息(供管理面板展示)
type StoreMetrics struct {
Type string // "redis" 或 "memory"
Healthy bool // Redis 健康状态memory 模式始终为 true
Degraded bool // 是否处于降级模式
DegradedNote string // 降级提示语(可选)
ActiveCount int // 活跃会话数,-1 表示不统计
}
// Store 会话存储接口(内存实现 / Redis 实现 / FallbackStore 均实现此接口)
type Store interface {
// Get 按会话 ID 获取会话,不存在或已过期返回 nil
Get(id string) (*Session, error)
// Set 写入/更新会话
Set(s *Session) error
// Delete 删除单个会话
Delete(id string) error
// DeleteByUID 删除某用户的所有会话(改密/注销/强制下线时调用)
DeleteByUID(uid uint) error
// DeleteByUIDExclude 删除某用户的所有会话,但保留指定的会话 ID
DeleteByUIDExclude(uid uint, excludeSID string) error
// ListByUID 列出某用户的所有活跃会话
ListByUID(uid uint) ([]*Session, error)
// Cleanup 清理过期会话,返回清理数量(由后台 goroutine 定期调用)
Cleanup() int
// Metrics 返回存储状态信息(供管理面板展示)
Metrics() StoreMetrics
}

97
internal/session/ua.go Normal file
View File

@ -0,0 +1,97 @@
package session
import "strings"
// DeviceInfo 从 User-Agent 解析出的设备摘要信息
type DeviceInfo struct {
Device string // 设备类型Desktop / Mobile / Tablet
OS string // 操作系统Windows 10 / macOS / Android 12 等
Browser string // 浏览器Chrome 120 / Firefox 121 / Safari 17 等
}
// ParseUA 从原始 User-Agent 字符串解析设备信息(轻量级,不引入第三方库)
func ParseUA(ua string) DeviceInfo {
info := DeviceInfo{Device: "Desktop"}
lower := strings.ToLower(ua)
// --- 操作系统 ---
switch {
case strings.Contains(lower, "windows nt 10"):
info.OS = "Windows 10/11"
case strings.Contains(lower, "windows nt 6.3"):
info.OS = "Windows 8.1"
case strings.Contains(lower, "windows nt 6.2"):
info.OS = "Windows 8"
case strings.Contains(lower, "windows nt 6.1"):
info.OS = "Windows 7"
case strings.Contains(lower, "windows"):
info.OS = "Windows"
case strings.Contains(lower, "mac os x"):
info.OS = "macOS"
if strings.Contains(lower, "iphone") {
info.OS = "iOS"
info.Device = "Mobile"
} else if strings.Contains(lower, "ipad") {
info.OS = "iPadOS"
info.Device = "Tablet"
}
case strings.Contains(lower, "android"):
info.OS = "Android"
info.Device = "Mobile"
if strings.Contains(lower, "tablet") || strings.Contains(lower, "ipad") {
info.Device = "Tablet"
}
case strings.Contains(lower, "iphone"):
info.OS = "iOS"
info.Device = "Mobile"
case strings.Contains(lower, "ipad"):
info.OS = "iPadOS"
info.Device = "Tablet"
case strings.Contains(lower, "linux"):
info.OS = "Linux"
case strings.Contains(lower, "cros"):
info.OS = "ChromeOS"
default:
info.OS = "Unknown"
}
// --- 浏览器 ---
switch {
case strings.Contains(lower, "edg/"):
info.Browser = "Edge"
info.Browser += extractVersion(ua, "Edg/")
case strings.Contains(lower, "chrome/") && !strings.Contains(lower, "edg/"):
info.Browser = "Chrome"
info.Browser += extractVersion(ua, "Chrome/")
case strings.Contains(lower, "firefox/"):
info.Browser = "Firefox"
info.Browser += extractVersion(ua, "Firefox/")
case strings.Contains(lower, "safari/") && !strings.Contains(lower, "chrome/"):
info.Browser = "Safari"
info.Browser += extractVersion(ua, "Version/")
default:
info.Browser = "Unknown"
}
return info
}
// extractVersion 从 UA 中提取主版本号
func extractVersion(ua, prefix string) string {
idx := strings.Index(ua, prefix)
if idx == -1 {
return ""
}
rest := ua[idx+len(prefix):]
dot := strings.Index(rest, ".")
if dot == -1 {
// 无点号,取到空格或结尾
sp := strings.Index(rest, " ")
if sp > 0 {
return " " + rest[:sp]
}
return ""
}
return " " + rest[:dot]
}

View File

@ -1,10 +1,13 @@
package theme package theme
import ( import (
"fmt"
"html/template" "html/template"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"metazone.cc/metalab/internal/common"
) )
// TemplateRoot 模板根目录配置 // TemplateRoot 模板根目录配置
@ -16,7 +19,13 @@ type TemplateRoot struct {
// LoadTemplates 加载多个根目录的 .html 模板到同一模板集 // LoadTemplates 加载多个根目录的 .html 模板到同一模板集
// 模板名 = prefix + 相对于 root.Dir 的相对路径 // 模板名 = prefix + 相对于 root.Dir 的相对路径
func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) { func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
t := template.New("") funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b },
"subtract": func(a, b int) int { return a - b },
"formatTTL": formatTTL,
"assetV": common.AssetV,
}
t := template.New("").Funcs(funcMap)
for _, root := range roots { for _, root := range roots {
dir := filepath.Clean(root.Dir) + string(os.PathSeparator) dir := filepath.Clean(root.Dir) + string(os.PathSeparator)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
@ -51,3 +60,23 @@ func LoadContent(path string) (template.HTML, error) {
} }
return template.HTML(content), nil return template.HTML(content), nil
} }
// formatTTL 将剩余分钟数格式化为人类可读的时间(如 "23 小时"、"3 天"、"15 分钟"
func formatTTL(minutes int) string {
if minutes <= 0 {
return "即将过期"
}
if minutes < 60 {
return fmt.Sprintf("%d 分钟", minutes)
}
hours := minutes / 60
if hours < 24 {
return fmt.Sprintf("%d 小时", hours)
}
days := hours / 24
remainHours := hours % 24
if remainHours == 0 {
return fmt.Sprintf("%d 天", days)
}
return fmt.Sprintf("%d 天 %d 小时", days, remainHours)
}

View File

@ -29,7 +29,7 @@
<button type="submit" class="submit-btn" id="submitBtn">登 录</button> <button type="submit" class="submit-btn" id="submitBtn">登 录</button>
</form> </form>
<div class="auth-footer"> <div class="auth-footer">
还没有账号?<a href="/auth/register">立即注册</a> {{if not .IsMaintenance}}还没有账号?<a href="/auth/register">立即注册</a>{{end}}
</div> </div>
</div> </div>
</main> </main>
@ -42,7 +42,7 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button> </button>
<script src="/static/js/common.js"></script> <script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/login.js"></script> <script src="/static/js/login.js?v={{assetV "/static/js/login.js"}}"></script>
</body> </body>
</html> </html>

View File

@ -5,6 +5,13 @@
<main class="auth-page"> <main class="auth-page">
<div class="auth-card"> <div class="auth-card">
{{if .IsMaintenance}}
<h2>系统维护中</h2>
<p class="auth-subtitle">社区正在维护升级,期间仅站长可访问</p>
<div class="registration-closed">
<p class="closed-message">请耐心等待,维护完成后将恢复注册与访问</p>
</div>
{{else if .RegistrationEnabled}}
<h2>创建账号</h2> <h2>创建账号</h2>
<p class="auth-subtitle">加入 MetaLab 开发者社区</p> <p class="auth-subtitle">加入 MetaLab 开发者社区</p>
<form id="registerForm" novalidate> <form id="registerForm" novalidate>
@ -34,6 +41,14 @@
<p class="guidelines-link">点击注册即表示同意 <a href="javascript:void(0)" id="openGuidelines">《MetaLab 社区用户准则》</a></p> <p class="guidelines-link">点击注册即表示同意 <a href="javascript:void(0)" id="openGuidelines">《MetaLab 社区用户准则》</a></p>
<button type="submit" class="submit-btn" id="submitBtn">注 册</button> <button type="submit" class="submit-btn" id="submitBtn">注 册</button>
</form> </form>
{{else}}
<h2>暂停注册</h2>
<p class="auth-subtitle">新用户注册暂时关闭</p>
<div class="registration-closed">
<p class="closed-message">当前暂不开放注册,敬请期待</p>
<p class="closed-subtitle">如需加入社区,请关注后续开放通知</p>
</div>
{{end}}
<div class="auth-footer"> <div class="auth-footer">
已有账号?<a href="/auth/login">立即登录</a> 已有账号?<a href="/auth/login">立即登录</a>
</div> </div>
@ -74,7 +89,7 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button> </button>
<script src="/static/js/common.js"></script> <script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/register.js"></script> <script src="/static/js/register.js?v={{assetV "/static/js/register.js"}}"></script>
</body> </body>
</html> </html>

View File

@ -37,6 +37,6 @@
{{template "layout/footer.html" .}} {{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script> <script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body> </body>
</html> </html>

View File

@ -4,10 +4,18 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}} {{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
<title>{{.Title}} - MetaLab</title> {{if .OgTitle}}
<link rel="stylesheet" href="/static/css/common.css"> <meta property="og:title" content="{{.OgTitle}}">
{{if .ExtraCSS}} <meta property="og:type" content="article">
<link rel="stylesheet" href="{{.ExtraCSS}}"> <meta property="og:site_name" content="MetaLab">
{{if .OgDescription}}<meta property="og:description" content="{{.OgDescription}}">{{end}}
{{if .OgImage}}<meta property="og:image" content="{{.OgImage}}">{{end}}
{{if .OgURL}}<meta property="og:url" content="{{.OgURL}}">{{end}}
{{end}} {{end}}
<script src="/shared/static/js/utils.js"></script> <title>{{.Title}} - MetaLab</title>
<link rel="stylesheet" href="/static/css/common.css?v={{assetV "/static/css/common.css"}}">
{{if .ExtraCSS}}
<link rel="stylesheet" href="{{.ExtraCSS}}?v={{assetV .ExtraCSS}}">
{{end}}
<script src="/shared/static/js/utils.js?v={{assetV "/shared/static/js/utils.js"}}"></script>
</head> </head>

View File

@ -14,7 +14,8 @@
<span class="nav-bell-badge" id="msgBadge" style="display:none">0</span> <span class="nav-bell-badge" id="msgBadge" style="display:none">0</span>
</a> </a>
</li> </li>
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li> <li><a href="/space" class="nav-user">{{.Username}}</a></li>
<li><a href="/settings/profile" class="nav-settings">设置</a></li>
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li> <li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{else}} {{else}}
<li><a href="/auth/login" class="nav-login">登录</a></li> <li><a href="/auth/login" class="nav-login">登录</a></li>
@ -22,3 +23,13 @@
</ul> </ul>
</div> </div>
</header> </header>
{{if .IsMaintenance}}
<div class="maintenance-banner">
<span class="maintenance-banner-icon">&#9881;</span>
{{if .IsOwner}}
<span>社区正处于维护状态</span>
{{else}}
<span>社区正在维护中,仅站长可访问</span>
{{end}}
</div>
{{end}}

View File

@ -21,6 +21,15 @@
</aside> </aside>
<div class="msg-main"> <div class="msg-main">
<!-- 系统消息区域 -->
<div class="msg-system-section">
<div class="msg-system-header">
<svg class="msg-system-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
系统消息
</div>
<div class="msg-system-divider"></div>
</div>
{{if .Messages}} {{if .Messages}}
<div class="msg-toolbar"> <div class="msg-toolbar">
{{if .UnreadCount}} {{if .UnreadCount}}
@ -29,7 +38,18 @@
</div> </div>
<div class="msg-list"> <div class="msg-list">
{{range $i, $m := .Messages}} {{range $i, $m := .Messages}}
<div class="msg-card{{if not $m.IsRead}} unread{{end}}" data-id="{{$m.ID}}"> {{$link := ""}}
{{if or (eq $m.NotifyType "post_approved") (eq $m.NotifyType "post_rejected")}}
{{if eq $m.NotifyType "post_approved"}}
{{$link = printf "/posts/%d" $m.RelatedID}}
{{else}}
{{$link = "/studio/posts"}}
{{end}}
{{end}}
{{if $link}}
<a href="{{$link}}" class="msg-card-link">
{{end}}
<div class="msg-card{{if not $m.IsRead}} unread{{end}}" data-id="{{$m.ID}}" data-type="{{$m.NotifyType}}" data-link="{{$link}}">
<div class="msg-icon"> <div class="msg-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</div> </div>
@ -45,6 +65,9 @@
<span class="msg-badge"></span> <span class="msg-badge"></span>
{{end}} {{end}}
</div> </div>
{{if $link}}
</a>
{{end}}
{{end}} {{end}}
</div> </div>
@ -82,7 +105,10 @@
var cards = document.querySelectorAll('.msg-card.unread'); var cards = document.querySelectorAll('.msg-card.unread');
for (var i = 0; i < cards.length; i++) { for (var i = 0; i < cards.length; i++) {
(function (card) { (function (card) {
card.addEventListener('click', function () { card.addEventListener('click', function (e) {
var link = card.getAttribute('data-link');
// 如果是可跳转的通知post 审核),不阻止默认行为,只标记已读
// 标记已读通过 fetch 发送,不阻止链接跳转
var id = card.getAttribute('data-id'); var id = card.getAttribute('data-id');
if (!id) return; if (!id) return;
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@ -96,6 +122,15 @@
} }
}; };
xhr.send(); xhr.send();
// 如果有链接,在标记已读后跳转(非 post 通知直接标记已读)
if (link) {
e.preventDefault();
var self = this;
setTimeout(function () {
window.location.href = link;
}, 200);
}
}); });
})(cards[i]); })(cards[i]);
} }
@ -167,6 +202,6 @@
} }
})(); })();
</script> </script>
<script src="/static/js/common.js"></script> <script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body> </body>
</html> </html>

View File

@ -3,12 +3,20 @@
{{template "layout/nav.html" .}} {{template "layout/nav.html" .}}
<div class="container"> <div class="container">
<div class="error-404"> <div class="error-404">
<h1>404</h1> <div class="error-404-icon">
<p>帖子不存在或已被删除</p> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<a href="/posts" class="btn btn-primary">返回帖子列表</a> <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<h1>页面未找到</h1>
<p>该帖子可能已被删除、尚未发布,或您输入的地址有误</p>
<div class="error-404-actions">
<a href="/" class="btn btn-secondary">返回首页</a>
<a href="/posts" class="btn btn-primary">浏览帖子</a>
</div>
</div> </div>
</div> </div>
{{template "layout/footer.html" .}} {{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script> <script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body> </body>
</html> </html>

View File

@ -29,10 +29,10 @@
<a href="/posts/{{.ID}}">{{.Title}}</a> <a href="/posts/{{.ID}}">{{.Title}}</a>
</h2> </h2>
<div class="post-card-meta"> <div class="post-card-meta">
<span class="post-author">{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</span> <span class="post-author">{{if .AuthorName}}{{.AuthorName}}{{else}}该用户已注销{{end}}</span>
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span> <span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div> </div>
<p class="post-card-summary">{{printf "%.200s" .Body}}</p> <p class="post-card-summary">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>
</article> </article>
{{end}} {{end}}
{{end}} {{end}}
@ -53,6 +53,6 @@
{{template "layout/footer.html" .}} {{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script> <script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body> </body>
</html> </html>

View File

@ -6,6 +6,10 @@
<div class="editor-layout"> <div class="editor-layout">
<form id="postForm" class="editor-form"> <form id="postForm" class="editor-form">
<input type="hidden" id="postId" value="{{if .Post}}{{.Post.ID}}{{end}}"> <input type="hidden" id="postId" value="{{if .Post}}{{.Post.ID}}{{end}}">
{{/* 编辑模式下传入已有的 MD 内容 */}}
{{if .Post}}
<textarea id="editBody" style="display:none">{{.Post.Body}}</textarea>
{{end}}
{{/* 标题区域 */}} {{/* 标题区域 */}}
<div class="editor-header"> <div class="editor-header">
@ -15,63 +19,35 @@
maxlength="200" required autofocus> maxlength="200" required autofocus>
</div> </div>
{{/* 工具栏 */}} {{/* Vditor 编辑器容器(工具栏+编辑区由 Vditor 自动生成) */}}
<div class="editor-toolbar" id="toolbar">
<button type="button" data-action="bold" title="加粗 (Ctrl+B)"><strong>B</strong></button>
<button type="button" data-action="italic" title="斜体 (Ctrl+I)"><em>I</em></button>
<button type="button" data-action="strike" title="删除线"><s>S</s></button>
<button type="button" data-action="code" title="行内代码">&lt;/&gt;</button>
<span class="toolbar-divider"></span>
<button type="button" data-action="h1" title="标题 1">H1</button>
<button type="button" data-action="h2" title="标题 2">H2</button>
<button type="button" data-action="h3" title="标题 3">H3</button>
<span class="toolbar-divider"></span>
<button type="button" data-action="bulletList" title="无序列表"></button>
<button type="button" data-action="orderedList" title="有序列表">1.</button>
<button type="button" data-action="blockquote" title="引用">"</button>
<span class="toolbar-divider"></span>
<button type="button" data-action="codeBlock" title="代码块">{ }</button>
<select id="codeLangSelect" class="code-lang-select" style="display:none" title="选择语言">
<option value="text">text</option>
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="python">Python</option>
<option value="go">Go</option>
<option value="rust">Rust</option>
<option value="java">Java</option>
<option value="cpp">C++</option>
<option value="c">C</option>
<option value="csharp">C#</option>
<option value="ruby">Ruby</option>
<option value="php">PHP</option>
<option value="swift">Swift</option>
<option value="kotlin">Kotlin</option>
<option value="sql">SQL</option>
<option value="bash">Bash</option>
<option value="json">JSON</option>
<option value="yaml">YAML</option>
<option value="xml">XML</option>
<option value="css">CSS</option>
<option value="scss">SCSS</option>
<option value="html">HTML</option>
<option value="markdown">Markdown</option>
<option value="shell">Shell</option>
<option value="dockerfile">Dockerfile</option>
<option value="nginx">Nginx</option>
<option value="diff">Diff</option>
</select>
<button type="button" data-action="horizontalRule" title="分隔线"></button>
<span class="toolbar-divider"></span>
<button type="button" data-action="undo" title="撤销"></button>
<button type="button" data-action="redo" title="重做"></button>
</div>
{{/* 编辑器主体 */}}
<div class="editor-main"> <div class="editor-main">
<div class="editor-pane" id="editorPane"> <div class="editor-pane">
<div id="editor-container" <div id="vditor"></div>
data-content="{{if .Post}}{{.Post.BodyHTML}}{{end}}"></div>
</div> </div>
<aside class="editor-sidebar">
<div class="editor-sidebar-section">
<h4>快捷语法</h4>
<ul class="tips-list">
<li><code>#</code> <code>##</code> <code>###</code> 标题</li>
<li><code>**粗体**</code> <code>*斜体*</code></li>
<li><code>`行内代码`</code></li>
<li><code>```</code> 代码块</li>
<li><code>&gt;</code> 引用</li>
<li><code>-</code> 无序列表</li>
<li><code>[文字](链接)</code></li>
<li><code>![图片](链接)</code></li>
</ul>
</div>
<div class="editor-sidebar-section">
<h4>发布提示</h4>
<ul class="tips-list">
<li>标题控制在 50 字以内</li>
<li>正文清晰分段,便于阅读</li>
<li>代码使用代码块包裹</li>
<li>Ctrl+S 快速保存草稿</li>
</ul>
</div>
</aside>
</div> </div>
{{/* 状态栏 */}} {{/* 状态栏 */}}
@ -79,7 +55,6 @@
<span class="status-item" id="wordCount">0 字</span> <span class="status-item" id="wordCount">0 字</span>
<span class="status-item status-draft" id="draftStatus" style="display:none">草稿已保存</span> <span class="status-item status-draft" id="draftStatus" style="display:none">草稿已保存</span>
<span class="status-spacer"></span> <span class="status-spacer"></span>
<span class="status-item" id="btnFullscreen" style="cursor:pointer" title="全屏编辑 (F11)">全屏</span>
<button type="submit" class="btn btn-primary btn-submit">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button> <button type="submit" class="btn btn-primary btn-submit">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
</div> </div>
</form> </form>
@ -87,18 +62,9 @@
{{template "layout/footer.html" .}} {{template "layout/footer.html" .}}
<script type="importmap"> <link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
{ <script src="/static/vditor/dist/index.min.js?v={{assetV "/static/vditor/dist/index.min.js"}}"></script>
"imports": { <script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
"@tiptap/core": "https://esm.sh/@tiptap/core@3.13.0", <script type="module" src="/static/js/editor.js?v={{assetV "/static/js/editor.js"}}"></script>
"@tiptap/starter-kit": "https://esm.sh/@tiptap/starter-kit@3.13.0", </body>
"@tiptap/extension-placeholder": "https://esm.sh/@tiptap/extension-placeholder@3.13.0", </html>
"@tiptap/extension-image": "https://esm.sh/@tiptap/extension-image@3.13.0",
"@tiptap/extension-code-block-lowlight": "https://esm.sh/@tiptap/extension-code-block-lowlight@3.13.0",
"lowlight": "https://esm.sh/lowlight@3.3.0",
"highlight.js/lib/languages/": "https://esm.sh/highlight.js@11.11.0/es/languages/"
}
}
</script>
<script src="/static/js/common.js"></script>
<script type="module" src="/static/js/editor.js"></script>

View File

@ -1,5 +1,4 @@
{{template "layout/header.html" .}} {{template "layout/header.html" .}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.0/styles/github-dark.min.css">
<body> <body>
{{template "layout/nav.html" .}} {{template "layout/nav.html" .}}
@ -9,21 +8,21 @@
<header class="post-detail-header"> <header class="post-detail-header">
<h1 class="post-detail-title">{{.Post.Title}}</h1> <h1 class="post-detail-title">{{.Post.Title}}</h1>
<div class="post-detail-meta"> <div class="post-detail-meta">
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}UID{{.Post.UserID}}{{end}}</span> <span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span>
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span> <span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if ne .Post.Status "approved"}} {{if ne .Post.Status "approved"}}
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span> <span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
{{end}} {{end}}
{{if .Post.IsLocked}}
<span class="post-status post-status-locked">已锁定</span>
{{end}}
{{if and .Post.PendingBody (eq $.Post.UserID $.UID)}}
<span class="post-status post-status-revision">修订审核中</span>
{{end}}
</div> </div>
</header> </header>
<div class="post-detail-body"> <div class="post-detail-body" id="postContent"></div>
{{.PostBodyHTML}}
{{/* 如果 BodyHTML 为空,直接显示原文 */}}
</div>
{{if not .PostBodyHTML}}
<div class="post-detail-body-raw">{{.Post.Body}}</div>
{{end}}
{{if .Post.RejectReason}} {{if .Post.RejectReason}}
<div class="post-reject-reason"> <div class="post-reject-reason">
@ -34,7 +33,7 @@
{{if .IsLoggedIn}} {{if .IsLoggedIn}}
<div class="post-actions"> <div class="post-actions">
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (ne $.Post.Status "locked")}} {{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (not $.Post.IsLocked)}}
<a href="/posts/{{$.Post.ID}}/edit" class="btn btn-secondary">编辑</a> <a href="/posts/{{$.Post.ID}}/edit" class="btn btn-secondary">编辑</a>
{{end}} {{end}}
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected"))}} {{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected"))}}
@ -46,38 +45,46 @@
{{template "layout/footer.html" .}} {{template "layout/footer.html" .}}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.0/highlight.min.js"></script> {{/* MD 内容安全传递给 JS */}}
<script src="/static/js/common.js"></script> <textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
<script src="/static/vditor/dist/method.min.js?v={{assetV "/static/vditor/dist/method.min.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/shortcode.js?v={{assetV "/static/js/shortcode.js"}}"></script>
<script> <script>
// ---- 代码块语言标签注入 + 语法高亮 ----
(function() { (function() {
document.querySelectorAll('.post-detail-body pre').forEach(function(pre) { var el = document.getElementById('postContent');
// 从 pre 的 data-language 或 code 的 class 中获取语言 var mdEl = document.getElementById('postMdContent');
var lang = pre.getAttribute('data-language'); if (!el || !mdEl) return;
if (!lang) {
var code = pre.querySelector('code'); var md = mdEl.value;
if (code) { Vditor.preview(el, md, {
var match = code.className.match(/language-(\w+)/); cdn: '/static/vditor',
if (match) lang = match[1]; theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
after: function() {
// 外部链接自动加 nofollow + target="_blank",避免权重流失
var host = window.location.host;
var links = el.querySelectorAll('a[href^="http"]');
for (var i = 0; i < links.length; i++) {
var a = links[i];
if (a.host && a.host !== host) {
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
} }
} }
lang = lang || 'text';
var label = document.createElement('div');
label.className = 'code-lang-label';
label.textContent = lang;
pre.insertBefore(label, pre.firstChild);
pre.style.paddingTop = '32px';
// 语法高亮
var code = pre.querySelector('code');
if (code && lang !== 'text') {
code.classList.add('language-' + lang);
hljs.highlightElement(code);
} }
}); });
})();
// Vditor.preview() 渲染 MDshortcode 占位 div 原样保留
// 渲染完成后,将占位 div 替换为对应卡片 UI
renderShortcodes(el);
})();
</script>
<script>
// 提交审核按钮
(function() { (function() {
var btn = document.getElementById('submitAuditBtn'); var btn = document.getElementById('submitAuditBtn');
if (!btn) return; if (!btn) return;

View File

@ -24,6 +24,15 @@
</svg> </svg>
账号信息 账号信息
</a> </a>
<a href="/settings/sessions"
class="settings-nav-item {{if eq .ActiveTab "sessions"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
登录管理
</a>
</nav> </nav>
</aside> </aside>
@ -87,6 +96,57 @@
</div> </div>
</div> </div>
</div> </div>
{{else if eq .ActiveTab "sessions"}}
<!-- 登录管理 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>登录管理</h2>
<p class="settings-card-desc">查看和管理你的登录设备,保护账号安全</p>
</div>
<div class="settings-card-body">
<div class="session-list" id="sessionList">
{{range .Sessions}}
<div class="session-item{{if .IsCurrent}} session-current{{end}}" data-sid="{{.ID}}">
<div class="session-icon">
{{if eq .DeviceInfo.Device "Mobile"}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
{{else if eq .DeviceInfo.Device "Tablet"}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="4" y="2" width="16" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
{{end}}
</div>
<div class="session-info">
<div class="session-title">
<span class="session-browser">{{.DeviceInfo.Browser}}</span>
<span class="session-os">/ {{.DeviceInfo.OS}}</span>
{{if .IsCurrent}}<span class="session-badge">当前设备</span>{{end}}
{{if not .RememberMe}}<span class="session-badge session-badge-temp">临时会话</span>{{end}}
</div>
<div class="session-meta">
<span class="session-ip">IP: {{.IP}}</span>
<span class="session-time">登录于 {{.CreatedAt.Format "2006-01-02 15:04"}}</span>
<span class="session-time">最后活动 {{.LastAccess.Format "2006-01-02 15:04"}}</span>
{{if not .IsCurrent}}<span class="session-ttl">剩余约 {{formatTTL .TTLMinutes}}</span>{{end}}
</div>
<div class="session-remark-row">
<input type="text" class="session-remark-input" value="{{.Remark}}" placeholder="添加备注(如 我的笔记本)" maxlength="32" data-sid="{{.ID}}" data-current="{{.IsCurrent}}">
</div>
</div>
<div class="session-action">
{{if not .IsCurrent}}
<button class="session-kick-btn" data-sid="{{.ID}}">踢出</button>
{{end}}
</div>
</div>
{{end}}
{{if not .Sessions}}
<div class="session-empty">暂无活跃的登录会话</div>
{{end}}
</div>
</div>
<div class="settings-card-footer">
<button id="kickOthersBtn" class="settings-danger-btn">一键踢出其他设备</button>
<span class="form-hint">将强制退出除当前设备外的所有登录会话</span>
</div>
</div>
{{else}} {{else}}
<!-- 账号信息 --> <!-- 账号信息 -->
<div class="settings-card"> <div class="settings-card">
@ -152,7 +212,7 @@
<div class="settings-card settings-card-danger"> <div class="settings-card settings-card-danger">
<div class="settings-card-header"> <div class="settings-card-header">
<h2>注销账号</h2> <h2>注销账号</h2>
<p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录恢复</p> <p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录撤销注销</p>
</div> </div>
<div class="settings-card-body"> <div class="settings-card-body">
<div class="form-group"> <div class="form-group">
@ -178,7 +238,7 @@
<!-- 头像裁切弹窗 --> <!-- 头像裁切弹窗 -->
<div class="crop-overlay" id="cropOverlay"> <div class="crop-overlay" id="cropOverlay">
<div class="crop-dialog"> <div class="crop-dialog">
<div class="crop-header">调整裁切范围</div> <div class="crop-header">调整裁切范围 <span class="crop-info" id="cropInfo"></span></div>
<div class="crop-stage" id="cropStage"> <div class="crop-stage" id="cropStage">
<img id="cropImageEl" draggable="false"> <img id="cropImageEl" draggable="false">
<div class="crop-mask" id="cropMask"></div> <div class="crop-mask" id="cropMask"></div>
@ -193,7 +253,7 @@
</div> </div>
</div> </div>
<script src="/static/js/common.js"></script> <script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
@ -316,9 +376,11 @@
var pendingFile = null; var pendingFile = null;
var imgNW, imgNH; // 原图像素尺寸 var imgNW, imgNH; // 原图像素尺寸
var imgDW, imgDH; // 图片缩放后显示尺寸zoom=1 基准) var imgDW, imgDH; // 图片缩放后显示尺寸zoom=1 基准)
var zoom = 1; // 图片缩放系数 1.0 ~ 3.0 var zoom = 1; // 图片缩放系数
var minZoom = 1; // 最小缩放系数(超大图限制选区不超过 3840px
var panX = 0, panY = 0; // 图片平移偏移量 var panX = 0, panY = 0; // 图片平移偏移量
var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶 var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶
var CROP_MAX_PX = 3840; // 选区最大像素限制
// 更新裁切框固定参数(图片加载时计算一次,之后不变) // 更新裁切框固定参数(图片加载时计算一次,之后不变)
function initFrame() { function initFrame() {
@ -334,8 +396,6 @@
var iw = imgDW * zoom, ih = imgDH * zoom; var iw = imgDW * zoom, ih = imgDH * zoom;
var cx = Math.round((sw - iw) / 2); // 图片居中时的基准偏移 var cx = Math.round((sw - iw) / 2); // 图片居中时的基准偏移
var cy = Math.round((sh - ih) / 2); var cy = Math.round((sh - ih) / 2);
// 图片左边界必须在框左边之左cx + panX <= fLeft → panX <= fLeft - cx
// 图片右边界必须在框右边之右cx + panX + iw >= fLeft + fSize → panX >= fLeft + fSize - cx - iw
panX = Math.max(fLeft + fSize - cx - iw, Math.min(panX, fLeft - cx)); panX = Math.max(fLeft + fSize - cx - iw, Math.min(panX, fLeft - cx));
panY = Math.max(fTop + fSize - cy - ih, Math.min(panY, fTop - cy)); panY = Math.max(fTop + fSize - cy - ih, Math.min(panY, fTop - cy));
} }
@ -351,7 +411,6 @@
cropImageEl.style.left = ox + 'px'; cropImageEl.style.left = ox + 'px';
cropImageEl.style.top = oy + 'px'; cropImageEl.style.top = oy + 'px';
// 裁切框固定不变
cropFrame.style.left = fLeft + 'px'; cropFrame.style.left = fLeft + 'px';
cropFrame.style.top = fTop + 'px'; cropFrame.style.top = fTop + 'px';
cropFrame.style.width = fSize + 'px'; cropFrame.style.width = fSize + 'px';
@ -361,6 +420,11 @@
cropMask.style.top = fTop + 'px'; cropMask.style.top = fTop + 'px';
cropMask.style.width = fSize + 'px'; cropMask.style.width = fSize + 'px';
cropMask.style.height = fSize + 'px'; cropMask.style.height = fSize + 'px';
// 实时显示选区像素尺寸
var p = getCropParams();
var infoEl = document.getElementById('cropInfo');
if (infoEl) infoEl.textContent = p.size + ' × ' + p.size + ' px';
} }
function openCrop(file) { function openCrop(file) {
@ -374,7 +438,11 @@
var fit = Math.min(sw / imgNW, sh / imgNH); var fit = Math.min(sw / imgNW, sh / imgNH);
imgDW = Math.round(imgNW * fit); imgDW = Math.round(imgNW * fit);
imgDH = Math.round(imgNH * fit); imgDH = Math.round(imgNH * fit);
zoom = 1; // 限制选框最大像素:原图短边超过 3840 时,抬高最小缩放
minZoom = Math.min(imgNW, imgNH) > CROP_MAX_PX
? Math.min(imgNW, imgNH) / CROP_MAX_PX
: 1;
zoom = minZoom;
panX = 0; panX = 0;
panY = 0; panY = 0;
initFrame(); initFrame();
@ -391,16 +459,14 @@
pendingFile = null; pendingFile = null;
} }
// 将裁切框映射回原图像素坐标
function getCropParams() { function getCropParams() {
var sw = cropStage.clientWidth, sh = cropStage.clientHeight; var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
var iw = imgDW * zoom, ih = imgDH * zoom; var iw = imgDW * zoom, ih = imgDH * zoom;
var ox = Math.round((sw - iw) / 2) + panX; var ox = Math.round((sw - iw) / 2) + panX;
var oy = Math.round((sh - ih) / 2) + panY; var oy = Math.round((sh - ih) / 2) + panY;
// 裁切框中心在 zoom=1 图片坐标中的位置
var cx = (fLeft + fSize / 2 - ox) / zoom; var cx = (fLeft + fSize / 2 - ox) / zoom;
var cy = (fTop + fSize / 2 - oy) / zoom; var cy = (fTop + fSize / 2 - oy) / zoom;
var cs = fSize / zoom; // zoom=1 坐标中的裁切边长 var cs = fSize / zoom;
var sx = imgNW / imgDW, sy = imgNH / imgDH; var sx = imgNW / imgDW, sy = imgNH / imgDH;
return { return {
x: Math.round((cx - cs / 2) * sx), x: Math.round((cx - cs / 2) * sx),
@ -409,7 +475,7 @@
}; };
} }
// ---- 图片平移(点击拖拽 stage 任意位置) ---- // ---- 图片平移 ----
var panning = false, panStartX = 0, panStartY = 0, panInitX = 0, panInitY = 0; var panning = false, panStartX = 0, panStartY = 0, panInitX = 0, panInitY = 0;
function startPan(clientX, clientY) { function startPan(clientX, clientY) {
@ -441,7 +507,6 @@
cropStage.style.cursor = 'grab'; cropStage.style.cursor = 'grab';
}); });
// 触屏平移
cropStage.addEventListener('touchstart', function (e) { cropStage.addEventListener('touchstart', function (e) {
e.preventDefault(); e.preventDefault();
var t = e.touches[0]; var t = e.touches[0];
@ -457,11 +522,10 @@
cropStage.style.cursor = 'grab'; cropStage.style.cursor = 'grab';
}); });
// ---- 滚轮缩放 ----
cropStage.addEventListener('wheel', function (e) { cropStage.addEventListener('wheel', function (e) {
e.preventDefault(); e.preventDefault();
if (panning) return; if (panning) return;
var newZoom = Math.max(1, Math.min(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), 3)); var newZoom = Math.max(minZoom, Math.min(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), 3));
if (newZoom === zoom) return; if (newZoom === zoom) return;
zoom = newZoom; zoom = newZoom;
clampPan(); clampPan();
@ -473,19 +537,52 @@
if (e.target === cropOverlay) closeCrop(); if (e.target === cropOverlay) closeCrop();
}); });
// 客户端裁剪 + 压缩Canvas 裁出选区并一步到位缩放到 512px上传体积最小
function cropOnClient(file, x, y, size) {
return new Promise(function (resolve) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
URL.revokeObjectURL(url);
var out = 512; // 直接缩放到服务端目标输出尺寸
var canvas = document.createElement('canvas');
canvas.width = out;
canvas.height = out;
var ctx = canvas.getContext('2d');
ctx.drawImage(img, x, y, size, size, 0, 0, out, out);
canvas.toBlob(function (blob) {
resolve({
file: new File([blob], 'avatar.jpg', { type: blob.type || 'image/jpeg' }),
cropX: 0,
cropY: 0,
cropSize: out
});
}, 'image/jpeg', 0.88);
};
img.onerror = function () {
URL.revokeObjectURL(url);
// Canvas 处理失败时回退:直接上传原文件 + 原坐标
resolve({ file: file, cropX: x, cropY: y, cropSize: size });
};
img.src = url;
});
}
cropConfirmBtn.addEventListener('click', function () { cropConfirmBtn.addEventListener('click', function () {
if (!pendingFile) return; if (!pendingFile) return;
var params = getCropParams(); var params = getCropParams();
var formData = new FormData();
formData.append('avatar', pendingFile);
formData.append('crop_x', params.x);
formData.append('crop_y', params.y);
formData.append('crop_size', params.size);
cropConfirmBtn.disabled = true; cropConfirmBtn.disabled = true;
cropConfirmBtn.textContent = '处理中...'; cropConfirmBtn.textContent = '处理中...';
showSettingsToast('上传中...'); showSettingsToast('上传中...');
cropOnClient(pendingFile, params.x, params.y, params.size).then(function (result) {
var formData = new FormData();
formData.append('avatar', result.file, result.file.name);
formData.append('crop_x', result.cropX);
formData.append('crop_y', result.cropY);
formData.append('crop_size', result.cropSize);
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings/avatar', true); xhr.open('POST', '/api/settings/avatar', true);
xhr.onreadystatechange = function () { xhr.onreadystatechange = function () {
@ -509,6 +606,11 @@
} }
}; };
xhr.send(formData); xhr.send(formData);
}).catch(function () {
cropConfirmBtn.disabled = false;
cropConfirmBtn.textContent = '确认';
showSettingsToast('处理失败,请重试', true);
});
}); });
if (avatarWrap && avatarInput) { if (avatarWrap && avatarInput) {
@ -554,7 +656,6 @@
showSettingsToast('密码已修改,请重新登录'); showSettingsToast('密码已修改,请重新登录');
currentPwd.value = ''; currentPwd.value = '';
newPwd.value = ''; newPwd.value = '';
// 延时后执行登出并跳转到首页
setTimeout(function () { setTimeout(function () {
window.location.href = '/'; window.location.href = '/';
}, 2000); }, 2000);
@ -582,7 +683,7 @@
showSettingsToast('请输入密码确认', true); showSettingsToast('请输入密码确认', true);
return; return;
} }
if (!confirm('确定要注销账号吗?\n\n注销后 7 天内重新登录可自动恢复,期满后将永久删除。')) { if (!confirm('确定要注销账号吗?\n\n注销后 7 天内重新登录可撤销注销,期满后将永久删除。')) {
return; return;
} }
deleteAccountBtn.disabled = true; deleteAccountBtn.disabled = true;
@ -615,6 +716,134 @@
})); }));
}); });
} }
// ===== 登录管理 Tab =====
(function () {
var kickBtns = document.querySelectorAll('.session-kick-btn');
var kickOthersBtn = document.getElementById('kickOthersBtn');
var remarkInputs = document.querySelectorAll('.session-remark-input');
if (!kickBtns.length && !kickOthersBtn && !remarkInputs.length) return;
// ---- 踢出单个设备 ----
kickBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
var sid = btn.getAttribute('data-sid');
if (!sid) return;
if (!confirm('确定要踢出该设备吗?')) return;
btn.disabled = true;
btn.textContent = '踢出中...';
var xhr = new XMLHttpRequest();
xhr.open('DELETE', '/api/settings/sessions/' + encodeURIComponent(sid), true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
btn.disabled = false;
btn.textContent = '踢出';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '已踢出该设备');
var item = btn.closest('.session-item');
if (item) {
item.style.transition = 'opacity .3s ease, transform .3s ease';
item.style.opacity = '0';
item.style.transform = 'translateX(20px)';
setTimeout(function () { item.remove(); }, 300);
}
} else {
showSettingsToast(res.message || '操作失败', true);
}
} catch (e) {
showSettingsToast('操作失败', true);
}
};
xhr.send();
});
});
// ---- 一键踢出其他设备 ----
if (kickOthersBtn) {
kickOthersBtn.addEventListener('click', function () {
var otherCount = document.querySelectorAll('.session-item:not(.session-current)').length;
if (otherCount === 0) {
showSettingsToast('没有其他在线设备');
return;
}
if (!confirm('确定要踢出所有其他设备(共 ' + otherCount + ' 台)吗?')) return;
kickOthersBtn.disabled = true;
kickOthersBtn.textContent = '处理中...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings/sessions/destroy-others', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
kickOthersBtn.disabled = false;
kickOthersBtn.textContent = '一键踢出其他设备';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '已踢出所有其他设备');
var others = document.querySelectorAll('.session-item:not(.session-current)');
others.forEach(function (item, i) {
setTimeout(function () {
item.style.transition = 'opacity .3s ease, transform .3s ease';
item.style.opacity = '0';
item.style.transform = 'translateX(20px)';
setTimeout(function () { item.remove(); }, 300);
}, i * 100);
});
} else {
showSettingsToast(res.message || '操作失败', true);
}
} catch (e) {
showSettingsToast('操作失败', true);
}
};
xhr.send();
});
}
// ---- 备注保存 ----
var remarkTimers = {};
remarkInputs.forEach(function (input) {
input.addEventListener('blur', function () {
var sid = input.getAttribute('data-sid');
var remark = input.value.trim();
if (!sid) return;
saveRemark(sid, remark);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
input.blur();
}
});
});
function saveRemark(sid, remark) {
if (remarkTimers[sid]) clearTimeout(remarkTimers[sid]);
remarkTimers[sid] = setTimeout(function () {
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/settings/sessions/' + encodeURIComponent(sid) + '/remark', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast('备注已保存');
} else {
showSettingsToast(res.message || '保存失败', true);
}
} catch (e) {
showSettingsToast('保存失败', true);
}
};
xhr.send(JSON.stringify({ remark: remark }));
}, 500);
}
})();
})(); })();
</script> </script>
</body> </body>

View File

@ -0,0 +1,93 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
{{if .Error}}
<div class="space-error">
<div class="container">
<h1>{{.Error}}</h1>
<a href="/" class="btn btn-primary">返回首页</a>
</div>
</div>
{{else}}
<div class="space-page">
<!-- 用户信息头部 -->
<div class="space-header">
<div class="container space-header-inner">
<div class="space-avatar">
{{if .SpaceUser.Avatar}}
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}">
{{else}}
<div class="space-avatar-placeholder">{{slice .SpaceUser.Username 0 1}}</div>
{{end}}
</div>
<div class="space-info">
<h1 class="space-username">{{.SpaceUser.Username}}</h1>
<p class="space-bio">{{if .SpaceUser.Bio}}{{.SpaceUser.Bio}}{{else}}&nbsp;{{end}}</p>
<div class="space-meta">
<span class="space-uid">UID: {{.SpaceUser.ID}}</span>
<span class="space-divider">·</span>
<span class="space-joined">加入于 {{.SpaceUser.CreatedAt.Format "2006-01-02"}}</span>
</div>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="container space-content">
<!-- 统计栏 -->
<div class="space-stats">
<div class="space-stat-item">
<span class="space-stat-num">{{.Total}}</span>
<span class="space-stat-label">稿件</span>
</div>
</div>
<!-- 稿件列表 -->
<div class="space-section">
<h2 class="space-section-title">稿件</h2>
{{if eq (len .Posts) 0}}
<div class="empty-state">暂无稿件</div>
{{else}}
<div class="space-posts-list">
{{range .Posts}}
<article class="space-post-card">
<div class="space-post-main">
<h3 class="space-post-title">
<a href="/posts/{{.ID}}">{{.Title}}</a>
</h3>
<p class="space-post-excerpt">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>
<div class="space-post-meta">
<span class="space-post-status">已发布</span>
<span class="space-post-time">{{.CreatedAt.Format "2006-01-02"}}</span>
</div>
</div>
</article>
{{end}}
</div>
{{end}}
<!-- 分页 -->
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="?page={{.PrevPage}}" class="page-link">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页 (共 {{.Total}} 条)</span>
{{if lt .Page .TotalPages}}
<a href="?page={{.NextPage}}" class="page-link">下一页</a>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body>
</html>

View File

@ -0,0 +1,72 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="studio-wrapper">
{{template "studio/sidebar.html" .}}
<main class="studio-main">
<div class="studio-header">
<h1>数据分析</h1>
<span class="studio-subtitle">了解你的创作表现</span>
</div>
{{if .Error}}
<div class="studio-error">{{.Error}}</div>
{{else}}
<div class="stat-cards">
<div class="stat-card">
<div class="stat-card-icon">📝</div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.TotalPosts}}</span>
<span class="stat-card-label">文章总数</span>
</div>
</div>
<div class="stat-card stat-card-approved">
<div class="stat-card-icon"></div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.ApprovedCount}}</span>
<span class="stat-card-label">已发布</span>
</div>
</div>
<div class="stat-card stat-card-draft">
<div class="stat-card-icon">📄</div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.DraftCount}}</span>
<span class="stat-card-label">草稿</span>
</div>
</div>
</div>
<div class="studio-section">
<h2 class="section-title">状态分布</h2>
<div class="status-bar">
{{if gt .Overview.ApprovedCount 0}}
<div class="status-bar-segment status-bar-approved" style="flex:{{.Overview.ApprovedCount}}">已发布 {{.Overview.ApprovedCount}}</div>
{{end}}
{{if gt .Overview.DraftCount 0}}
<div class="status-bar-segment status-bar-draft" style="flex:{{.Overview.DraftCount}}">草稿 {{.Overview.DraftCount}}</div>
{{end}}
{{if gt .Overview.PendingCount 0}}
<div class="status-bar-segment status-bar-pending" style="flex:{{.Overview.PendingCount}}">审核中 {{.Overview.PendingCount}}</div>
{{end}}
{{if gt .Overview.RejectedCount 0}}
<div class="status-bar-segment status-bar-rejected" style="flex:{{.Overview.RejectedCount}}">已退回 {{.Overview.RejectedCount}}</div>
{{end}}
</div>
</div>
<div class="studio-section">
<div class="studio-placeholder">
<p>📈 更详细的阅读趋势、互动数据将在后续版本中推出</p>
</div>
</div>
{{end}}
</main>
</div>
{{template "layout/footer.html" .}}
</body>
</html>

View File

@ -0,0 +1,105 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="studio-wrapper">
{{template "studio/sidebar.html" .}}
<main class="studio-main">
<div class="studio-header">
<h1>草稿箱</h1>
<span class="studio-subtitle">共 {{.Total}} 篇草稿</span>
</div>
{{if .Error}}
<div class="studio-error">{{.Error}}</div>
{{else}}
{{if .Posts}}
<div class="post-list">
{{range .Posts}}
<div class="post-item">
<div class="post-item-main">
<span class="post-item-title draft-title">{{if .Title}}{{.Title}}{{else}}无标题{{end}}</span>
<div class="post-item-meta">
<span class="post-status-badge status-draft">草稿</span>
<span>{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
</div>
<div class="post-item-actions">
<a href="/studio/write?id={{.ID}}" class="action-btn primary">继续编辑</a>
<button class="action-btn" onclick="submitPost({{.ID}})" title="提交发布">提交</button>
<button class="action-btn danger" onclick="deletePost({{.ID}})" title="删除">删除</button>
</div>
</div>
{{end}}
</div>
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="/studio/drafts?page={{subtract .Page 1}}" class="page-btn">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页</span>
{{if lt .Page .TotalPages}}
<a href="/studio/drafts?page={{add .Page 1}}" class="page-btn">下一页</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="studio-empty">
<p>✨ 草稿箱是空的</p>
<a href="/studio/write" class="btn-primary">开始写作</a>
</div>
{{end}}
{{end}}
</main>
</div>
<script>
function submitPost(id) {
if (!confirm('确定要提交这篇草稿进行审核吗?')) return;
fetch('/api/studio/posts/' + id + '/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{.CSRFToken}}'
}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || '提交失败');
}
});
}
function deletePost(id) {
if (!confirm('确定要删除这篇草稿吗?此操作不可恢复。')) return;
fetch('/api/studio/posts/' + id, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{.CSRFToken}}'
}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || '删除失败');
}
});
}
</script>
{{template "layout/footer.html" .}}
</body>
</html>

View File

@ -0,0 +1,95 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="studio-wrapper">
{{template "studio/sidebar.html" .}}
<main class="studio-main">
<div class="studio-header">
<h1>创作中心</h1>
<span class="studio-subtitle">数据概览</span>
</div>
{{if .Error}}
<div class="studio-error">{{.Error}}</div>
{{else}}
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-card-icon">📝</div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.TotalPosts}}</span>
<span class="stat-card-label">文章总数</span>
</div>
</div>
<div class="stat-card stat-card-approved">
<div class="stat-card-icon"></div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.ApprovedCount}}</span>
<span class="stat-card-label">已发布</span>
</div>
</div>
<div class="stat-card stat-card-pending">
<div class="stat-card-icon"></div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.PendingCount}}</span>
<span class="stat-card-label">审核中</span>
</div>
</div>
<div class="stat-card stat-card-draft">
<div class="stat-card-icon">📄</div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.DraftCount}}</span>
<span class="stat-card-label">草稿</span>
</div>
</div>
<div class="stat-card stat-card-rejected">
<div class="stat-card-icon">↩️</div>
<div class="stat-card-body">
<span class="stat-card-value">{{.Overview.RejectedCount}}</span>
<span class="stat-card-label">已退回</span>
</div>
</div>
</div>
<!-- 状态分布 -->
{{if gt .Overview.TotalPosts 0}}
<div class="studio-section">
<h2 class="section-title">内容分布</h2>
<div class="status-bar">
{{if gt .Overview.ApprovedCount 0}}
<div class="status-bar-segment status-bar-approved" style="flex:{{.Overview.ApprovedCount}}">已发布 {{.Overview.ApprovedCount}}</div>
{{end}}
{{if gt .Overview.DraftCount 0}}
<div class="status-bar-segment status-bar-draft" style="flex:{{.Overview.DraftCount}}">草稿 {{.Overview.DraftCount}}</div>
{{end}}
{{if gt .Overview.PendingCount 0}}
<div class="status-bar-segment status-bar-pending" style="flex:{{.Overview.PendingCount}}">审核中 {{.Overview.PendingCount}}</div>
{{end}}
{{if gt .Overview.RejectedCount 0}}
<div class="status-bar-segment status-bar-rejected" style="flex:{{.Overview.RejectedCount}}">已退回 {{.Overview.RejectedCount}}</div>
{{end}}
</div>
</div>
{{end}}
<!-- 快捷入口 -->
<div class="studio-section">
<h2 class="section-title">快捷操作</h2>
<div class="quick-actions">
<a href="/studio/write" class="quick-action-btn primary">✏️ 写新文章</a>
<a href="/studio/drafts" class="quick-action-btn">📄 草稿箱{{if gt .Overview.DraftCount 0}}{{.Overview.DraftCount}}{{end}}</a>
<a href="/studio/posts" class="quick-action-btn">📋 管理内容</a>
<a href="/studio/analytics" class="quick-action-btn">📈 数据分析</a>
</div>
</div>
{{end}}
</main>
</div>
{{template "layout/footer.html" .}}
</body>
</html>

View File

@ -0,0 +1,105 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="studio-wrapper">
{{template "studio/sidebar.html" .}}
<main class="studio-main">
<div class="studio-header">
<h1>内容管理</h1>
{{if .Overview}}
<div class="header-counts">
<span class="header-count approved">已发布 {{.Overview.ApprovedCount}}</span>
<span class="header-count pending">审核中 {{.Overview.PendingCount}}</span>
<span class="header-count draft">草稿 {{.Overview.DraftCount}}</span>
<span class="header-count rejected">已退回 {{.Overview.RejectedCount}}</span>
</div>
{{end}}
</div>
{{if .Error}}
<div class="studio-error">{{.Error}}</div>
{{else}}
<!-- 状态筛选 tabs -->
<div class="status-tabs">
<a href="/studio/posts" class="status-tab {{if not .CurrentStatus}}active{{end}}">全部</a>
<a href="/studio/posts?status=approved" class="status-tab {{if eq .CurrentStatus "approved"}}active{{end}}">已发布</a>
<a href="/studio/posts?status=pending" class="status-tab {{if eq .CurrentStatus "pending"}}active{{end}}">审核中</a>
<a href="/studio/posts?status=draft" class="status-tab {{if eq .CurrentStatus "draft"}}active{{end}}">草稿</a>
<a href="/studio/posts?status=rejected" class="status-tab {{if eq .CurrentStatus "rejected"}}active{{end}}">已退回</a>
</div>
{{if .Posts}}
<div class="post-list">
{{range .Posts}}
<div class="post-item">
<div class="post-item-main">
<a href="/posts/{{.ID}}" class="post-item-title" target="_blank">{{.Title}}</a>
<div class="post-item-meta">
<span class="post-status-badge status-{{.Status}}">{{index $.StatusNames .Status}}</span>
<span>{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if .RejectReason}}
<span class="post-reject-hint">退回理由:{{.RejectReason}}</span>
{{end}}
</div>
</div>
<div class="post-item-actions">
<a href="/studio/write?id={{.ID}}" class="action-btn">编辑</a>
<a href="/posts/{{.ID}}" target="_blank" class="action-btn">查看</a>
<button class="action-btn danger" onclick="deletePost({{.ID}})" title="删除">删除</button>
</div>
</div>
{{end}}
</div>
<!-- 分页 -->
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="/studio/posts?status={{$.CurrentStatus}}&page={{subtract .Page 1}}" class="page-btn">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页(共 {{.Total}} 篇)</span>
{{if lt .Page .TotalPages}}
<a href="/studio/posts?status={{$.CurrentStatus}}&page={{add .Page 1}}" class="page-btn">下一页</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="studio-empty">
<p>还没有{{if .CurrentStatus}}{{index $.StatusNames .CurrentStatus}}{{end}}内容的文章</p>
<a href="/studio/write" class="btn-primary">去写一篇</a>
</div>
{{end}}
{{end}}
</main>
</div>
<script>
function deletePost(id) {
if (!confirm('确定要删除这篇文章吗?')) return;
fetch('/api/studio/posts/' + id, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{.CSRFToken}}'
}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || '删除失败');
}
});
}
</script>
{{template "layout/footer.html" .}}
</body>
</html>

View File

@ -0,0 +1,25 @@
<aside class="studio-sidebar">
<div class="sidebar-brand">创作中心</div>
<nav class="sidebar-nav">
<a href="/studio" class="sidebar-item {{if eq .ActiveTab "overview"}}active{{end}}">
<span class="sidebar-icon">📊</span>
<span>数据概览</span>
</a>
<a href="/studio/posts" class="sidebar-item {{if eq .ActiveTab "posts"}}active{{end}}">
<span class="sidebar-icon">📋</span>
<span>内容管理</span>
</a>
<a href="/studio/drafts" class="sidebar-item {{if eq .ActiveTab "drafts"}}active{{end}}">
<span class="sidebar-icon">📄</span>
<span>草稿箱</span>
</a>
<a href="/studio/write" class="sidebar-item {{if eq .ActiveTab "write"}}active{{end}}">
<span class="sidebar-icon">✏️</span>
<span>写文章</span>
</a>
<a href="/studio/analytics" class="sidebar-item {{if eq .ActiveTab "analytics"}}active{{end}}">
<span class="sidebar-icon">📈</span>
<span>数据分析</span>
</a>
</nav>
</aside>

View File

@ -0,0 +1,72 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="studio-wrapper">
{{template "studio/sidebar.html" .}}
<main class="studio-main">
<div class="editor-layout">
<form id="postForm" class="editor-form">
<input type="hidden" id="postId" value="{{if .Post}}{{.Post.ID}}{{end}}">
{{if .Post}}
<textarea id="editBody" style="display:none">{{.Post.Body}}</textarea>
{{end}}
<div class="editor-header">
<input type="text" id="postTitle" class="editor-title-input"
value="{{if .Post}}{{.Post.Title}}{{end}}"
placeholder="输入文章标题..."
maxlength="200" required autofocus>
</div>
<div class="editor-main">
<div class="editor-pane">
<div id="vditor"></div>
</div>
<aside class="editor-sidebar">
<div class="editor-sidebar-section">
<h4>快捷语法</h4>
<ul class="tips-list">
<li><code>#</code> <code>##</code> <code>###</code> 标题</li>
<li><code>**粗体**</code> <code>*斜体*</code></li>
<li><code>`行内代码`</code></li>
<li><code>```</code> 代码块</li>
<li><code>&gt;</code> 引用</li>
<li><code>-</code> 无序列表</li>
<li><code>[文字](链接)</code></li>
<li><code>![图片](链接)</code></li>
</ul>
</div>
<div class="editor-sidebar-section">
<h4>发布提示</h4>
<ul class="tips-list">
<li>标题控制在 50 字以内</li>
<li>正文清晰分段,便于阅读</li>
<li>代码使用代码块包裹</li>
<li>Ctrl+S 快速提交</li>
</ul>
</div>
</aside>
</div>
<div class="editor-statusbar">
<span class="status-item" id="wordCount">0 字</span>
<span class="status-item status-draft" id="draftStatus" style="display:none">草稿已保存</span>
<span class="status-spacer"></span>
<button type="submit" class="btn btn-primary btn-submit">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
</div>
</form>
</div>
</main>
</div>
{{template "layout/footer.html" .}}
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
<script src="/static/vditor/dist/index.min.js?v={{assetV "/static/vditor/dist/index.min.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/studio-editor.js?v={{assetV "/static/js/studio-editor.js"}}"></script>
</body>
</html>

View File

@ -36,6 +36,25 @@
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* ---------- Registration Closed Notice ---------- */
.registration-closed {
text-align: center;
padding: 2.5rem 1rem;
margin-bottom: .5rem;
}
.registration-closed .closed-message {
font-size: 1.05rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: .5rem;
}
.registration-closed .closed-subtitle {
font-size: .9rem;
color: var(--color-secondary);
}
/* ---------- Form ---------- */ /* ---------- Form ---------- */
.form-group { .form-group {
margin-bottom: 1.2rem; margin-bottom: 1.2rem;

View File

@ -26,13 +26,23 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {} html {
height: 100%;
}
body { body {
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
line-height: 1.6; line-height: 1.6;
overflow-x: hidden; overflow-x: hidden;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 主内容区撑满剩余空间,实现 sticky footer */
body > .container {
flex: 1;
} }
a { a {
@ -126,6 +136,11 @@ header {
font-weight: 600 !important; font-weight: 600 !important;
} }
.nav-settings {
font-size: .9rem;
color: var(--color-secondary) !important;
}
.nav-logout { .nav-logout {
color: var(--color-secondary) !important; color: var(--color-secondary) !important;
} }
@ -178,6 +193,22 @@ header {
cursor: pointer; cursor: pointer;
} }
/* ---------- Maintenance Banner ---------- */
.maintenance-banner {
text-align: center;
padding: .6rem 1rem;
background: #fef3cd;
border-bottom: 1px solid #f0d48a;
color: #856404;
font-size: .9rem;
font-weight: 500;
}
.maintenance-banner-icon {
font-size: 1rem;
margin-right: .4rem;
}
/* ---------- Contact / Footer ---------- */ /* ---------- Contact / Footer ---------- */
.contact-bar { .contact-bar {
text-align: center; text-align: center;
@ -203,6 +234,7 @@ footer {
background: var(--color-primary); background: var(--color-primary);
color: #bdc3c7; color: #bdc3c7;
padding: .8rem 0; padding: .8rem 0;
flex-shrink: 0;
} }
.footer-content { .footer-content {

View File

@ -100,6 +100,43 @@ body {
min-width: 0; min-width: 0;
} }
/* ---------- System Message Section ---------- */
.msg-system-section {
margin-bottom: 1.25rem;
}
.msg-system-header {
display: flex;
align-items: center;
gap: .5rem;
font-size: .95rem;
font-weight: 600;
color: var(--color-primary);
padding: 0 0 .5rem 0;
}
.msg-system-icon {
color: var(--color-accent);
flex-shrink: 0;
}
.msg-system-divider {
height: 1px;
background: linear-gradient(to right, var(--color-border), transparent);
margin: 0;
}
/* ---------- Message Card Link ---------- */
.msg-card-link {
text-decoration: none;
color: inherit;
display: block;
}
.msg-card-link:hover {
text-decoration: none;
}
/* ---------- Toolbar ---------- */ /* ---------- Toolbar ---------- */
.msg-toolbar { .msg-toolbar {
display: flex; display: flex;

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