Compare commits
47 Commits
v0.1.0
...
37c83d4c12
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c83d4c12 | |||
| 12de8be0cf | |||
| 5a8b0ca79c | |||
| d6d03a7c3c | |||
| 55c408d86c | |||
| e1fb28e8fb | |||
| 8b075f7387 | |||
| 48186085d8 | |||
| d8e58b5f8c | |||
| 6ea4e5365e | |||
| 5fbd010b71 | |||
| da7560b918 | |||
| 4887093c3a | |||
| 09a9394c30 | |||
| eabceecaae | |||
| d3e5cbe18b | |||
| b8658712bb | |||
| 0bcaadceb4 | |||
| a62039e6bb | |||
| 9d84d6bf25 | |||
| 213b2a571e | |||
| e244bccb5c | |||
| d5d33c3ace | |||
| 47ef3f2a99 | |||
| b63dc9075f | |||
| 2180c27b16 | |||
| d203447eb3 | |||
| e3853071d6 | |||
| d9ba1cdf28 | |||
| daf87f895b | |||
| 53f84bfcc4 | |||
| 67444434b9 | |||
| fb94ce60ae | |||
| c1357cf4af | |||
| fe683900bb | |||
| 1b4a74c23d | |||
| 6229c81f6e | |||
| fb2eff5f23 | |||
| 90d17ed4b1 | |||
| 8bb87cd685 | |||
| 95311704fe | |||
| eabc1e7d3d | |||
| 89fa50a013 | |||
| 7659aee468 | |||
| bacfd4945d | |||
| 40f0529cbf | |||
| 6ec12010f3 |
@ -3,3 +3,4 @@
|
||||
|
||||
DATABASE_PASSWORD=your_password_here
|
||||
JWT_SECRET=generate-a-random-64-char-string-here
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
|
||||
21
README.md
21
README.md
@ -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)
|
||||
- 🔲 话题/分类导航
|
||||
- 🔲 评论系统
|
||||
- 🔲 邮箱验证
|
||||
- 🔲 全文搜索
|
||||
- 🔲 回收站(用户数据彻底清理)
|
||||
|
||||
@ -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)
|
||||
|
||||
19
config.yaml
19
config.yaml
@ -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
|
||||
|
||||
426
docs/audit-report-2025-05-31.md
Normal file
426
docs/audit-report-2025-05-31.md
Normal file
@ -0,0 +1,426 @@
|
||||
# MetaLab 项目全量代码审计报告
|
||||
|
||||
**审计日期**: 2025-05-31
|
||||
**审计范围**: `lab.metazone.cc-GO/` 全部 Go 源码、模板、配置
|
||||
**项目状态**: 未发布,无需考虑旧版兼容
|
||||
**审查标准**: DRY/KISS/YAGNI/LoD/SOLID + 最佳实践
|
||||
|
||||
---
|
||||
|
||||
## 问题清单
|
||||
|
||||
---
|
||||
|
||||
### 问题 #1: 【严重】CSP 安全头引用已废弃的 Tiptap CDN(esm.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
231
docs/post-system-audit.md
Normal 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 个)
|
||||
|
||||
#### 问题 1:Repository 方法代码重复(~70%)
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/repository/post_repo.go` |
|
||||
| **位置** | `FindPageable`(行 49-73)vs `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 条件。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 2:Service 方法重复
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `internal/service/post_service.go` |
|
||||
| **位置** | `List`(行 148-156)vs `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 检查逻辑。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 3:Controller 权限检查重复 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)`。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 4:Model 层职责过重
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `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/` 包。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 6:Controller 层分页重复初始化
|
||||
|
||||
| 项 | 详情 |
|
||||
|----|------|
|
||||
| **文件** | `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()`。
|
||||
|
||||
---
|
||||
|
||||
#### 问题 7:Admin 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 | 轻微优化项 |
|
||||
319
docs/vditor-migration-plan.md
Normal file
319
docs/vditor-migration-plan.md
Normal file
@ -0,0 +1,319 @@
|
||||
# Vditor 整合 + MD 存储迁移方案
|
||||
|
||||
> **状态:已实施 ✓** `2026-05-30`
|
||||
|
||||
## 决策与结果
|
||||
|
||||
| 决策 | 结论 | 结果 |
|
||||
|------|------|------|
|
||||
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
|
||||
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
|
||||
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
|
||||
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
|
||||
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist,5.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.js(47KB),不需要完整编辑器 -->
|
||||
<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
7
go.mod
@ -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
20
go.sum
@ -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=
|
||||
|
||||
20
internal/common/context.go
Normal file
20
internal/common/context.go
Normal file
@ -0,0 +1,20 @@
|
||||
package common
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
|
||||
// 如果用户未登录,ok 返回 false;role 可能为空字符串
|
||||
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
|
||||
}
|
||||
@ -11,52 +11,30 @@ import (
|
||||
|
||||
// Cookie 名称常量
|
||||
const (
|
||||
CookieName = "mlb_token"
|
||||
RefreshCookieName = "mlb_refresh"
|
||||
RMCookieName = "mlb_rm" // 记住我标记,JS 可读
|
||||
SessionCookieName = "mlb_sid" // 会话 ID Cookie(HttpOnly)
|
||||
)
|
||||
|
||||
// SetAuthCookies 设置 access + refresh Cookie
|
||||
// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
|
||||
// rememberMe=false → Cookie 用 session 模式(maxAge=0),关浏览器即清除,但页面开启期间自动刷新
|
||||
func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) {
|
||||
// SetSessionCookie 写入会话 ID Cookie
|
||||
// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
|
||||
// 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
|
||||
}
|
||||
|
||||
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
|
||||
c.SetCookie(SessionCookieName, sid, maxAge, "/", "", secure, true)
|
||||
}
|
||||
|
||||
// SetAccessCookie 仅刷新 access Cookie(refresh 续期调用)
|
||||
func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) {
|
||||
// ClearSessionCookie 清除会话 ID Cookie(logout 调用)
|
||||
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 清除所有认证 Cookie(logout 调用)
|
||||
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 Cookie(JS 可读)
|
||||
func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
|
||||
c.SetCookie(name, value, maxAge, "/", "", secure, false)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,15 +32,17 @@ var (
|
||||
// 密码 & 注销相关
|
||||
ErrIncorrectPassword = errors.New("当前密码错误")
|
||||
ErrLoginBeforeDelete = errors.New("请先登录再注销")
|
||||
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号")
|
||||
ErrNeedsConfirmRestore = errors.New("需要二次确认撤销注销")
|
||||
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
|
||||
ErrRegistrationDisabled = errors.New("注册功能已关闭")
|
||||
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
|
||||
|
||||
// 帖子相关
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
||||
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
||||
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
||||
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
||||
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||
)
|
||||
|
||||
29
internal/common/file.go
Normal file
29
internal/common/file.go
Normal 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
|
||||
}
|
||||
@ -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 图片语法 
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
|
||||
12
internal/common/post_helpers.go
Normal file
12
internal/common/post_helpers.go
Normal 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
28
internal/common/redis.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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"` // 不记住我:空闲超时(分钟),默认 1440(24小时)
|
||||
RememberTimeout int `mapstructure:"remember_timeout"` // 记住我:空闲超时(分钟),默认 43200(30天)
|
||||
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 连接字符串
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -29,9 +29,15 @@ func (ac *AdminController) Dashboard(c *gin.Context) {
|
||||
if err != nil {
|
||||
totalUsers = 0
|
||||
}
|
||||
storeMetrics := ac.adminService.GetStoreMetrics()
|
||||
c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "管理首页",
|
||||
"TotalUsers": totalUsers,
|
||||
"Title": "管理首页",
|
||||
"TotalUsers": totalUsers,
|
||||
"StoreType": storeMetrics.Type,
|
||||
"StoreHealthy": storeMetrics.Healthy,
|
||||
"StoreDegraded": storeMetrics.Degraded,
|
||||
"DegradedNote": storeMetrics.DegradedNote,
|
||||
"ActiveSessions": storeMetrics.ActiveCount,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -94,7 +100,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+"成功")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
||||
if errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -3,14 +3,16 @@ package admin
|
||||
import (
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
"metazone.cc/metalab/internal/session"
|
||||
)
|
||||
|
||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:4 个方法)
|
||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:5 个方法)
|
||||
type adminUseCase interface {
|
||||
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
|
||||
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
|
||||
ResetUserToken(operatorUID, targetUID uint) error
|
||||
CountUsers() (int64, error)
|
||||
GetStoreMetrics() session.StoreMetrics
|
||||
}
|
||||
|
||||
// auditUseCase AuditController 对 AuditService 的最小依赖(ISP:3 个方法)
|
||||
@ -33,9 +35,10 @@ type auditStatusProvider interface {
|
||||
FindByID(id uint) (*model.User, error)
|
||||
}
|
||||
|
||||
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:7 个方法)
|
||||
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
|
||||
|
||||
@ -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, "注销已撤销,欢迎回来")
|
||||
}
|
||||
|
||||
@ -34,19 +34,20 @@ func (ac *AuthController) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 注册 IP 限流:1 分钟 5 次
|
||||
regResult, recordReg := ac.rateLimiter.AllowIP(clientIP(c) + ":register")
|
||||
// 注册 IP 限流:1 分钟 5 次(原子检查+递增)
|
||||
regKey := clientIP(c) + ":register"
|
||||
regResult := ac.rateLimiter.AllowIP(regKey)
|
||||
if regResult.Blocked {
|
||||
common.Error(c, http.StatusTooManyRequests, "注册请求过于频繁,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, refreshToken, user, err := ac.authService.Register(req, clientIP(c))
|
||||
user, err := ac.authService.Register(req, clientIP(c))
|
||||
if err != nil {
|
||||
if recordReg != nil {
|
||||
recordReg()
|
||||
}
|
||||
// 失败计数已在 AllowIP 中原子递增,无需额外记录
|
||||
switch err {
|
||||
case common.ErrMaintenanceMode:
|
||||
common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册")
|
||||
case common.ErrEmailExists:
|
||||
common.Error(c, http.StatusConflict, "该邮箱已注册")
|
||||
case common.ErrWeakPassword:
|
||||
@ -59,40 +60,48 @@ func (ac *AuthController) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
|
||||
// 注册成功 → 清除该 IP 的注册限流计数
|
||||
ac.rateLimiter.ClearIP(regKey)
|
||||
|
||||
sid, err := ac.sessionManager.Create(user, req.RememberMe, clientIP(c), c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
|
||||
|
||||
common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab")
|
||||
}
|
||||
|
||||
// Logout 退出登录:清除所有认证 Cookie
|
||||
// Logout 退出登录:删除服务端 session + 清除 Cookie
|
||||
func (ac *AuthController) Logout(c *gin.Context) {
|
||||
common.ClearAuthCookies(c, ac.cfg)
|
||||
if sid, err := c.Cookie(common.SessionCookieName); err == nil && sid != "" {
|
||||
_ = ac.sessionManager.Destroy(sid)
|
||||
}
|
||||
common.ClearSessionCookie(c, ac.cfg)
|
||||
common.OkMessage(c, "已退出登录")
|
||||
}
|
||||
|
||||
// RefreshToken 用 refresh token 换取新的 access token
|
||||
// RefreshToken 检查登录状态并续期(session 模式下每次请求已自动续期,此端点用于前端显式检查)
|
||||
func (ac *AuthController) RefreshToken(c *gin.Context) {
|
||||
refreshToken, err := c.Cookie(common.RefreshCookieName)
|
||||
if err != nil {
|
||||
sid, err := c.Cookie(common.SessionCookieName)
|
||||
if err != nil || sid == "" {
|
||||
common.Error(c, http.StatusUnauthorized, "请重新登录")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, _, err := ac.tokenService.RefreshAccessToken(refreshToken)
|
||||
if err != nil {
|
||||
common.ClearAuthCookies(c, ac.cfg)
|
||||
switch err {
|
||||
case common.ErrTokenExpired, common.ErrTokenRevoked:
|
||||
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
|
||||
case common.ErrUserBanned:
|
||||
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
||||
default:
|
||||
common.Error(c, http.StatusUnauthorized, "请重新登录")
|
||||
}
|
||||
s, err := ac.sessionManager.Validate(sid)
|
||||
if err != nil || s == nil {
|
||||
common.ClearSessionCookie(c, ac.cfg)
|
||||
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
|
||||
return
|
||||
}
|
||||
|
||||
common.SetAccessCookie(c, accessToken, ac.cfg)
|
||||
|
||||
common.Ok(c, nil)
|
||||
common.Ok(c, gin.H{
|
||||
"uid": s.UserID,
|
||||
"email": s.Email,
|
||||
"username": s.Username,
|
||||
"role": s.Role,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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"
|
||||
@ -12,18 +13,18 @@ import (
|
||||
|
||||
// AuthController 认证相关页面 + API
|
||||
type AuthController struct {
|
||||
authService authUseCase
|
||||
tokenService tokenRefresher
|
||||
rateLimiter rateLimiter
|
||||
cfg *config.Config
|
||||
authService authUseCase
|
||||
sessionManager *session.Manager
|
||||
rateLimiter rateLimiter
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// 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, "/")
|
||||
@ -35,9 +36,10 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "注册",
|
||||
"ExtraCSS": "/static/css/auth.css",
|
||||
"Guidelines": guidelines,
|
||||
"Title": "注册",
|
||||
"ExtraCSS": "/static/css/auth.css",
|
||||
"Guidelines": guidelines,
|
||||
"RegistrationEnabled": ac.authService.IsRegistrationEnabled(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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 的最小依赖(ISP:4 个方法)
|
||||
// authUseCase AuthController 对 AuthService 的最小依赖(ISP:5 个方法)
|
||||
// 注意:Login/Register/ConfirmRestore 不再返回 JWT,session 由 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 的最小依赖(ISP:1 个方法)
|
||||
type tokenRefresher interface {
|
||||
RefreshAccessToken(refreshTokenStr string) (string, *model.User, error)
|
||||
}
|
||||
|
||||
// rateLimiter AuthController 对 RateLimiter 的最小依赖(ISP:3 个方法)
|
||||
// rateLimiter AuthController 对 RateLimiter 的最小依赖(ISP:4 个方法)
|
||||
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 的最小依赖(ISP:6 个方法)
|
||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:7 个方法)
|
||||
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 的最小依赖(ISP:8 个方法)
|
||||
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 的最小依赖(ISP:2 个方法)
|
||||
type spaceUseCase interface {
|
||||
GetSpaceUser(uid uint) (*model.User, error)
|
||||
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
|
||||
}
|
||||
|
||||
// sessionManager 登录管理对会话管理的最小依赖(ISP:4 个方法)
|
||||
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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
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 {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
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,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
"Title": post.Title,
|
||||
"Post": post,
|
||||
"UID": uid,
|
||||
"StatusNames": common.PostStatusDisplayNames,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
"OgTitle": post.Title,
|
||||
"OgDescription": post.Excerpt,
|
||||
"OgImage": ogImage,
|
||||
"OgURL": ogURL,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||
return
|
||||
}
|
||||
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
|
||||
isWebP := ext == ".webp"
|
||||
isGIF := ext == ".gif"
|
||||
|
||||
url := "/uploads/posts/" + filename
|
||||
common.Ok(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// saveUploadedFile 将 multipart.File 写入目标路径
|
||||
func saveUploadedFile(file multipart.File, dst string) error {
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// 限制读取 5MB 防止内存放大
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
// 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// GIF:仅解码验证,不转换
|
||||
if isGIF {
|
||||
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||||
return
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
// WebP:仅解码验证,不重复编码
|
||||
if isWebP {
|
||||
if _, err := webp.Decode(bytes.NewReader(data)); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 回退存储:JPEG/PNG 重编码剥离元数据;WebP 已验证直接存;GIF 不重编(丢动画)
|
||||
if savePath == "" {
|
||||
dataOut := data
|
||||
|
||||
if isJPEGPNG {
|
||||
decImg, _, decErr := image.Decode(bytes.NewReader(data))
|
||||
if decErr == nil {
|
||||
var buf bytes.Buffer
|
||||
var encErr error
|
||||
if ext == ".png" {
|
||||
encErr = png.Encode(&buf, decImg)
|
||||
} else {
|
||||
encErr = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||
}
|
||||
if encErr == nil && buf.Len() > 0 {
|
||||
dataOut = buf.Bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||
savePath = filepath.Join(storageDir, filename)
|
||||
if err := os.WriteFile(savePath, dataOut, 0644); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||
return
|
||||
}
|
||||
url = "/uploads/posts/" + filename
|
||||
}
|
||||
|
||||
common.VditorUploadOk(c, map[string]string{header.Filename: url})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@ -55,5 +55,5 @@ func (sc *SettingsController) DeleteAccount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "账号已注销,7 天内重新登录即可恢复")
|
||||
common.OkMessage(c, "账号已注销,7 天内重新登录即可撤销注销")
|
||||
}
|
||||
|
||||
161
internal/controller/settings_api_sessions.go
Normal file
161
internal/controller/settings_api_sessions.go
Normal 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, "备注已更新")
|
||||
}
|
||||
@ -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 登录管理对配置的最小依赖(ISP:2 个方法)
|
||||
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 查询当前用户的待审核类型(需登录)
|
||||
|
||||
103
internal/controller/space_controller.go
Normal file
103
internal/controller/space_controller.go
Normal 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",
|
||||
}))
|
||||
}
|
||||
368
internal/controller/studio_controller.go
Normal file
368
internal/controller/studio_controller.go
Normal 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, "已提交审核")
|
||||
}
|
||||
@ -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
|
||||
cfg *config.Config
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/")
|
||||
}
|
||||
@ -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"])
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
90
internal/middleware/maintenance.go
Normal file
90
internal/middleware/maintenance.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
package middleware
|
||||
|
||||
// tokenVersionStore 最小接口:仅暴露 AuthMiddleware 需要的 1 个方法
|
||||
type tokenVersionStore interface {
|
||||
FindTokenVersion(userID uint) (int, error)
|
||||
}
|
||||
@ -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: 本站 + inline(highlight.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")
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
@ -2,8 +2,10 @@ package model
|
||||
|
||||
// 消息/通知类型常量
|
||||
const (
|
||||
NotifyAuditApproved = "audit_approved"
|
||||
NotifyAuditRejected = "audit_rejected"
|
||||
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 消息列表查询结果
|
||||
|
||||
@ -8,17 +8,20 @@ import (
|
||||
|
||||
// Post 帖子/文章模型
|
||||
type Post struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"`
|
||||
BodyHTML string `gorm:"type:text" json:"body_html"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
|
||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
||||
UserID uint `gorm:"index;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:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 非数据库字段(联表查询填充)
|
||||
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
92
internal/model/shortcode.go
Normal file
92
internal/model/shortcode.go
Normal 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 替换后的 body(shortcode → 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)
|
||||
}
|
||||
@ -12,9 +12,8 @@ type User struct {
|
||||
Avatar string `gorm:"type:varchar(500);default:''" json:"avatar"`
|
||||
Bio string `gorm:"type:text" json:"bio"`
|
||||
|
||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
|
||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||
|
||||
// 安全与审计
|
||||
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
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, tokenSvc, rateLimiter, cfg)
|
||||
authCtrl := controller.NewAuthController(authService, sessionMgr, 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
|
||||
authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
|
||||
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, sessionMgr))
|
||||
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 认证页面(已登录自动跳走)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -109,6 +112,11 @@ func (s *AdminService) CountUsers() (int64, error) {
|
||||
return s.userRepo.CountSearchUsers("", "", "")
|
||||
}
|
||||
|
||||
// GetStoreMetrics 返回会话存储状态信息(管理首页用)
|
||||
func (s *AdminService) GetStoreMetrics() session.StoreMetrics {
|
||||
return s.sessionManager.GetStoreMetrics()
|
||||
}
|
||||
|
||||
// checkAndOperate 通用权限检查 + 执行操作
|
||||
func (s *AdminService) checkAndOperate(operatorUID, targetUID uint, operate func(*model.User, *model.User) error) error {
|
||||
// 1. 不可操作自己
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
@ -15,15 +16,15 @@ import (
|
||||
|
||||
// AuthService 认证业务逻辑
|
||||
type AuthService struct {
|
||||
userRepo userAuthStore
|
||||
tokenService tokenProvider
|
||||
cfg *config.Config
|
||||
siteSettings *config.SiteSettings
|
||||
userRepo userAuthStore
|
||||
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. 检查注册开关
|
||||
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → 返回 user(session 由 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)
|
||||
if err != nil {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return "", "", nil, common.ErrInvalidCred
|
||||
|
||||
// 维护期间:非站长一律返回统一提示(模拟 bcrypt 防时序攻击)
|
||||
if s.siteSettings.IsMaintenanceEnabled() {
|
||||
if err != nil {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return nil, common.ErrMaintenanceMode
|
||||
}
|
||||
if !model.HasMinRole(user.Role, model.RoleOwner) {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return nil, common.ErrMaintenanceMode
|
||||
}
|
||||
}
|
||||
|
||||
// 已注销(deleted)→ 验证密码后要求二次确认,不自动恢复
|
||||
if err != nil {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return nil, common.ErrInvalidCred
|
||||
}
|
||||
|
||||
// 已注销(deleted)→ 验证密码后要求二次确认
|
||||
if user.Status == model.StatusDeleted {
|
||||
if !common.CheckPassword(req.Password, user.PasswordHash) {
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return "", "", nil, common.ErrInvalidCred
|
||||
return nil, common.ErrInvalidCred
|
||||
}
|
||||
return "", "", nil, common.ErrNeedsConfirmRestore
|
||||
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
|
||||
}
|
||||
|
||||
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, user, nil
|
||||
return 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
|
||||
}
|
||||
|
||||
@ -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. 缩放至 512x512(CatmullRom 高质量)
|
||||
resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize))
|
||||
draw.CatmullRom.Scale(resized, resized.Bounds(), cropped, cropped.Bounds(), draw.Over, nil)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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("`[^`]+`"),
|
||||
// 图片 
|
||||
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
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
// 修订退回:清空待审修订,保持已发布内容不变
|
||||
if post.PendingBody != "" {
|
||||
post.PendingTitle = ""
|
||||
post.PendingBody = ""
|
||||
post.RejectReason = reason
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者修订被退回
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通退回(首次审核)
|
||||
post.Status = model.PostStatusRejected
|
||||
post.RejectReason = reason
|
||||
return s.repo.Update(post)
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者审核被退回
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lock 锁定:任意状态 → locked(管理员可随时锁定)
|
||||
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
|
||||
func (s *PostService) Lock(postID uint) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
package service
|
||||
|
||||
import "metazone.cc/metalab/internal/model"
|
||||
|
||||
// tokenProvider AuthService 对 TokenService 的最小依赖(ISP:2 个方法)
|
||||
type tokenProvider interface {
|
||||
BuildAccessToken(user *model.User) (string, error)
|
||||
BuildRefreshToken(user *model.User, rememberMe bool) (string, error)
|
||||
}
|
||||
@ -3,23 +3,16 @@ package service
|
||||
import "metazone.cc/metalab/internal/model"
|
||||
|
||||
// userAuthStore AuthService 所需的最小仓储接口(ISP:7 个方法)
|
||||
// 同时满足 common.UsernameChecker(ExistsByUsername),可传入 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 所需的最小仓储接口(ISP:1 个方法)
|
||||
type userTokenStore interface {
|
||||
FindByIDForAuth(userID uint) (*model.User, error)
|
||||
}
|
||||
|
||||
// userAdminStore AdminService 所需的最小仓储接口(ISP:8 个方法)
|
||||
// userAdminStore AdminService 所需的最小仓储接口(ISP:7 个方法)
|
||||
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 所需的最小仓储接口(ISP:8 个方法)
|
||||
// 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)
|
||||
}
|
||||
|
||||
147
internal/service/shortcode_service.go
Normal file
147
internal/service/shortcode_service.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
25
internal/service/space_service.go
Normal file
25
internal/service/space_service.go
Normal 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)
|
||||
}
|
||||
@ -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_expire(30天);false → 使用 refresh_expire(7天,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
|
||||
}
|
||||
185
internal/session/fallback_store.go
Normal file
185
internal/session/fallback_store.go
Normal file
@ -0,0 +1,185 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// FallbackStore 会话存储降级保护层
|
||||
// Redis 正常时委托给 RedisStore,异常时自动降级到 MemoryStore
|
||||
// Redis 恢复时自动将降级期间的内存会话静默迁移回 Redis,用户无感知
|
||||
type FallbackStore struct {
|
||||
redis *RedisStore
|
||||
memory *MemoryStore
|
||||
client *redis.Client
|
||||
|
||||
healthy atomic.Bool // Redis 当前是否健康
|
||||
degraded atomic.Bool // 是否处于降级模式(曾因异常而切换到 Memory)
|
||||
migrating atomic.Bool // 是否正在执行迁移(避免并发迁移)
|
||||
}
|
||||
|
||||
// NewFallbackStore 创建降级保护存储
|
||||
// checkInterval: 健康检测间隔,建议与 session cleanup_interval 一致
|
||||
func NewFallbackStore(redis *RedisStore, memory *MemoryStore, 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()
|
||||
|
||||
if ok && !prev {
|
||||
// Redis 恢复:先迁移内存会话,再切回 Redis
|
||||
fs.migrateSessions()
|
||||
fs.healthy.Store(true)
|
||||
fs.degraded.Store(false)
|
||||
} else if !ok && prev {
|
||||
// Redis 掉线,标记降级
|
||||
fs.healthy.Store(false)
|
||||
fs.degraded.Store(true)
|
||||
log.Println("[FallbackStore] Redis 不可用,已降级为内存存储")
|
||||
} else {
|
||||
fs.healthy.Store(ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// migrateSessions 将降级期间内存中的活跃会话同步到 Redis
|
||||
func (fs *FallbackStore) migrateSessions() {
|
||||
if !fs.migrating.CompareAndSwap(false, true) {
|
||||
return // 已有迁移在进行中
|
||||
}
|
||||
defer fs.migrating.Store(false)
|
||||
|
||||
sessions := fs.memory.Snapshot()
|
||||
if len(sessions) == 0 {
|
||||
log.Println("[FallbackStore] 无需迁移(降级期间无活跃会话)")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pipe := fs.client.Pipeline()
|
||||
|
||||
for _, s := range sessions {
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
log.Printf("[FallbackStore] 迁移序列化失败 sid=%s err=%v", s.ID[:16]+"...", err)
|
||||
continue
|
||||
}
|
||||
ttl := fs.redis.idleTimeout
|
||||
if s.RememberMe {
|
||||
ttl = fs.redis.rememberTimeout
|
||||
}
|
||||
pipe.Set(ctx, keyPrefix+s.ID, data, ttl)
|
||||
pipe.SAdd(ctx, userKeyPrefix+fmt.Sprint(s.UserID), s.ID)
|
||||
}
|
||||
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[FallbackStore] 迁移到 Redis 失败: %v,保持内存模式", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[FallbackStore] Redis 已恢复,静默迁移 %d 条会话", len(sessions))
|
||||
}
|
||||
|
||||
// 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 返回当前应使用的 Store:Redis 健康用 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
134
internal/session/manager.go
Normal file
@ -0,0 +1,134 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/config"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
)
|
||||
|
||||
// userStore SessionManager 对 UserRepo 的最小接口(ISP:1 个方法)
|
||||
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()
|
||||
}
|
||||
233
internal/session/memory_store.go
Normal file
233
internal/session/memory_store.go
Normal file
@ -0,0 +1,233 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot 返回所有活跃会话的副本(用于 Redis 恢复时迁移)
|
||||
func (ms *MemoryStore) Snapshot() []*Session {
|
||||
ms.mu.RLock()
|
||||
defer ms.mu.RUnlock()
|
||||
|
||||
var active []*Session
|
||||
for _, s := range ms.sessions {
|
||||
timeout := ms.idleTimeout
|
||||
if s.RememberMe {
|
||||
timeout = ms.rememberTimeout
|
||||
}
|
||||
if !s.IsExpired(timeout) {
|
||||
// 返回副本,避免外部修改内部 map
|
||||
copyS := *s
|
||||
active = append(active, ©S)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
240
internal/session/redis_store.go
Normal file
240
internal/session/redis_store.go
Normal 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,
|
||||
}
|
||||
}
|
||||
41
internal/session/session.go
Normal file
41
internal/session/session.go
Normal 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
37
internal/session/store.go
Normal 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
97
internal/session/ua.go
Normal 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]
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">⚙</span>
|
||||
{{if .IsOwner}}
|
||||
<span>社区正处于维护状态</span>
|
||||
{{else}}
|
||||
<span>社区正在维护中,仅站长可访问</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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="行内代码"></></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>></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>
|
||||
|
||||
@ -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() 渲染 MD,shortcode 占位 div 原样保留
|
||||
// 渲染完成后,将占位 div 替换为对应卡片 UI
|
||||
renderShortcodes(el);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// 提交审核按钮
|
||||
(function() {
|
||||
var btn = document.getElementById('submitAuditBtn');
|
||||
if (!btn) return;
|
||||
|
||||
@ -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,42 +537,80 @@
|
||||
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('上传中...');
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/settings/avatar', true);
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) return;
|
||||
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 () {
|
||||
if (xhr.readyState !== 4) return;
|
||||
cropConfirmBtn.disabled = false;
|
||||
cropConfirmBtn.textContent = '确认';
|
||||
closeCrop();
|
||||
|
||||
try {
|
||||
var res = JSON.parse(xhr.responseText);
|
||||
if (res.success) {
|
||||
showSettingsToast(res.message || '头像已更新');
|
||||
if (res.data && res.data.url && avatarPreview.tagName === 'IMG') {
|
||||
avatarPreview.src = res.data.url + '?t=' + Date.now();
|
||||
}
|
||||
} else {
|
||||
showSettingsToast(res.message || '上传失败', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showSettingsToast('上传失败', true);
|
||||
}
|
||||
};
|
||||
xhr.send(formData);
|
||||
}).catch(function () {
|
||||
cropConfirmBtn.disabled = false;
|
||||
cropConfirmBtn.textContent = '确认';
|
||||
closeCrop();
|
||||
|
||||
try {
|
||||
var res = JSON.parse(xhr.responseText);
|
||||
if (res.success) {
|
||||
showSettingsToast(res.message || '头像已更新');
|
||||
if (res.data && res.data.url && avatarPreview.tagName === 'IMG') {
|
||||
avatarPreview.src = res.data.url + '?t=' + Date.now();
|
||||
}
|
||||
} else {
|
||||
showSettingsToast(res.message || '上传失败', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showSettingsToast('上传失败', true);
|
||||
}
|
||||
};
|
||||
xhr.send(formData);
|
||||
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>
|
||||
|
||||
93
templates/MetaLab-2026/html/space/index.html
Normal file
93
templates/MetaLab-2026/html/space/index.html
Normal 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}} {{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>
|
||||
72
templates/MetaLab-2026/html/studio/analytics.html
Normal file
72
templates/MetaLab-2026/html/studio/analytics.html
Normal 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>
|
||||
105
templates/MetaLab-2026/html/studio/drafts.html
Normal file
105
templates/MetaLab-2026/html/studio/drafts.html
Normal 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>
|
||||
95
templates/MetaLab-2026/html/studio/overview.html
Normal file
95
templates/MetaLab-2026/html/studio/overview.html
Normal 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>
|
||||
105
templates/MetaLab-2026/html/studio/posts.html
Normal file
105
templates/MetaLab-2026/html/studio/posts.html
Normal 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>
|
||||
25
templates/MetaLab-2026/html/studio/sidebar.html
Normal file
25
templates/MetaLab-2026/html/studio/sidebar.html
Normal 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>
|
||||
72
templates/MetaLab-2026/html/studio/write.html
Normal file
72
templates/MetaLab-2026/html/studio/write.html
Normal 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>></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>
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
Reference in New Issue
Block a user