fix: 设计原则审查修复 — DIP/ISP, LoD, DRY, OCP, URL, 301缓存

- P0 DIP+ISP: 全链路注入接口,消除零接口紧耦合
- P0 URL: auth 301→302,修复登出后浏览器缓存陷阱
- P1 DRY: JWT 认证逻辑收敛至 TokenService+中间件
- P2 DRY: 前后端角色/状态映射统一为 model 常量
- P2 LoD: 新增 SettingsController,router 不再跨层调 repo
- P2 URL: settings ?tab= → /settings/:tab 伪静态
- P3 OCP: 角色权限 map 化,告别硬编码 switch
This commit is contained in:
2026-05-26 21:12:19 +08:00
parent 483fdd919f
commit 39d13993ba
37 changed files with 961 additions and 260 deletions

119
README.md
View File

@ -1,3 +1,118 @@
# MetaLab <p align="center">
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go" alt="Go 1.25">
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
<img src="https://img.shields.io/badge/GORM-1.31-7A6F5D?style=flat-square" alt="GORM 1.31">
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL 3.0">
<img src="https://img.shields.io/badge/status-dev-orange?style=flat-square" alt="status">
</p>
MetaLab - 下一代开源技术社区 <h1 align="center">MetaLab</h1>
<p align="center"><em>下一代开源技术社区——为构建者准备的纯粹空间</em></p>
---
## 关于项目
MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者提供一个**平等、纯粹、开放**的技术交流空间。
> **当前状态:私有开发中**。社区上线后将完全开源。
**核心理念:**
- 🎯 **专注技术**——代码、架构、逻辑与可验证的事实
- 🤝 **平等交流**——没有特权,每个成员权利平等
- 🔓 **开放共享**——鼓励开源、协作与知识共享
## 技术栈
| 类别 | 技术 | 版本 |
|------|------|------|
| 语言 | **Go** | 1.25 |
| Web 框架 | **Gin** | 1.12 |
| ORM | **GORM** | 1.31 |
| 数据库 | **PostgreSQL** | 16+ |
| 认证 | **JWT** (HS256) | v5 |
| 密码加密 | **bcrypt** (cost=12) | — |
| 配置 | **Viper** | 1.21 |
| 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — |
| 架构 | 分层六边形架构 | — |
## 当前功能
-**用户系统**——注册、登录、登出
-**双 Token 认证**——Access Token (15min) + Refresh Token (30d)
-**"记住我"**——跨会话自动恢复登录态
-**CSRF 保护**——全站 CSRF Token
-**速率限制**——API 调用频率控制
-**安全头**——Content-Security-Policy 等安全响应头
-**管理后台**——用户管理、角色权限控制
-**软删除**——用户数据可恢复
-**多主题支持**——可切换的前端主题系统
## 计划中
- 🔲 帖子系统CRUD + Markdown
- 🔲 话题/分类导航
- 🔲 用户设置页
- 🔲 邮箱验证
- 🔲 全文搜索
- 🔲 管理后台增强(内容审核、统计分析)
## 快速开始
```bash
# 克隆仓库
git clone git@git.metazone.cc:MetaZone/MetaLab.git
cd MetaLab
# 创建 .env 文件
cat > .env << EOF
DATABASE_PASSWORD=your_db_password
JWT_SECRET=your_jwt_secret
EOF
# 确保 PostgreSQL 运行中
# 默认连接: 127.0.0.1:5432, 数据库: metalab_dev
# 运行
go run cmd/server/main.go
```
启动后访问 `http://localhost:8080`
## 项目结构
```
MetaLab/
├── cmd/server/ # 入口:初始化 → 启动
├── internal/
│ ├── config/ # 配置加载YAML + .env
│ ├── router/ # 路由注册
│ ├── controller/ # 控制器(参数绑定 → 调服务 → 返回)
│ │ └── admin/ # 后台控制器
│ ├── service/ # 业务逻辑层
│ ├── repository/ # 数据访问层DAO
│ ├── model/ # 数据模型 + DTO
│ ├── middleware/ # 中间件(认证/CSRF/限流/安全)
│ ├── common/ # 公共工具(响应/分页/加密)
│ └── theme/ # 主题/模板管理
├── templates/ # 前端模板(多主题)
│ ├── MetaLab-2026/ # 前台主题
│ └── admin/ # 后台模板
└── docs/ # 开发文档
```
## 许可
本项目采用 **[GNU Affero General Public License v3.0 (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html)** 许可。
**社区版**AGPL-3.0):可自由使用、修改、分发,但修改后通过网络提供服务时必须公开源码。
**商业授权**:如需闭源部署、私有化修改或商业技术支持,请联系 [metazone@foxmail.com](mailto:metazone@foxmail.com) 获取商业授权。
---
<p align="center">
<sub>&copy; 2024-2026 MetaZone | Built with ❤️ by <a href="https://metazone.cc">MetaZone</a> </sub>
</p>

View File

@ -17,6 +17,9 @@ func main() {
// 配置 // 配置
cfg := config.Load("config.yaml") cfg := config.Load("config.yaml")
// 从配置初始化角色系统OCP新增角色无需修改源代码
model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles)
// 数据库 // 数据库
db, err := gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{}) db, err := gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{})
if err != nil { if err != nil {
@ -58,6 +61,7 @@ func main() {
// 静态资源 // 静态资源
r.Static("/static", "./templates/MetaLab-2026/static") r.Static("/static", "./templates/MetaLab-2026/static")
r.Static("/admin/static", "./templates/admin/static") r.Static("/admin/static", "./templates/admin/static")
r.Static("/shared/static", "./templates/shared/static")
// 路由注册 // 路由注册
router.Setup(r, db, cfg) router.Setup(r, db, cfg)

View File

@ -21,3 +21,24 @@ jwt:
bcrypt: bcrypt:
cost: 12 cost: 12
# 角色配置(可扩展,无需修改源代码)
roles:
# 角色层级(数字越大权限越高)
levels:
user: 0
moderator: 1
admin: 2
owner: 3
# 角色显示名称
names:
user: "普通用户"
moderator: "版主"
admin: "管理员"
owner: "站长"
# 角色权限规则
permissions:
# 可操作的目标角色(未在此列出的角色默认可操作所有角色)
operable_roles:
admin: ["user"]
moderator: ["user"]

2
go.mod
View File

@ -1,6 +1,6 @@
module metazone.cc/metalab module metazone.cc/metalab
go 1.25.0 go 1.26.0
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0

View File

@ -15,6 +15,10 @@ func OkMessage(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": message}) c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
} }
func OkWithMessage(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": data, "message": message})
}
func Error(c *gin.Context, code int, message string) { func Error(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{"success": false, "message": message}) c.JSON(code, gin.H{"success": false, "message": message})
} }

View File

@ -15,6 +15,7 @@ type Config struct {
Database DatabaseConfig `mapstructure:"database"` Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"` JWT JWTConfig `mapstructure:"jwt"`
Bcrypt BcryptConfig `mapstructure:"bcrypt"` Bcrypt BcryptConfig `mapstructure:"bcrypt"`
Roles RolesConfig `mapstructure:"roles"`
} }
type ServerConfig struct { type ServerConfig struct {
@ -42,6 +43,18 @@ type BcryptConfig struct {
Cost int `mapstructure:"cost"` Cost int `mapstructure:"cost"`
} }
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml
type RolesConfig struct {
Levels map[string]int `mapstructure:"levels"`
Names map[string]string `mapstructure:"names"`
Permissions RolesPermissions `mapstructure:"permissions"`
}
// RolesPermissions 角色间操作权限
type RolesPermissions struct {
OperableRoles map[string][]string `mapstructure:"operable_roles"`
}
// 全局配置实例(初始化后只读) // 全局配置实例(初始化后只读)
var App *Config var App *Config

View File

@ -11,13 +11,15 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// Note: service import kept for ListUsersParams / ListUsersResult DTOs
// AdminController 管理后台控制器 // AdminController 管理后台控制器
type AdminController struct { type AdminController struct {
adminService *service.AdminService adminService adminUseCase
} }
// NewAdminController 构造函数 // NewAdminController 构造函数
func NewAdminController(adminService *service.AdminService) *AdminController { func NewAdminController(adminService adminUseCase) *AdminController {
return &AdminController{adminService: adminService} return &AdminController{adminService: adminService}
} }
@ -34,6 +36,8 @@ func (ac *AdminController) UsersPage(c *gin.Context) {
"Title": "用户管理", "Title": "用户管理",
"ExtraCSS": "/admin/static/css/users.css", "ExtraCSS": "/admin/static/css/users.css",
"ExtraJS": "/admin/static/js/users.js", "ExtraJS": "/admin/static/js/users.js",
"RoleNames": model.RoleDisplayNames,
"StatusNames": model.StatusDisplayNames,
})) }))
} }

View File

@ -0,0 +1,10 @@
package admin
import "metazone.cc/metalab/internal/service"
// adminUseCase AdminController 对 AdminService 的最小依赖ISP3 个方法)
type adminUseCase interface {
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
ResetUserToken(operatorUID, targetUID uint) error
}

View File

@ -5,9 +5,7 @@ import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"metazone.cc/metalab/internal/theme" "metazone.cc/metalab/internal/theme"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -15,21 +13,21 @@ import (
// AuthController 认证相关页面 + API // AuthController 认证相关页面 + API
type AuthController struct { type AuthController struct {
authService *service.AuthService authService authUseCase
tokenService *service.TokenService tokenService tokenRefresher
rateLimiter *middleware.RateLimiter rateLimiter rateLimiter
cfg *config.Config cfg *config.Config
} }
// NewAuthController 构造函数 // NewAuthController 构造函数
func NewAuthController(authService *service.AuthService, tokenSvc *service.TokenService, limiter *middleware.RateLimiter, cfg *config.Config) *AuthController { func NewAuthController(authService authUseCase, tokenSvc tokenRefresher, limiter rateLimiter, cfg *config.Config) *AuthController {
return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg} return &AuthController{authService: authService, tokenService: tokenSvc, rateLimiter: limiter, cfg: cfg}
} }
// RegisterPage 注册页面(已登录用户重定向到首页) // RegisterPage 注册页面(已登录用户重定向到首页)
func (ac *AuthController) RegisterPage(c *gin.Context) { func (ac *AuthController) RegisterPage(c *gin.Context) {
if _, exists := c.Get("uid"); exists { if _, exists := c.Get("uid"); exists {
c.Redirect(http.StatusMovedPermanently, "/") c.Redirect(http.StatusFound, "/")
return return
} }
guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html") guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
@ -47,7 +45,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
// LoginPage 登录页面(已登录用户重定向到首页) // LoginPage 登录页面(已登录用户重定向到首页)
func (ac *AuthController) LoginPage(c *gin.Context) { func (ac *AuthController) LoginPage(c *gin.Context) {
if _, exists := c.Get("uid"); exists { if _, exists := c.Get("uid"); exists {
c.Redirect(http.StatusMovedPermanently, "/") c.Redirect(http.StatusFound, "/")
return return
} }
c.HTML(http.StatusOK, "auth/login.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusOK, "auth/login.html", common.BuildPageData(c, gin.H{
@ -103,13 +101,13 @@ func (ac *AuthController) Login(c *gin.Context) {
} }
switch err { switch err {
case service.ErrInvalidCred: case common.ErrInvalidCred:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case service.ErrUserBanned: case common.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁") common.Error(c, http.StatusForbidden, "账号已被封禁")
case service.ErrUserLocked: case common.ErrUserLocked:
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误") common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
case service.ErrUserDeleted: case common.ErrUserDeleted:
// deleted 状态本应在 Login 中自动恢复,此 case 作为兜底 // deleted 状态本应在 Login 中自动恢复,此 case 作为兜底
common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复") common.Error(c, http.StatusForbidden, "该账号已申请注销,登录即自动恢复")
default: default:
@ -123,11 +121,7 @@ func (ac *AuthController) Login(c *gin.Context) {
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
c.JSON(http.StatusOK, gin.H{ common.OkWithMessage(c, user, "登录成功")
"success": true,
"message": "登录成功",
"data": user,
})
} }
// CheckEmail 检查邮箱是否已注册 // CheckEmail 检查邮箱是否已注册
@ -144,10 +138,7 @@ func (ac *AuthController) CheckEmail(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ common.Ok(c, gin.H{"exists": exists})
"success": true,
"data": gin.H{"exists": exists},
})
} }
// Register 注册 API // Register 注册 API
@ -171,9 +162,9 @@ func (ac *AuthController) Register(c *gin.Context) {
recordReg() recordReg()
} }
switch err { switch err {
case service.ErrEmailExists: case common.ErrEmailExists:
common.Error(c, http.StatusConflict, "该邮箱已注册") common.Error(c, http.StatusConflict, "该邮箱已注册")
case service.ErrWeakPassword: case common.ErrWeakPassword:
common.Error(c, http.StatusBadRequest, err.Error()) common.Error(c, http.StatusBadRequest, err.Error())
default: default:
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试") common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
@ -183,20 +174,13 @@ func (ac *AuthController) Register(c *gin.Context) {
common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg) common.SetAuthCookies(c, accessToken, refreshToken, req.RememberMe, ac.cfg)
c.JSON(http.StatusOK, gin.H{ common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab")
"success": true,
"message": "注册成功!欢迎加入 MetaLab",
"data": user,
})
} }
// Logout 退出登录:清除所有认证 Cookie // Logout 退出登录:清除所有认证 Cookie
func (ac *AuthController) Logout(c *gin.Context) { func (ac *AuthController) Logout(c *gin.Context) {
common.ClearAuthCookies(c, ac.cfg) common.ClearAuthCookies(c, ac.cfg)
c.JSON(http.StatusOK, gin.H{ common.OkMessage(c, "已退出登录")
"success": true,
"message": "已退出登录",
})
} }
// RefreshToken 用 refresh token 换取新的 access token // RefreshToken 用 refresh token 换取新的 access token
@ -207,23 +191,26 @@ func (ac *AuthController) RefreshToken(c *gin.Context) {
return return
} }
accessToken, _, err := ac.tokenService.RefreshAccessToken(refreshToken) accessToken, user, err := ac.tokenService.RefreshAccessToken(refreshToken)
if err != nil { if err != nil {
common.ClearAuthCookies(c, ac.cfg) common.ClearAuthCookies(c, ac.cfg)
switch err { switch err {
case service.ErrTokenExpired, service.ErrTokenRevoked: case common.ErrTokenExpired, common.ErrTokenRevoked:
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录") common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
case service.ErrUserBanned:
common.Error(c, http.StatusForbidden, "账号已被封禁")
default: default:
common.Error(c, http.StatusUnauthorized, "请重新登录") common.Error(c, http.StatusUnauthorized, "请重新登录")
} }
return return
} }
// 防止封禁用户通过 refresh 续期(状态检查是认证业务域,放在控制器层)
if user.Status == model.StatusBanned {
common.ClearAuthCookies(c, ac.cfg)
common.Error(c, http.StatusForbidden, "账号已被封禁")
return
}
common.SetAccessCookie(c, accessToken, ac.cfg) common.SetAccessCookie(c, accessToken, ac.cfg)
c.JSON(http.StatusOK, gin.H{ common.Ok(c, nil)
"success": true,
})
} }

View File

@ -0,0 +1,25 @@
package controller
import (
"metazone.cc/metalab/internal/middleware"
"metazone.cc/metalab/internal/model"
)
// authUseCase AuthController 对 AuthService 的最小依赖ISP3 个方法)
type authUseCase interface {
Register(req model.RegisterRequest) (string, string, *model.User, error)
Login(req model.LoginRequest) (string, string, *model.User, error)
CheckEmail(email string) (bool, error)
}
// tokenRefresher AuthController 对 TokenService 的最小依赖ISP1 个方法)
type tokenRefresher interface {
RefreshAccessToken(refreshTokenStr string) (string, *model.User, error)
}
// rateLimiter AuthController 对 RateLimiter 的最小依赖ISP3 个方法)
type rateLimiter interface {
AllowAccount(email string) (middleware.RateLimitResult, func())
AllowIP(ip string) (middleware.RateLimitResult, func())
Clear(email, ip string)
}

View File

@ -0,0 +1,57 @@
package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// profileProvider SettingsController 对 Service 层的最小依赖ISP1 个方法)
type profileProvider interface {
GetProfile(userID uint) (*model.User, error)
}
// SettingsController 个人设置控制器
type SettingsController struct {
authService profileProvider
}
// NewSettingsController 构造函数
func NewSettingsController(authService profileProvider) *SettingsController {
return &SettingsController{authService: authService}
}
// SettingsPage 个人设置页面(需登录)
func (sc *SettingsController) SettingsPage(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
c.Redirect(http.StatusFound, "/auth/login")
c.Abort()
return
}
uid := uidVal.(uint)
tab := c.Param("tab")
if tab != "profile" && tab != "account" {
c.String(http.StatusNotFound, "页面不存在")
return
}
user, err := sc.authService.GetProfile(uid)
if err != nil || user == nil {
c.String(http.StatusNotFound, "用户不存在")
return
}
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
"StatusName": model.StatusDisplayNames[user.Status],
}))
}

View File

@ -4,63 +4,11 @@ import (
"log" "log"
"net/http" "net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// AdminAuth 管理后台页面认证中间件
// 逻辑同 AuthRequiredJWT 校验 + token_version 吊销检查)
// 但失败时重定向到首页,而非返回 JSON
// 成功时向 context 注入 uid/email/username/role
func AdminAuth(cfg *config.Config, userRepo *repository.UserRepo) gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr, err := c.Cookie(common.CookieName)
if err != nil {
log.Printf("[AdminAuth] NO_COOKIE path=%s → 302", c.Request.URL.Path)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
claims, err := parseToken(tokenStr, cfg.JWT.Secret)
if err != nil {
log.Printf("[AdminAuth] TOKEN_PARSE_FAIL path=%s err=%v → 302", c.Request.URL.Path, err)
common.ClearAuthCookies(c, cfg)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
uid := uint(claims["uid"].(float64))
tokenVer := int(claims["ver"].(float64))
// 即时吊销检查
currentVer, err := userRepo.FindTokenVersion(uid)
if err != nil || tokenVer != currentVer {
if tokenVer != currentVer {
log.Printf("[AdminAuth] REVOKED: uid=%d tokenVer=%d dbVer=%d → 302", uid, tokenVer, currentVer)
} else {
log.Printf("[AdminAuth] DB_ERR: uid=%d err=%v → 302", uid, err)
}
common.ClearAuthCookies(c, cfg)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uid, claims["role"], c.Request.URL.Path)
c.Set("uid", uid)
c.Set("email", claims["email"])
c.Set("username", claims["username"])
c.Set("role", claims["role"])
c.Next()
}
}
// RequireMinRole 角色权限检查(用于 API 路由) // RequireMinRole 角色权限检查(用于 API 路由)
// 不足时返回 JSON 403 // 不足时返回 JSON 403
func RequireMinRole(minRole string) gin.HandlerFunc { func RequireMinRole(minRole string) gin.HandlerFunc {

View File

@ -2,12 +2,12 @@ package middleware
import ( import (
"log" "log"
"net/http"
"strings" "strings"
"time" "time"
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/repository"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -16,11 +16,11 @@ import (
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验) // AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验)
type AuthMiddleware struct { type AuthMiddleware struct {
cfg *config.Config cfg *config.Config
userRepo *repository.UserRepo userRepo tokenVersionStore
} }
// NewAuthMiddleware 构造函数 // NewAuthMiddleware 构造函数
func NewAuthMiddleware(cfg *config.Config, userRepo *repository.UserRepo) *AuthMiddleware { func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMiddleware {
return &AuthMiddleware{cfg: cfg, userRepo: userRepo} return &AuthMiddleware{cfg: cfg, userRepo: userRepo}
} }
@ -28,15 +28,7 @@ func NewAuthMiddleware(cfg *config.Config, userRepo *repository.UserRepo) *AuthM
// 令牌版本与 DB 不匹配时即时拒绝,实现实时吊销 // 令牌版本与 DB 不匹配时即时拒绝,实现实时吊销
func (am *AuthMiddleware) Required() gin.HandlerFunc { func (am *AuthMiddleware) Required() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
tokenStr, err := c.Cookie(common.CookieName) claims, err := am.authenticateToken(c)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{
"success": false, "message": "请先登录",
})
return
}
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
if err != nil { if err != nil {
common.ClearAuthCookies(c, am.cfg) common.ClearAuthCookies(c, am.cfg)
c.AbortWithStatusJSON(401, gin.H{ c.AbortWithStatusJSON(401, gin.H{
@ -45,27 +37,7 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
return return
} }
uid := uint(claims["uid"].(float64)) injectUserContext(c, claims)
tokenVer := int(claims["ver"].(float64))
// 即时吊销检查:查询 DB 当前版本号,不匹配则拒绝
currentVer, err := am.userRepo.FindTokenVersion(uid)
if err != nil || tokenVer != currentVer {
if tokenVer != currentVer {
log.Printf("[AuthRequired] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
}
common.ClearAuthCookies(c, am.cfg)
c.AbortWithStatusJSON(401, gin.H{
"success": false, "message": "登录凭证已失效,请重新登录",
})
return
}
// 注入用户信息到上下文
c.Set("uid", uid)
c.Set("email", claims["email"])
c.Set("username", claims["username"])
c.Set("role", claims["role"])
c.Next() c.Next()
} }
} }
@ -73,39 +45,70 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行(仅清除过期 Cookie // Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行(仅清除过期 Cookie
func (am *AuthMiddleware) Optional() gin.HandlerFunc { func (am *AuthMiddleware) Optional() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
tokenStr, err := c.Cookie(common.CookieName) claims, err := am.authenticateToken(c)
if err != nil { if err != nil {
log.Println("[AuthOptional] no cookie for", c.Request.URL.Path) log.Printf("[AuthOptional] token invalid for %s: %v", c.Request.URL.Path, err)
c.Next()
return
}
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
if err != nil {
log.Printf("[AuthOptional] JWT parse failed for %s: %v", c.Request.URL.Path, err)
c.Next()
return
}
uid := uint(claims["uid"].(float64))
tokenVer := int(claims["ver"].(float64))
// 即时吊销检查
currentVer, err := am.userRepo.FindTokenVersion(uid)
if err != nil || tokenVer != currentVer {
log.Printf("[AuthOptional] REVOKED or err: uid=%d tokenVer=%d dbVer=%d err=%v",
uid, tokenVer, currentVer, err)
// 已吊销:清除 Cookie但仍放行页面视图前端自行处理
c.Next() c.Next()
return return
} }
log.Printf("[AuthOptional] OK: uid=%v username=%v path=%s", claims["uid"], claims["username"], c.Request.URL.Path) log.Printf("[AuthOptional] OK: uid=%v username=%v path=%s", claims["uid"], claims["username"], c.Request.URL.Path)
c.Set("uid", uid) injectUserContext(c, claims)
c.Next()
}
}
// 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)
c.Redirect(http.StatusFound, "/")
c.Abort()
return
}
log.Printf("[AdminAuth] OK: uid=%d role=%v path=%s", uint(claims["uid"].(float64)), claims["role"], c.Request.URL.Path)
injectUserContext(c, claims)
c.Next()
}
}
// authenticateToken 统一的认证核心流程:读 Cookie → 解析 JWT → 检查 token_version
// 成功返回 claims失败返回 error调用方自行决定如何响应
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 || tokenVer != currentVer {
if tokenVer != currentVer {
log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer)
}
return nil, err
}
return claims, nil
}
// injectUserContext 将 JWT claims 中的用户信息注入 gin context
func injectUserContext(c *gin.Context, claims jwt.MapClaims) {
c.Set("uid", uint(claims["uid"].(float64)))
c.Set("email", claims["email"]) c.Set("email", claims["email"])
c.Set("username", claims["username"]) c.Set("username", claims["username"])
c.Set("role", claims["role"]) c.Set("role", claims["role"])
c.Next()
}
} }
// parseToken 解析并验证 JWT // parseToken 解析并验证 JWT

View File

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

View File

@ -19,10 +19,3 @@ type LoginRequest struct {
type CheckEmailRequest struct { type CheckEmailRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
} }
// AuthResponse 认证响应(含 JWT
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
User User `json:"user"`
}

View File

@ -15,7 +15,7 @@ type User struct {
TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT TokenVersion int `gorm:"default:0;not null" json:"-"` // 令牌版本,+1 即时吊销所有 JWT
} }
// 角色常量 // 角色常量(仅用作字符串标识符,层级和权限由配置驱动)
const ( const (
RoleUser = "user" RoleUser = "user"
RoleModerator = "moderator" RoleModerator = "moderator"
@ -31,15 +31,43 @@ const (
StatusLocked = "locked" // 永久锁定(管理员删除或注销满 7 天) StatusLocked = "locked" // 永久锁定(管理员删除或注销满 7 天)
) )
// roleLevel 角色层级映射(数字越大权限越高) // --- 以下由 InitRoles 从配置初始化,无硬编码默认值 ---
var roleLevel = map[string]int{ var roleLevel map[string]int
RoleUser: 0, var operableRoles map[string][]string
RoleModerator: 1, var RoleDisplayNames map[string]string
RoleAdmin: 2,
RoleOwner: 3, // InitRoles 从配置初始化角色系统(唯一数据源)
// 必须在服务启动前调用,不提供默认值
func InitRoles(levels map[string]int, names map[string]string, operRoles map[string][]string) {
roleLevel = levels
RoleDisplayNames = names
operableRoles = operRoles
} }
// HasMinRole 检查 role 是否达到 minRole 的权限级别 // HasMinRole 检查 role 是否达到 minRole 的权限级别
func HasMinRole(role, minRole string) bool { func HasMinRole(role, minRole string) bool {
return roleLevel[role] >= roleLevel[minRole] return roleLevel[role] >= roleLevel[minRole]
} }
// CanOperateRole 检查操作者是否可以操作目标角色
// 未在 operableRoles 中声明的角色默认可操作所有角色
func CanOperateRole(operatorRole, targetRole string) bool {
allowed, hasRule := operableRoles[operatorRole]
if !hasRule {
return true
}
for _, r := range allowed {
if r == targetRole {
return true
}
}
return false
}
// StatusDisplayNames 状态 → 中文名(服务端唯一数据源)
var StatusDisplayNames = map[string]string{
StatusActive: "正常",
StatusBanned: "已封禁",
StatusDeleted: "已注销",
StatusLocked: "已锁定",
}

View File

@ -139,8 +139,8 @@ func (r *UserRepo) Restore(uid uint) error {
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error
} }
// SearchUsers 综合搜索(含软删除用户) // buildSearchQuery 构建搜索/筛选的公共查询条件
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) { func (r *UserRepo) buildSearchQuery(keyword, role, status string) *gorm.DB {
query := r.db.Unscoped().Model(&model.User{}) query := r.db.Unscoped().Model(&model.User{})
if keyword != "" { if keyword != "" {
like := "%" + keyword + "%" like := "%" + keyword + "%"
@ -152,26 +152,21 @@ func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int)
if status != "" { if status != "" {
query = query.Where("status = ?", status) query = query.Where("status = ?", status)
} }
return query
}
// SearchUsers 综合搜索(含软删除用户)
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
var users []model.User var users []model.User
err := query.Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error err := r.buildSearchQuery(keyword, role, status).
Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
return users, err return users, err
} }
// CountSearchUsers 搜索结果总数(含软删除用户) // CountSearchUsers 搜索结果总数(含软删除用户)
func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error) { func (r *UserRepo) CountSearchUsers(keyword, role, status string) (int64, error) {
query := r.db.Unscoped().Model(&model.User{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("email LIKE ? OR username LIKE ?", like, like)
}
if role != "" {
query = query.Where("role = ?", role)
}
if status != "" {
query = query.Where("status = ?", status)
}
var count int64 var count int64
err := query.Count(&count).Error err := r.buildSearchQuery(keyword, role, status).Count(&count).Error
return count, err return count, err
} }

View File

@ -27,6 +27,7 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
authService := service.NewAuthService(userRepo, tokenSvc, cfg) authService := service.NewAuthService(userRepo, tokenSvc, cfg)
rateLimiter := middleware.NewRateLimiter() rateLimiter := middleware.NewRateLimiter()
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg) authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
settingsCtrl := controller.NewSettingsController(authService)
authMdw := middleware.NewAuthMiddleware(cfg, userRepo) authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
@ -50,6 +51,12 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
"ExtraCSS": "/static/css/home.css", "ExtraCSS": "/static/css/home.css",
})) }))
}) })
// 个人设置页(需登录,/:tab 为伪静态子页面)
pages.GET("/settings", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/settings/profile")
})
pages.GET("/settings/:tab", settingsCtrl.SettingsPage)
} }
// 认证页面(已登录自动跳走) // 认证页面(已登录自动跳走)
@ -67,7 +74,7 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
// --- 管理后台 SSR 页面(认证失败 302 跳首页) --- // --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
adminPages := r.Group("/admin") adminPages := r.Group("/admin")
adminPages.Use(middleware.AdminAuth(cfg, userRepo)) adminPages.Use(authMdw.AdminAuth())
adminPages.Use(middleware.RequirePageRole(model.RoleModerator)) adminPages.Use(middleware.RequirePageRole(model.RoleModerator))
adminPages.Use(middleware.CSRF(cfg)) adminPages.Use(middleware.CSRF(cfg))
adminPages.Use(func(c *gin.Context) { adminPages.Use(func(c *gin.Context) {

View File

@ -3,17 +3,16 @@ package service
import ( import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
) )
// AdminService 管理后台业务逻辑 // AdminService 管理后台业务逻辑
// 集中处理所有权限边界检查,避免胖控制器 // 集中处理所有权限边界检查,避免胖控制器
type AdminService struct { type AdminService struct {
userRepo *repository.UserRepo userRepo userAdminStore
} }
// NewAdminService 构造函数 // NewAdminService 构造函数
func NewAdminService(userRepo *repository.UserRepo) *AdminService { func NewAdminService(userRepo userAdminStore) *AdminService {
return &AdminService{userRepo: userRepo} return &AdminService{userRepo: userRepo}
} }
@ -132,8 +131,8 @@ func (s *AdminService) checkAndOperate(operatorUID, targetUID uint, operate func
return common.ErrUserNotFound return common.ErrUserNotFound
} }
// 4. Admin 只能操作 user 角色 // 4. 权限检查:操作者是否有权限操作目标角色
if operator.Role == model.RoleAdmin && target.Role != model.RoleUser { if !model.CanOperateRole(operator.Role, target.Role) {
return common.ErrPermissionDenied return common.ErrPermissionDenied
} }

View File

@ -6,7 +6,6 @@ import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
@ -14,13 +13,13 @@ import (
// AuthService 认证业务逻辑 // AuthService 认证业务逻辑
type AuthService struct { type AuthService struct {
userRepo *repository.UserRepo userRepo userAuthStore
tokenService *TokenService tokenService tokenProvider
cfg *config.Config cfg *config.Config
} }
// NewAuthService 构造函数 // NewAuthService 构造函数
func NewAuthService(userRepo *repository.UserRepo, tokenSvc *TokenService, cfg *config.Config) *AuthService { func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.Config) *AuthService {
return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg} return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg}
} }
@ -175,6 +174,11 @@ func (s *AuthService) CheckEmail(email string) (bool, error) {
return s.userRepo.ExistsByEmail(email) return s.userRepo.ExistsByEmail(email)
} }
// GetProfile 获取当前用户资料(供 SettingsController 使用)
func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
return s.userRepo.FindByID(userID)
}
// InvalidateSessions 吊销某用户所有 JWT递增 token_version强制所有设备重新登录 // InvalidateSessions 吊销某用户所有 JWT递增 token_version强制所有设备重新登录
// 适用场景:修改密码、账号被盗、管理员强制下线 // 适用场景:修改密码、账号被盗、管理员强制下线
func (s *AuthService) InvalidateSessions(userID uint) error { func (s *AuthService) InvalidateSessions(userID uint) error {

View File

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

View File

@ -0,0 +1,35 @@
package service
import "metazone.cc/metalab/internal/model"
// userAuthStore AuthService 所需的最小仓储接口ISP7 个方法)
// 同时满足 common.UsernameCheckerExistsByUsername可传入 GenerateUsername
type userAuthStore interface {
ExistsByEmail(email string) (bool, error)
Create(user *model.User) error
FindByEmail(email string) (*model.User, error)
FindByID(id uint) (*model.User, error)
Update(user *model.User) error
IncrementTokenVersion(userID uint) error
ExistsByUsername(username string) (bool, error)
}
// userTokenStore TokenService 所需的最小仓储接口ISP1 个方法)
type userTokenStore interface {
FindByIDForAuth(userID uint) (*model.User, error)
}
// userAdminStore AdminService 所需的最小仓储接口ISP8 个方法)
type userAdminStore interface {
CountSearchUsers(keyword, role, status string) (int64, error)
SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error)
FindByIDUnscoped(userID uint) (*model.User, error)
UpdateStatus(uid uint, status string) error
SoftDelete(uid uint) error
Restore(uid uint) error
ExistsByEmailExclude(email string, excludeUID uint) (bool, error)
IncrementTokenVersion(userID uint) error
}

View File

@ -7,7 +7,6 @@ import (
"metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
@ -16,11 +15,11 @@ import (
// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用 // 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用
type TokenService struct { type TokenService struct {
cfg *config.Config cfg *config.Config
userRepo *repository.UserRepo userRepo userTokenStore
} }
// NewTokenService 构造函数 // NewTokenService 构造函数
func NewTokenService(cfg *config.Config, userRepo *repository.UserRepo) *TokenService { func NewTokenService(cfg *config.Config, userRepo userTokenStore) *TokenService {
return &TokenService{cfg: cfg, userRepo: userRepo} return &TokenService{cfg: cfg, userRepo: userRepo}
} }
@ -110,11 +109,6 @@ func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *mod
return "", nil, common.ErrTokenRevoked return "", nil, common.ErrTokenRevoked
} }
// 防止封禁用户通过 refresh 续期
if user.Status == model.StatusBanned {
return "", nil, common.ErrUserBanned
}
accessToken, err := ts.BuildAccessToken(user) accessToken, err := ts.BuildAccessToken(user)
if err != nil { if err != nil {
return "", nil, err return "", nil, err

View File

@ -3,6 +3,7 @@
{{template "layout/nav.html" .}} {{template "layout/nav.html" .}}
<div class="home-layout">
<main class="hero"> <main class="hero">
<div class="container"> <div class="container">
<h1>Meta<span>Lab</span></h1> <h1>Meta<span>Lab</span></h1>
@ -15,6 +16,25 @@
</div> </div>
</main> </main>
<aside class="sidebar">
<div class="sidebar-card user-card">
{{if .IsLoggedIn}}
<div class="user-card-avatar">{{slice .Username 0 1}}</div>
<h3 class="user-card-name">{{.Username}}</h3>
<p class="user-card-desc">欢迎加入 MetaLab</p>
<a href="/settings/profile" class="user-card-link">个人设置</a>
{{else}}
<div class="user-card-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="28" height="28"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg>
</div>
<h3 class="user-card-name">未登录</h3>
<p class="user-card-desc">登录后解锁社区功能</p>
<a href="/auth/login" class="user-card-btn">登录</a>
{{end}}
</div>
</aside>
</div>
{{template "layout/footer.html" .}} {{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script> <script src="/static/js/common.js"></script>

View File

@ -9,4 +9,5 @@
{{if .ExtraCSS}} {{if .ExtraCSS}}
<link rel="stylesheet" href="{{.ExtraCSS}}"> <link rel="stylesheet" href="{{.ExtraCSS}}">
{{end}} {{end}}
<script src="/shared/static/js/utils.js"></script>
</head> </head>

View File

@ -7,7 +7,7 @@
<ul class="nav-links" id="navLinks"> <ul class="nav-links" id="navLinks">
<li><a href="/">首页</a></li> <li><a href="/">首页</a></li>
{{if .IsLoggedIn}} {{if .IsLoggedIn}}
<li><a href="/settings" class="nav-user">{{.Username}}</a></li> <li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li>
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li> <li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{else}} {{else}}
<li><a href="/auth/login" class="nav-login">登录</a></li> <li><a href="/auth/login" class="nav-login">登录</a></li>

View File

@ -0,0 +1,105 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="settings-layout">
<aside class="settings-sidebar">
<nav class="settings-nav">
<a href="/settings/profile"
class="settings-nav-item {{if eq .ActiveTab "profile"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/>
</svg>
个人资料
</a>
<a href="/settings/account"
class="settings-nav-item {{if eq .ActiveTab "account"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<circle cx="12" cy="16" r="1"/>
</svg>
账号信息
</a>
</nav>
</aside>
<main class="settings-main">
{{if eq .ActiveTab "profile"}}
<!-- 个人资料 -->
<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="info-row">
<span class="info-label">用户名</span>
<span class="info-value">{{.User.Username}}</span>
</div>
<div class="info-row">
<span class="info-label">头像</span>
<span class="info-value">
{{if .User.Avatar}}
<img src="{{.User.Avatar}}" alt="avatar" class="avatar-preview">
{{else}}
<span class="info-muted">未设置</span>
{{end}}
</span>
</div>
<div class="info-row">
<span class="info-label">简介</span>
<span class="info-value{{if not .User.Bio}} info-muted{{end}}">
{{if .User.Bio}}{{.User.Bio}}{{else}}未填写{{end}}
</span>
</div>
<div class="info-row">
<span class="info-label">角色</span>
<span class="info-value">
<span class="role-tag role-{{.User.Role}}">{{.RoleName}}</span>
</span>
</div>
</div>
</div>
{{else}}
<!-- 账号信息 -->
<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="info-row">
<span class="info-label">用户 ID</span>
<span class="info-value info-mono">{{.User.ID}}</span>
</div>
<div class="info-row">
<span class="info-label">邮箱</span>
<span class="info-value">{{.User.Email}}</span>
</div>
<div class="info-row">
<span class="info-label">账号状态</span>
<span class="info-value">
<span class="status-badge status-{{.User.Status}}">{{.StatusName}}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">注册时间</span>
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
</div>
<div class="info-row">
<span class="info-label">最后更新</span>
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
</div>
</div>
</div>
{{end}}
</main>
</div>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script>
</body>
</html>

View File

@ -26,9 +26,7 @@
box-sizing: border-box; box-sizing: border-box;
} }
html { html {}
scroll-snap-type: y mandatory;
}
body { body {
background: var(--color-bg); background: var(--color-bg);

View File

@ -3,16 +3,23 @@
仅在首页加载,通过 ExtraCSS 注入 仅在首页加载,通过 ExtraCSS 注入
============================================================ */ ============================================================ */
/* ---------- Two-column layout ---------- */
.home-layout {
display: flex;
min-height: 100vh;
align-items: stretch;
}
/* ---------- Hero ---------- */ /* ---------- Hero ---------- */
.hero { .hero {
min-height: 100vh; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 4rem 0; padding: 4rem 0;
text-align: center; text-align: center;
background: linear-gradient(180deg, #fff 0%, #f0f4f8 100%); background: linear-gradient(180deg, #fff 0%, #f0f4f8 100%);
scroll-snap-align: start; min-width: 0;
} }
.hero h1 { .hero h1 {
@ -105,7 +112,6 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 1.5rem 0; padding: 1.5rem 0;
scroll-snap-align: start;
} }
.plans .section-title { .plans .section-title {
@ -185,7 +191,6 @@
justify-content: center; justify-content: center;
padding: 4rem 0; padding: 4rem 0;
background: var(--color-surface); background: var(--color-surface);
scroll-snap-align: start;
} }
.features-grid { .features-grid {
@ -220,7 +225,6 @@
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
scroll-snap-align: start;
} }
.timeline-vision { .timeline-vision {
@ -316,6 +320,84 @@
font-size: .95rem; font-size: .95rem;
} }
/* ---------- Sidebar ---------- */
.sidebar {
width: 300px;
flex-shrink: 0;
padding: 4rem 20px 4rem 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.sidebar-card {
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
padding: 1.5rem;
}
/* --- User Card --- */
.user-card {
text-align: center;
}
.user-card-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto .8rem;
font-size: 1.5rem;
font-weight: 700;
text-transform: uppercase;
}
.user-card-name {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: .3rem;
}
.user-card-desc {
font-size: .85rem;
color: var(--color-text-light);
margin-bottom: 1rem;
}
.user-card-link {
display: inline-block;
font-size: .85rem;
color: var(--color-accent);
font-weight: 500;
transition: var(--transition);
}
.user-card-link:hover {
opacity: .8;
}
.user-card-btn {
display: inline-block;
padding: .5rem 1.8rem;
background: var(--color-accent);
color: #fff;
border-radius: var(--radius);
font-size: .9rem;
font-weight: 500;
transition: var(--transition);
}
.user-card-btn:hover {
opacity: .85;
}
/* ---------- Responsive (首页专属) ---------- */ /* ---------- Responsive (首页专属) ---------- */
@media (max-width: 768px) { @media (max-width: 768px) {
.hero h1 { .hero h1 {
@ -327,16 +409,15 @@
.hero-desc { .hero-desc {
font-size: 1.05rem; font-size: 1.05rem;
} }
.timeline { .timeline {
justify-content: flex-start; justify-content: flex-start;
gap: 1.5rem; gap: 1.5rem;
padding: 2rem 20px 1.5rem; padding: 2rem 20px 1.5rem;
scroll-snap-type: x mandatory;
} }
.timeline-item { .timeline-item {
min-width: 240px; min-width: 240px;
max-width: 280px; max-width: 280px;
scroll-snap-align: start;
} }
.timeline::before { .timeline::before {
left: 20px; left: 20px;

View File

@ -0,0 +1,215 @@
/* ============================================================
MetaLab Settings Styles — 个人设置页样式
左侧导航 + 右侧卡片信息展示,只读
============================================================ */
/* ---------- Page Body ---------- */
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ---------- Layout ---------- */
.settings-layout {
display: flex;
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 2rem 20px;
gap: 2rem;
}
/* ---------- Left Sidebar Nav ---------- */
.settings-sidebar {
width: 220px;
flex-shrink: 0;
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
padding: 1.25rem 1rem;
align-self: flex-start;
}
.settings-nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.settings-nav-item {
display: flex;
align-items: center;
gap: .6rem;
padding: .65rem 1rem;
border-radius: var(--radius);
font-size: .92rem;
color: var(--color-secondary);
font-weight: 500;
transition: all .15s ease;
}
.settings-nav-item:hover {
background: rgba(52, 152, 219, .06);
color: var(--color-accent);
}
.settings-nav-item.active {
background: rgba(52, 152, 219, .1);
color: var(--color-accent);
font-weight: 600;
}
.settings-nav-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* ---------- Right Content Area ---------- */
.settings-main {
flex: 1;
min-width: 0;
}
.settings-card {
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
}
.settings-card-header {
padding: 1.5rem 2rem 0;
}
.settings-card-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: .3rem;
}
.settings-card-desc {
font-size: .85rem;
color: var(--color-text-light);
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0;
}
.settings-card-body {
padding: 1.25rem 2rem 1.5rem;
}
/* ---------- Info Rows ---------- */
.info-row {
display: flex;
align-items: flex-start;
padding: .9rem 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
width: 100px;
flex-shrink: 0;
font-size: .88rem;
color: var(--color-text-light);
font-weight: 500;
padding-top: 1px;
}
.info-value {
flex: 1;
font-size: .92rem;
color: var(--color-text);
min-width: 0;
word-break: break-all;
}
.info-muted {
color: #aaa;
}
.info-mono {
font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
font-size: .85rem;
background: #f5f5f5;
padding: 1px 8px;
border-radius: 4px;
}
/* ---------- Avatar Preview ---------- */
.avatar-preview {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--color-border);
}
/* ---------- Role Tag ---------- */
.role-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 4px;
font-size: .82rem;
font-weight: 600;
}
.role-user { background: #e3f2fd; color: #1976d2; }
.role-moderator { background: #e8f5e9; color: #2e7d32; }
.role-admin { background: #fce4ec; color: #c62828; }
.role-owner { background: #fff3e0; color: #e65100; }
/* ---------- Status Badge ---------- */
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: .82rem;
font-weight: 600;
}
.status-active { background: #e8f5e9; color: #2e7d32; }
.status-banned { background: #ffebee; color: #c62828; }
.status-deleted { background: #fff3e0; color: #e65100; }
.status-locked { background: #eceff1; color: #546e7a; }
/* ---------- Responsive ---------- */
@media (max-width: 768px) {
.settings-layout {
flex-direction: column;
padding: 1rem 16px;
gap: 1rem;
}
.settings-sidebar {
width: 100%;
align-self: auto;
}
.settings-nav {
flex-direction: row;
gap: 2px;
}
.settings-nav-item {
padding: .5rem .9rem;
font-size: .85rem;
}
.settings-card-header {
padding: 1.25rem 1.25rem 0;
}
.settings-card-body {
padding: 1rem 1.25rem 1.25rem;
}
}

View File

@ -19,11 +19,8 @@
}, 3000); }, 3000);
}; };
// ---- CSRF token helper (reads from <meta> tag rendered by server) ---- // ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
window.MetaLab.csrfToken = function () { window.MetaLab.csrfToken = getCSRFToken;
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
};
// ---- Auto-attach X-CSRF-Token to all state-changing requests ---- // ---- Auto-attach X-CSRF-Token to all state-changing requests ----
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header // Monkey-patch XMLHttpRequest and fetch to inject CSRF header

View File

@ -16,14 +16,14 @@
<a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a> <a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a>
<div class="admin-header-right"> <div class="admin-header-right">
<span class="admin-user">{{.Username}}</span> <span class="admin-user">{{.Username}}</span>
<a href="/" class="admin-back-btn">返回前台</a> <a href="/" class="admin-back-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align:-2px;margin-right:3px;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>返回前台</a>
</div> </div>
</div> </div>
</header> </header>
<div class="admin-container"> <div class="admin-container">
<aside class="admin-sidebar"> <aside class="admin-sidebar">
<nav> <nav>
<a href="/admin" class="sidebar-item {{if eq .CurrentPath "/admin"}}active{{end}}"> <a href="/admin" class="sidebar-item {{if eq .CurrentPath "/admin/"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></span> <span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></span>
管理首页 管理首页
</a> </a>

View File

@ -1,5 +1,6 @@
</main> </main>
</div> </div>
<script src="/shared/static/js/utils.js"></script>
<script src="/admin/static/js/common.js"></script> <script src="/admin/static/js/common.js"></script>
{{if .ExtraJS}} {{if .ExtraJS}}
<script src="{{.ExtraJS}}"></script> <script src="{{.ExtraJS}}"></script>

View File

@ -9,17 +9,15 @@
<input type="text" id="searchInput" class="filter-input" placeholder="搜索邮箱或用户名..." autocomplete="off"> <input type="text" id="searchInput" class="filter-input" placeholder="搜索邮箱或用户名..." autocomplete="off">
<select id="roleFilter" class="filter-select"> <select id="roleFilter" class="filter-select">
<option value="">全部角色</option> <option value="">全部角色</option>
<option value="user">普通用户</option> {{range $val, $label := .RoleNames}}
<option value="moderator">版主</option> <option value="{{$val}}">{{$label}}</option>
<option value="admin">管理员</option> {{end}}
<option value="owner">站长</option>
</select> </select>
<select id="statusFilter" class="filter-select"> <select id="statusFilter" class="filter-select">
<option value="">全部状态</option> <option value="">全部状态</option>
<option value="active">正常</option> {{range $val, $label := .StatusNames}}
<option value="banned">已封禁</option> <option value="{{$val}}">{{$label}}</option>
<option value="deleted">已注销</option> {{end}}
<option value="locked">已锁定</option>
</select> </select>
<button id="searchBtn" class="btn btn-primary">搜索</button> <button id="searchBtn" class="btn btn-primary">搜索</button>
</div> </div>
@ -46,5 +44,17 @@
<!-- 分页 --> <!-- 分页 -->
<div class="pagination-bar" id="paginationBar"></div> <div class="pagination-bar" id="paginationBar"></div>
<script>window.__currentUid = {{.UID}}; window.__isOwner = {{if .IsOwner}}true{{else}}false{{end}};</script> <script>
window.__currentUid = {{.UID}};
window.__isOwner = {{if .IsOwner}}true{{else}}false{{end}};
// 由服务端 model.RoleDisplayNames / StatusDisplayNames 作为唯一数据源注入
window.__ROLE_NAMES = {
{{range $val, $label := .RoleNames}}"{{$val}}": "{{$label}}",
{{end}}
};
window.__STATUS_NAMES = {
{{range $val, $label := .StatusNames}}"{{$val}}": "{{$label}}",
{{end}}
};
</script>
{{template "admin/layout/footer.html" .}} {{template "admin/layout/footer.html" .}}

View File

@ -3,10 +3,10 @@
单一职责:仅用户管理页面特有样式 单一职责:仅用户管理页面特有样式
===================================================== */ ===================================================== */
.data-table .col-uid { width: 60px; } .data-table .col-uid { width: 60px; white-space: nowrap; }
.data-table .col-username { width: 120px; } .data-table .col-username { width: 120px; white-space: nowrap; }
.data-table .col-email { min-width: 180px; } .data-table .col-email { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table .col-role { width: 90px; } .data-table .col-role { width: 100px; white-space: nowrap; }
.data-table .col-status { width: 90px; } .data-table .col-status { width: 90px; white-space: nowrap; }
.data-table .col-time { width: 140px; } .data-table .col-time { width: 140px; white-space: nowrap; }
.data-table .col-actions { width: 160px; } .data-table .col-actions { width: 180px; white-space: nowrap; }

View File

@ -5,6 +5,7 @@
===================================================== */ ===================================================== */
// --- AJAX 封装 --- // --- AJAX 封装 ---
// 注意getCSRFToken() 由共享 utils.js 提供
const api = { const api = {
async get(url) { async get(url) {
const res = await fetch(url, { const res = await fetch(url, {
@ -36,11 +37,6 @@ const api = {
} }
}; };
function getCSRFToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
// --- Toast 通知 --- // --- Toast 通知 ---
function showToast(message, type) { function showToast(message, type) {
const container = document.querySelector('.toast-container') || createToastContainer(); const container = document.querySelector('.toast-container') || createToastContainer();
@ -110,13 +106,15 @@ function formatTime(isoStr) {
} }
// --- 角色标签 --- // --- 角色标签 ---
// 数据源由服务端 model.RoleDisplayNames 注入为 window.__ROLE_NAMES
function roleLabel(role) { function roleLabel(role) {
const map = { user: '普通用户', moderator: '版主', admin: '管理员', owner: '站长' }; var map = window.__ROLE_NAMES || { user: '普通用户', moderator: '版主', admin: '管理员', owner: '站长' };
return `<span class="role-tag role-${role}">${map[role] || role}</span>`; return '<span class="role-tag role-' + role + '">' + (map[role] || role) + '</span>';
} }
// --- 状态标签 --- // --- 状态标签 ---
// 数据源由服务端 model.StatusDisplayNames 注入为 window.__STATUS_NAMES
function statusLabel(status) { function statusLabel(status) {
const map = { active: '正常', banned: '已封禁', deleted: '已注销', locked: '已锁定' }; var map = window.__STATUS_NAMES || { active: '正常', banned: '已封禁', deleted: '已注销', locked: '已锁定' };
return `<span class="badge badge-${status}">${map[status] || status}</span>`; return '<span class="badge badge-' + status + '">' + (map[status] || status) + '</span>';
} }

View File

@ -0,0 +1,14 @@
/* ============================================================
MetaLab 共享工具函数 — 前后台公用,消除 DRY 重复
============================================================ */
'use strict';
/**
* 从 <meta name="csrf-token"> 读取 CSRF 令牌
* 由服务端 CSRF 中间件注入到页面模板的 <meta> 标签中
* @returns {string} CSRF 令牌,未找到时返回空字符串
*/
function getCSRFToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}