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

View File

@ -3,12 +3,14 @@ package main
import (
"log"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/router"
"metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
@ -48,6 +50,9 @@ func main() {
r := gin.Default()
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变)
common.ComputeAssetHashes("templates/MetaLab-2026")
// 模板加载(前端主题 + 管理后台)
tmpl, err := theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
@ -65,6 +70,18 @@ func main() {
r.Static("/uploads/avatars", "./storage/uploads/avatars")
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 持久化 + 内存缓存)
siteSettings, err := config.NewSiteSettings(db)
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)

View File

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

View File

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

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

20
go.sum
View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ var (
ErrInvalidCred = errors.New("邮箱或密码错误")
ErrUserNotFound = errors.New("用户不存在")
ErrUserBanned = errors.New("该账号已被封禁")
ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复")
ErrUserDeleted = errors.New("该账号正在注销,登录即撤销注销")
// 统一返回,避免泄露用户存在性、软删除状态等内部信息
ErrUserLocked = errors.New("邮箱或密码错误")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
@ -32,9 +32,10 @@ var (
// 密码 & 注销相关
ErrIncorrectPassword = errors.New("当前密码错误")
ErrLoginBeforeDelete = errors.New("请先登录再注销")
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号")
ErrNeedsConfirmRestore = errors.New("需要二次确认撤销注销")
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
ErrRegistrationDisabled = errors.New("注册功能已关闭")
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
// 帖子相关
ErrPostNotFound = errors.New("帖子不存在")
@ -42,5 +43,6 @@ var (
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotLock = 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
import (
"fmt"
"hash/crc32"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// assetHashes 静态资源文件哈希表URL 路径 → 短哈希,文件不变哈希不变)
var assetHashes map[string]string
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
func ComputeAssetHashes(templateDir string) {
assetHashes = make(map[string]string)
// 前端主题templates/MetaLab-2026/static → URL /static/
walkStaticDir(filepath.Join(templateDir, "static"), "/static")
// 共享资源templates/shared/static → URL /shared/static/
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
// 管理后台templates/admin/static → URL /admin/static/
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
log.Printf("[AssetHashes] computed %d file hashes", len(assetHashes))
}
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32存入 assetHashes
func walkStaticDir(dir, urlPrefix string) {
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".js" && ext != ".css" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
h := crc32.ChecksumIEEE(data)
shortHash := strconv.FormatUint(uint64(h), 16)
// 生成 URL 路径urlPrefix + 相对于 dir 的子路径
rel, _ := filepath.Rel(dir, path)
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
assetHashes[urlPath] = shortHash
return nil
})
}
// AssetV 返回指定静态资源的哈希版本号(模板函数)
func AssetV(path string) string {
if h, ok := assetHashes[path]; ok {
return h
}
return "0"
}
// mdImageRe 匹配 Markdown 图片语法 ![alt](url)
var mdImageRe = regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
// ExtractFirstImage 从 Markdown 正文提取第一张图片 URL
func ExtractFirstImage(md string) string {
match := mdImageRe.FindStringSubmatch(md)
if len(match) > 1 {
return match[1]
}
return ""
}
// BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
data := gin.H{}
@ -25,6 +95,11 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
// 注入管理入口权限moderator 及以上可看到页脚管理面板链接)
if role, exists := c.Get("role"); exists {
data["CanAccessAdmin"] = model.HasMinRole(role.(string), model.RoleModerator)
data["IsOwner"] = model.HasMinRole(role.(string), model.RoleOwner)
}
// 注入维护状态
if isMaintenance, exists := c.Get("is_maintenance"); exists {
data["IsMaintenance"] = isMaintenance
}
return data
}
@ -55,6 +130,16 @@ func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
return data
}
// RedirectToLogin 重定向到登录页,携带当前页面路径作为 redirect 参数
func RedirectToLogin(c *gin.Context) {
returnURL := url.QueryEscape(c.Request.URL.Path)
if c.Request.URL.RawQuery != "" {
returnURL = url.QueryEscape(c.Request.URL.Path + "?" + c.Request.URL.RawQuery)
}
c.Redirect(http.StatusFound, fmt.Sprintf("/auth/login?redirect=%s", returnURL))
c.Abort()
}
// injectSiteInfo 从 context 注入站点展示信息
func injectSiteInfo(c *gin.Context, data gin.H) {
if framework, exists := c.Get("site_framework"); exists {

View File

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

View File

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

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) {
c.JSON(code, gin.H{"success": false, "message": message})
}
// VditorUploadOk Vditor 图片上传成功响应
// succMap: key=原始文件名, value=文件 URL
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"data": gin.H{
"errFiles": []string{},
"succMap": succMap,
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,29 +3,29 @@ package controller
import (
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
)
// authUseCase AuthController 对 AuthService 的最小依赖ISP4 个方法)
// authUseCase AuthController 对 AuthService 的最小依赖ISP5 个方法)
// 注意Login/Register/ConfirmRestore 不再返回 JWTsession 由 controller 通过 SessionManager 创建
type authUseCase interface {
Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error)
Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error)
Register(req model.RegisterRequest, regIP string) (*model.User, error)
Login(req model.LoginRequest, loginIP string) (*model.User, error)
ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error)
CheckEmail(email string) (bool, error)
IsRegistrationEnabled() bool
}
// tokenRefresher AuthController 对 TokenService 的最小依赖ISP1 个方法)
type tokenRefresher interface {
RefreshAccessToken(refreshTokenStr string) (string, *model.User, error)
}
// rateLimiter AuthController 对 RateLimiter 的最小依赖ISP3 个方法)
// rateLimiter AuthController 对 RateLimiter 的最小依赖ISP4 个方法)
type rateLimiter interface {
AllowAccount(email string) (middleware.RateLimitResult, func())
AllowIP(ip string) (middleware.RateLimitResult, func())
AllowAccount(email string) middleware.RateLimitResult
AllowIP(ip string) middleware.RateLimitResult
Clear(email, ip string)
ClearIP(ipKey string)
}
// postUseCase PostController 对 PostService 的最小依赖ISP6 个方法)
// postUseCase PostController 对 PostService 的最小依赖ISP7 个方法)
type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
@ -33,4 +33,31 @@ type postUseCase interface {
Update(postID uint, title, body string) error
Delete(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) {
uidVal, exists := c.Get("uid")
if !exists {
c.Redirect(http.StatusFound, "/auth/login")
c.Abort()
common.RedirectToLogin(c)
return
}
uid := uidVal.(uint)

View File

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

View File

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

View File

@ -0,0 +1,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 (
"io"
"net/http"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"github.com/gin-gonic/gin"
)
@ -32,30 +34,37 @@ type auditSubmittable interface {
GetPendingTypes(userID uint) ([]string, error)
}
// sessionConfig 登录管理对配置的最小依赖ISP2 个方法)
type sessionConfig interface {
GetIdleTimeout() int // 分钟
GetRememberTimeout() int // 分钟
}
// SettingsController 个人设置控制器
type SettingsController struct {
authService profileProvider
avatarService avatarProvider
auditService auditSubmittable
sessionMgr sessionManager
sessionCfg sessionConfig
}
// NewSettingsController 构造函数
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService}
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg}
}
// SettingsPage 个人设置页面(需登录)
func (sc *SettingsController) SettingsPage(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
c.Redirect(http.StatusFound, "/auth/login")
c.Abort()
common.RedirectToLogin(c)
return
}
uid := uidVal.(uint)
tab := c.Param("tab")
if tab != "profile" && tab != "account" {
if tab != "profile" && tab != "account" && tab != "sessions" {
c.String(http.StatusNotFound, "页面不存在")
return
}
@ -69,7 +78,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
// 查询待审核类型(用于前端显示审核提示)
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
data := gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
@ -78,7 +87,45 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
"StatusName": model.StatusDisplayNames[user.Status],
"PendingTypes": pendingTypes,
"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 查询当前用户的待审核类型(需登录)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,4 +20,37 @@ type CheckEmailRequest struct {
Email string `json:"email" binding:"required,email"`
}
// ---- Post DTOs ----
// PostListResult 帖子列表查询结果
type PostListResult struct {
Items []Post `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
}
// PostCreateRequest 发帖请求
type PostCreateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostUpdateRequest 编辑请求
type PostUpdateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostRejectRequest 退回请求
type PostRejectRequest struct {
Reason string `json:"reason" binding:"required,min=1,max=500"`
}
// 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 (
NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected"
)
// Notification 消息/通知模型
@ -20,8 +22,10 @@ type Notification struct {
// NotifyTypeNames 通知类型 → 中文名
var NotifyTypeNames = map[string]string{
NotifyAuditApproved: "系统通知",
NotifyAuditRejected: "系统通知",
NotifyAuditApproved: "资料审核",
NotifyAuditRejected: "资料审核",
NotifyPostApproved: "稿件审核",
NotifyPostRejected: "稿件审核",
}
// NotificationListResult 消息列表查询结果

View File

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

View File

@ -0,0 +1,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"`
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

View File

@ -45,19 +45,58 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
return &post, nil
}
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
// buildQuery 构建帖子查询公共部分(联表 + 未删除 + 关键词 + 可选状态过滤
func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL").
Where("posts.status = ?", model.PostStatusApproved)
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)
}
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
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
@ -72,28 +111,15 @@ func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post
return posts, total, nil
}
// FindAdminPageable 管理后台分页查询(全状态筛选)
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")
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("posts.title LIKE ?", like)
}
if status != "" {
query = query.Where("posts.status = ?", status)
}
// pageResultsAdmin 管理后台分页待审稿件pending 或 修订待审)置顶优先
func (r *PostRepo) pageResultsAdmin(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) {
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
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
if err != nil {
return nil, 0, err
@ -113,5 +139,12 @@ func (r *PostRepo) SoftDelete(id uint) error {
// Restore 恢复软删除
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
}
// IncrementTokenVersion 递增用户令牌版本,使所有已签发的 JWT 即时失效
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
// FindByIDForAuth 认证专用查询:返回 uid/email/username/role/status
// 比 FindByID 轻量(不查 avatar、bio 等无用字段),避免全量 SELECT *
// 使用默认 scope排除软删除locked 用户不可持有有效 JWT
func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
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
if err != nil {
return nil, err
@ -106,7 +89,7 @@ func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
// FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作
func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
var user model.User
err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "token_version", "deleted_at").
err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "deleted_at").
First(&user, userID).Error
if err != nil {
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.POST("/delete-account", d.settingsCtrl.DeleteAccount)
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
// 登录管理
settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
}
// --- 消息中心 API需登录 ---
@ -35,12 +40,12 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
api := r.Group("/api")
api.Use(middleware.CSRF(cfg))
{
api.POST("/auth/check-email", d.tokenCtrl.CheckEmail)
api.POST("/auth/register", d.tokenCtrl.Register)
api.POST("/auth/login", d.tokenCtrl.Login)
api.POST("/auth/confirm-restore", d.tokenCtrl.ConfirmRestore)
api.POST("/auth/logout", d.tokenCtrl.Logout)
api.POST("/auth/refresh", d.tokenCtrl.RefreshToken)
api.POST("/auth/check-email", d.authCtrl.CheckEmail)
api.POST("/auth/register", d.authCtrl.Register)
api.POST("/auth/login", d.authCtrl.Login)
api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore)
api.POST("/auth/logout", d.authCtrl.Logout)
api.POST("/auth/refresh", d.authCtrl.RefreshToken)
// 帖子公开 API无需登录
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("/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
import (
"log"
"time"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/controller"
adminCtrl "metazone.cc/metalab/internal/controller/admin"
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/repository"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/session"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type dependencies struct {
authCtrl *controller.AuthController
authMdw *middleware.AuthMiddleware
maintenanceMdw *middleware.MaintenanceMiddleware
settingsCtrl *controller.SettingsController
messageCtrl *controller.MessageController
postCtrl *controller.PostController
spaceCtrl *controller.SpaceController
studioCtrl *controller.StudioController
adminPostCtrl *adminCtrl.AdminPostController
adminCtrl *adminCtrl.AdminController
auditCtrl *adminCtrl.AuditController
siteSettingCtrl *adminCtrl.SiteSettingController
tokenCtrl *controller.AuthController
}
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) (
*repository.UserRepo, *service.TokenService, *service.AuthService,
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
*repository.UserRepo, *session.Manager, *service.AuthService,
*middleware.RateLimiter, *controller.AuthController,
*service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController,
) {
userRepo := repository.NewUserRepo(db)
tokenSvc := service.NewTokenService(cfg, userRepo)
authService := service.NewAuthService(userRepo, tokenSvc, cfg, siteSettings)
rateLimiter := middleware.NewRateLimiter()
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
avatarSvc := service.NewAvatarService(userRepo)
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo))
return userRepo, tokenSvc, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController
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()
authCtrl := controller.NewAuthController(authService, sessionMgr, rateLimiter, cfg)
avatarSvc := service.NewAvatarService(userRepo)
authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, sessionMgr))
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController
}

View File

@ -4,14 +4,16 @@ import (
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/controller"
adminCtrl "metazone.cc/metalab/internal/controller/admin"
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/repository"
"metazone.cc/metalab/internal/service"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) *dependencies {
userRepo, _, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings)
func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) *dependencies {
userRepo, sessionMgr, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings, redisClient)
auditRepo := repository.NewAuditRepo(db)
notificationRepo := repository.NewNotificationRepo(db)
@ -21,7 +23,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
messageController := controller.NewMessageController(notificationService)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService)
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg)
siteSettingController := adminCtrl.NewSiteSettingController(
service.NewSiteSettingService(siteSettings),
&service.AuditDefaults{
@ -31,14 +33,25 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
)
postRepo := repository.NewPostRepo(db)
postService := service.NewPostService(postRepo, siteSettings)
postCtrl := controller.NewPostController(postService)
postService := service.NewPostService(postRepo, siteSettings, notificationService)
shortcodeSvc := service.NewShortcodeService()
postCtrl := controller.NewPostController(postService, shortcodeSvc)
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{
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl,
messageCtrl: messageController, postCtrl: postCtrl, adminPostCtrl: adminPostCtrl,
authCtrl: authCtrl, authMdw: authMdw, maintenanceMdw: maintenanceMdw,
settingsCtrl: settingsCtrl,
messageCtrl: messageController, postCtrl: postCtrl, spaceCtrl: spaceCtrl,
studioCtrl: studioCtrl,
adminPostCtrl: adminPostCtrl,
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("/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/new", d.authMdw.Required(), d.postCtrl.NewPage)
pages.GET("/posts/:id", d.postCtrl.ShowPage)
pages.GET("/posts/:id/edit", d.authMdw.Required(), d.postCtrl.EditPage)
// 创作中心(需登录)
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"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// 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.InjectSiteInfo(siteSettings))
deps := buildDeps(db, cfg, siteSettings)
deps := buildDeps(db, cfg, siteSettings, redisClient)
// 维护模式中间件(全局,在路由匹配之前)
r.Use(deps.maintenanceMdw.Handler())
setupFrontendRoutes(r, cfg, deps)
setupAPIRoutes(r, cfg, deps)

View File

@ -3,14 +3,17 @@ package service
import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
)
type AdminService struct {
userRepo userAdminStore
sessionManager *session.Manager
}
// NewAdminService 构造函数
func NewAdminService(userRepo userAdminStore) *AdminService {
return &AdminService{userRepo: userRepo}
func NewAdminService(userRepo userAdminStore, sm *session.Manager) *AdminService {
return &AdminService{userRepo: userRepo, sessionManager: sm}
}
// ListUsersParams 用户列表查询参数
@ -92,15 +95,15 @@ func (s *AdminService) UpdateUserStatus(operatorUID, targetUID uint, newStatus s
return err
}
}
// 修改状态必须立刻让 JWT 失效
return s.userRepo.IncrementTokenVersion(target.ID)
// 修改状态必须立刻让已有 session 失效
return s.sessionManager.DestroyByUID(target.ID)
})
}
// ResetUserToken 强制下线(递增 token_version
// ResetUserToken 强制下线:销毁所有 session
func (s *AdminService) ResetUserToken(operatorUID, targetUID uint) 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
}
// 5. 标记审核结果
// 4. 标记审核结果
submission.ReviewedBy = &reviewerID
submission.ReviewerName = &reviewer.Username
if approved {

View File

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

View File

@ -26,7 +26,7 @@ const (
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
avatarMinHeight = 128
avatarMaxWidth = 3840
avatarMaxHeight = 2160
avatarMaxHeight = 3840
avatarOutputSize = 512
avatarTargetKB = 100
avatarStorageDir = "storage/uploads/avatars"
@ -98,9 +98,7 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w > avatarMaxWidth || h > avatarMaxHeight {
return "", fmt.Errorf("图片分辨率不能超过 3840x2160")
}
// 原图只检查最小尺寸,确保有足够像素供上采样
if w < avatarMinWidth || h < avatarMinHeight {
return "", fmt.Errorf("图片分辨率不能低于 128x128")
}
@ -114,6 +112,13 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
cropped = centerCrop(img)
}
// 裁切后检查最大尺寸(用户实际选区不应超过此限制)
cb := cropped.Bounds()
cw, ch := cb.Dx(), cb.Dy()
if cw > avatarMaxWidth || ch > avatarMaxHeight {
return "", fmt.Errorf("裁切区域分辨率不能超过 3840x3840")
}
// 5. 缩放至 512x512CatmullRom 高质量)
resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize))
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,
&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"
"fmt"
"regexp"
"strings"
"unicode/utf8"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"github.com/microcosm-cc/bluemonday"
"gorm.io/gorm"
)
// sanitizePolicy Tiptap 输出 HTML 的消毒策略
// 前端 WYSIWYG 编辑器输出 HTML由 bluemonday UGC 策略消毒
// 放行 code/pre 的 class/data-language 属性以保留代码高亮和语言标签
// 放行 span 元素及其 hljs-* class 以保留 highlight.js 语法高亮
// 放行 img 的 src/alt/title 属性以保留图片
var sanitizePolicy = func() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
p.AllowElements("span")
p.AllowAttrs("class", "data-language").OnElements("code", "pre")
p.AllowAttrs("class").Matching(regexp.MustCompile("^hljs-")).OnElements("span")
p.AllowAttrs("src", "alt", "title").OnElements("img")
return p
}()
// mdPatterns Markdown 语法正则(用于生成纯文本摘要)
var mdPatterns = []*regexp.Regexp{
// 代码块 ```...```
regexp.MustCompile("(?s)```[^`]*```"),
// 行内代码 `...`
regexp.MustCompile("`[^`]+`"),
// 图片 ![alt](url)
regexp.MustCompile(`!\[.*?\]\(.*?\)`),
// 链接 [text](url)
regexp.MustCompile(`\[([^\]]*)\]\(.*?\)`),
// 标题标记 #
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 帖子业务逻辑
type PostService struct {
repo postStore
ss *config.SiteSettings
notifier postNotifier
}
// NewPostService 构造函数
func NewPostService(repo postStore, ss *config.SiteSettings) *PostService {
func NewPostService(repo postStore, ss *config.SiteSettings, notifier postNotifier) *PostService {
return &PostService{
repo: repo,
ss: ss,
notifier: notifier,
}
}
@ -70,16 +118,10 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
return post, nil
}
// sanitizeHTML 对 Tiptap 输出的 HTML 进行消毒
// 前端 WYSIWYG 编辑器直接输出 HTML后端仅做安全消毒
func (s *PostService) sanitizeHTML(body string) string {
return sanitizePolicy.Sanitize(body)
}
// Create 创建帖子
// body 为 Vditor 输出的 Markdown后端直接存储不做 HTML 消毒
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
bodyHTML := s.sanitizeHTML(body)
status := model.PostStatusApproved
if s.auditEnabled() {
status = model.PostStatusDraft
@ -88,7 +130,7 @@ func (s *PostService) Create(userID uint, title, body string) (*model.Post, erro
post := &model.Post{
Title: title,
Body: body,
BodyHTML: bodyHTML,
Excerpt: generateExcerpt(body),
UserID: userID,
Status: status,
AllowComment: true,
@ -104,18 +146,66 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
return s.findPostWithAuthor(id)
}
// List 公开帖子列表(仅 approved
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
// listPageable 分页查询公共逻辑:参数规范化 + nil 转空切片
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.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 管理后台帖子列表(全状态)
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
return s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
return s.repo.FindAdminPageable(keyword, status, offset, limit)
})
}
// 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 层检查)
@ -125,15 +215,23 @@ func (s *PostService) Update(postID uint, title, body string) error {
return err
}
if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
if post.Status == model.PostStatusPending || post.IsLocked {
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.Body = body
post.BodyHTML = s.sanitizeHTML(body)
post.Excerpt = generateExcerpt(body)
// rejected 状态编辑后自动重置为 draft
if post.Status == model.PostStatusRejected {
post.Status = model.PostStatusDraft
post.RejectReason = ""
@ -160,21 +258,51 @@ func (s *PostService) SubmitForAudit(postID uint) error {
return s.repo.Update(post)
}
// Approve 审核通过pending → approved
// Approve 审核通过pending → approved;或修订审核通过复制 Pending 到正式字段
func (s *PostService) Approve(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
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 {
return common.ErrPostCannotApprove
}
post.Status = model.PostStatusApproved
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 {
post, err := s.findPost(postID)
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 {
return common.ErrPostCannotReject
}
post.Status = model.PostStatusRejected
// 修订退回:清空待审修订,保持已发布内容不变
if post.PendingBody != "" {
post.PendingTitle = ""
post.PendingBody = ""
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管理员可随时锁定
// 普通退回(首次审核
post.Status = model.PostStatusRejected
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
}
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
func (s *PostService) Lock(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
post.Status = model.PostStatusLocked
// 待审核帖子不允许锁定(审核流程中)
if post.Status == model.PostStatusPending {
return common.ErrPostCannotLock
}
post.IsLocked = true
return s.repo.Update(post)
}
// Unlock 解锁:locked → 草稿
// Unlock 解锁:清除 IsLocked 标志,恢复可编辑
func (s *PostService) Unlock(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
if post.Status != model.PostStatusLocked {
if !post.IsLocked {
return common.ErrPostCannotUnlock
}
post.Status = model.PostStatusDraft
post.IsLocked = false
return s.repo.Update(post)
}
// Restore 恢复软删除
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"
// userAuthStore AuthService 所需的最小仓储接口ISP7 个方法)
// 同时满足 common.UsernameCheckerExistsByUsername可传入 GenerateUsername
type userAuthStore interface {
ExistsByEmail(email string) (bool, error)
Create(user *model.User) error
FindByEmail(email string) (*model.User, error)
FindByID(id uint) (*model.User, error)
Update(user *model.User) error
IncrementTokenVersion(userID uint) error
ExistsByUsername(username string) (bool, error)
}
// userTokenStore TokenService 所需的最小仓储接口ISP1 个方法)
type userTokenStore interface {
FindByIDForAuth(userID uint) (*model.User, error)
}
// userAdminStore AdminService 所需的最小仓储接口ISP8 个方法)
// userAdminStore AdminService 所需的最小仓储接口ISP7 个方法)
type userAdminStore interface {
CountSearchUsers(keyword, role, status string) (int64, error)
SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error)
@ -28,17 +21,34 @@ type userAdminStore interface {
SoftDelete(uid uint) error
Restore(uid uint) error
ExistsByEmailExclude(email string, excludeUID uint) (bool, error)
IncrementTokenVersion(userID uint) error
}
// postStore PostService 所需的最小仓储接口ISP8 个方法)
// postStore PostService 所需的最小仓储接口
type postStore interface {
Create(post *model.Post) error
FindByID(id uint) (*model.Post, error)
FindByIDWithAuthor(id uint) (*model.Post, error)
FindPageable(keyword 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
SoftDelete(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
import (
"fmt"
"html/template"
"os"
"path/filepath"
"strings"
"metazone.cc/metalab/internal/common"
)
// TemplateRoot 模板根目录配置
@ -16,7 +19,13 @@ type TemplateRoot struct {
// LoadTemplates 加载多个根目录的 .html 模板到同一模板集
// 模板名 = prefix + 相对于 root.Dir 的相对路径
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 {
dir := filepath.Clean(root.Dir) + string(os.PathSeparator)
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
}
// 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>
</form>
<div class="auth-footer">
还没有账号?<a href="/auth/register">立即注册</a>
{{if not .IsMaintenance}}还没有账号?<a href="/auth/register">立即注册</a>{{end}}
</div>
</div>
</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>
</button>
<script src="/static/js/common.js"></script>
<script src="/static/js/login.js"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/login.js?v={{assetV "/static/js/login.js"}}"></script>
</body>
</html>

View File

@ -5,6 +5,13 @@
<main class="auth-page">
<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>
<p class="auth-subtitle">加入 MetaLab 开发者社区</p>
<form id="registerForm" novalidate>
@ -34,6 +41,14 @@
<p class="guidelines-link">点击注册即表示同意 <a href="javascript:void(0)" id="openGuidelines">《MetaLab 社区用户准则》</a></p>
<button type="submit" class="submit-btn" id="submitBtn">注 册</button>
</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">
已有账号?<a href="/auth/login">立即登录</a>
</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>
</button>
<script src="/static/js/common.js"></script>
<script src="/static/js/register.js"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script src="/static/js/register.js?v={{assetV "/static/js/register.js"}}"></script>
</body>
</html>

View File

@ -37,6 +37,6 @@
{{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>
</html>

View File

@ -4,10 +4,18 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
<title>{{.Title}} - MetaLab</title>
<link rel="stylesheet" href="/static/css/common.css">
{{if .ExtraCSS}}
<link rel="stylesheet" href="{{.ExtraCSS}}">
{{if .OgTitle}}
<meta property="og:title" content="{{.OgTitle}}">
<meta property="og:type" content="article">
<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}}
<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>

View File

@ -14,7 +14,8 @@
<span class="nav-bell-badge" id="msgBadge" style="display:none">0</span>
</a>
</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>
{{else}}
<li><a href="/auth/login" class="nav-login">登录</a></li>
@ -22,3 +23,13 @@
</ul>
</div>
</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>
<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}}
<div class="msg-toolbar">
{{if .UnreadCount}}
@ -29,7 +38,18 @@
</div>
<div class="msg-list">
{{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">
<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>
@ -45,6 +65,9 @@
<span class="msg-badge"></span>
{{end}}
</div>
{{if $link}}
</a>
{{end}}
{{end}}
</div>
@ -82,7 +105,10 @@
var cards = document.querySelectorAll('.msg-card.unread');
for (var i = 0; i < cards.length; i++) {
(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');
if (!id) return;
var xhr = new XMLHttpRequest();
@ -96,6 +122,15 @@
}
};
xhr.send();
// 如果有链接,在标记已读后跳转(非 post 通知直接标记已读)
if (link) {
e.preventDefault();
var self = this;
setTimeout(function () {
window.location.href = link;
}, 200);
}
});
})(cards[i]);
}
@ -167,6 +202,6 @@
}
})();
</script>
<script src="/static/js/common.js"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body>
</html>

View File

@ -3,12 +3,20 @@
{{template "layout/nav.html" .}}
<div class="container">
<div class="error-404">
<h1>404</h1>
<p>帖子不存在或已被删除</p>
<a href="/posts" class="btn btn-primary">返回帖子列表</a>
<div class="error-404-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<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>
{{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>
</html>

View File

@ -29,10 +29,10 @@
<a href="/posts/{{.ID}}">{{.Title}}</a>
</h2>
<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>
</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>
{{end}}
{{end}}
@ -53,6 +53,6 @@
{{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>
</html>

View File

@ -6,6 +6,10 @@
<div class="editor-layout">
<form id="postForm" class="editor-form">
<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">
@ -15,63 +19,35 @@
maxlength="200" required autofocus>
</div>
{{/* 工具栏 */}}
<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>
{{/* 编辑器主体 */}}
{{/* Vditor 编辑器容器(工具栏+编辑区由 Vditor 自动生成) */}}
<div class="editor-main">
<div class="editor-pane" id="editorPane">
<div id="editor-container"
data-content="{{if .Post}}{{.Post.BodyHTML}}{{end}}"></div>
<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>
{{/* 状态栏 */}}
@ -79,7 +55,6 @@
<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>
<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>
</div>
</form>
@ -87,18 +62,9 @@
{{template "layout/footer.html" .}}
<script type="importmap">
{
"imports": {
"@tiptap/core": "https://esm.sh/@tiptap/core@3.13.0",
"@tiptap/starter-kit": "https://esm.sh/@tiptap/starter-kit@3.13.0",
"@tiptap/extension-placeholder": "https://esm.sh/@tiptap/extension-placeholder@3.13.0",
"@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>
<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 type="module" src="/static/js/editor.js?v={{assetV "/static/js/editor.js"}}"></script>
</body>
</html>

View File

@ -1,5 +1,4 @@
{{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>
{{template "layout/nav.html" .}}
@ -9,21 +8,21 @@
<header class="post-detail-header">
<h1 class="post-detail-title">{{.Post.Title}}</h1>
<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>
{{if ne .Post.Status "approved"}}
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
{{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>
</header>
<div class="post-detail-body">
{{.PostBodyHTML}}
{{/* 如果 BodyHTML 为空,直接显示原文 */}}
</div>
{{if not .PostBodyHTML}}
<div class="post-detail-body-raw">{{.Post.Body}}</div>
{{end}}
<div class="post-detail-body" id="postContent"></div>
{{if .Post.RejectReason}}
<div class="post-reject-reason">
@ -34,7 +33,7 @@
{{if .IsLoggedIn}}
<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>
{{end}}
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected"))}}
@ -46,38 +45,46 @@
{{template "layout/footer.html" .}}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.0/highlight.min.js"></script>
<script src="/static/js/common.js"></script>
{{/* MD 内容安全传递给 JS */}}
<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>
// ---- 代码块语言标签注入 + 语法高亮 ----
(function() {
document.querySelectorAll('.post-detail-body pre').forEach(function(pre) {
// 从 pre 的 data-language 或 code 的 class 中获取语言
var lang = pre.getAttribute('data-language');
if (!lang) {
var code = pre.querySelector('code');
if (code) {
var match = code.className.match(/language-(\w+)/);
if (match) lang = match[1];
var el = document.getElementById('postContent');
var mdEl = document.getElementById('postMdContent');
if (!el || !mdEl) return;
var md = mdEl.value;
Vditor.preview(el, md, {
cdn: '/static/vditor',
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() {
var btn = document.getElementById('submitAuditBtn');
if (!btn) return;

View File

@ -24,6 +24,15 @@
</svg>
账号信息
</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>
</aside>
@ -87,6 +96,57 @@
</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}}
<!-- 账号信息 -->
<div class="settings-card">
@ -152,7 +212,7 @@
<div class="settings-card settings-card-danger">
<div class="settings-card-header">
<h2>注销账号</h2>
<p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录恢复</p>
<p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录撤销注销</p>
</div>
<div class="settings-card-body">
<div class="form-group">
@ -178,7 +238,7 @@
<!-- 头像裁切弹窗 -->
<div class="crop-overlay" id="cropOverlay">
<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">
<img id="cropImageEl" draggable="false">
<div class="crop-mask" id="cropMask"></div>
@ -193,7 +253,7 @@
</div>
</div>
<script src="/static/js/common.js"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script>
(function () {
'use strict';
@ -316,9 +376,11 @@
var pendingFile = null;
var imgNW, imgNH; // 原图像素尺寸
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 fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶
var CROP_MAX_PX = 3840; // 选区最大像素限制
// 更新裁切框固定参数(图片加载时计算一次,之后不变)
function initFrame() {
@ -334,8 +396,6 @@
var iw = imgDW * zoom, ih = imgDH * zoom;
var cx = Math.round((sw - iw) / 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));
panY = Math.max(fTop + fSize - cy - ih, Math.min(panY, fTop - cy));
}
@ -351,7 +411,6 @@
cropImageEl.style.left = ox + 'px';
cropImageEl.style.top = oy + 'px';
// 裁切框固定不变
cropFrame.style.left = fLeft + 'px';
cropFrame.style.top = fTop + 'px';
cropFrame.style.width = fSize + 'px';
@ -361,6 +420,11 @@
cropMask.style.top = fTop + 'px';
cropMask.style.width = 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) {
@ -374,7 +438,11 @@
var fit = Math.min(sw / imgNW, sh / imgNH);
imgDW = Math.round(imgNW * 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;
panY = 0;
initFrame();
@ -391,16 +459,14 @@
pendingFile = null;
}
// 将裁切框映射回原图像素坐标
function getCropParams() {
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
var iw = imgDW * zoom, ih = imgDH * zoom;
var ox = Math.round((sw - iw) / 2) + panX;
var oy = Math.round((sh - ih) / 2) + panY;
// 裁切框中心在 zoom=1 图片坐标中的位置
var cx = (fLeft + fSize / 2 - ox) / 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;
return {
x: Math.round((cx - cs / 2) * sx),
@ -409,7 +475,7 @@
};
}
// ---- 图片平移(点击拖拽 stage 任意位置) ----
// ---- 图片平移 ----
var panning = false, panStartX = 0, panStartY = 0, panInitX = 0, panInitY = 0;
function startPan(clientX, clientY) {
@ -441,7 +507,6 @@
cropStage.style.cursor = 'grab';
});
// 触屏平移
cropStage.addEventListener('touchstart', function (e) {
e.preventDefault();
var t = e.touches[0];
@ -457,11 +522,10 @@
cropStage.style.cursor = 'grab';
});
// ---- 滚轮缩放 ----
cropStage.addEventListener('wheel', function (e) {
e.preventDefault();
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;
zoom = newZoom;
clampPan();
@ -473,19 +537,52 @@
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 () {
if (!pendingFile) return;
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.textContent = '处理中...';
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();
xhr.open('POST', '/api/settings/avatar', true);
xhr.onreadystatechange = function () {
@ -509,6 +606,11 @@
}
};
xhr.send(formData);
}).catch(function () {
cropConfirmBtn.disabled = false;
cropConfirmBtn.textContent = '确认';
showSettingsToast('处理失败,请重试', true);
});
});
if (avatarWrap && avatarInput) {
@ -554,7 +656,6 @@
showSettingsToast('密码已修改,请重新登录');
currentPwd.value = '';
newPwd.value = '';
// 延时后执行登出并跳转到首页
setTimeout(function () {
window.location.href = '/';
}, 2000);
@ -582,7 +683,7 @@
showSettingsToast('请输入密码确认', true);
return;
}
if (!confirm('确定要注销账号吗?\n\n注销后 7 天内重新登录可自动恢复,期满后将永久删除。')) {
if (!confirm('确定要注销账号吗?\n\n注销后 7 天内重新登录可撤销注销,期满后将永久删除。')) {
return;
}
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>
</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;
}
/* ---------- 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-group {
margin-bottom: 1.2rem;

View File

@ -26,13 +26,23 @@
box-sizing: border-box;
}
html {}
html {
height: 100%;
}
body {
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
overflow-x: hidden;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 主内容区撑满剩余空间,实现 sticky footer */
body > .container {
flex: 1;
}
a {
@ -126,6 +136,11 @@ header {
font-weight: 600 !important;
}
.nav-settings {
font-size: .9rem;
color: var(--color-secondary) !important;
}
.nav-logout {
color: var(--color-secondary) !important;
}
@ -178,6 +193,22 @@ header {
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-bar {
text-align: center;
@ -203,6 +234,7 @@ footer {
background: var(--color-primary);
color: #bdc3c7;
padding: .8rem 0;
flex-shrink: 0;
}
.footer-content {

View File

@ -100,6 +100,43 @@ body {
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 ---------- */
.msg-toolbar {
display: flex;

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