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

View File

@ -21,3 +21,24 @@ jwt:
bcrypt:
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
go 1.25.0
go 1.26.0
require (
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})
}
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) {
c.JSON(code, gin.H{"success": false, "message": message})
}

View File

@ -15,6 +15,7 @@ type Config struct {
Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"`
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
Roles RolesConfig `mapstructure:"roles"`
}
type ServerConfig struct {
@ -42,6 +43,18 @@ type BcryptConfig struct {
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

View File

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

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"
"net/http"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"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 路由)
// 不足时返回 JSON 403
func RequireMinRole(minRole string) gin.HandlerFunc {

View File

@ -2,12 +2,12 @@ package middleware
import (
"log"
"net/http"
"strings"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/repository"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
@ -16,11 +16,11 @@ import (
// AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验)
type AuthMiddleware struct {
cfg *config.Config
userRepo *repository.UserRepo
userRepo tokenVersionStore
}
// NewAuthMiddleware 构造函数
func NewAuthMiddleware(cfg *config.Config, userRepo *repository.UserRepo) *AuthMiddleware {
func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMiddleware {
return &AuthMiddleware{cfg: cfg, userRepo: userRepo}
}
@ -28,15 +28,7 @@ func NewAuthMiddleware(cfg *config.Config, userRepo *repository.UserRepo) *AuthM
// 令牌版本与 DB 不匹配时即时拒绝,实现实时吊销
func (am *AuthMiddleware) Required() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr, err := c.Cookie(common.CookieName)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{
"success": false, "message": "请先登录",
})
return
}
claims, err := parseToken(tokenStr, am.cfg.JWT.Secret)
claims, err := am.authenticateToken(c)
if err != nil {
common.ClearAuthCookies(c, am.cfg)
c.AbortWithStatusJSON(401, gin.H{
@ -45,27 +37,7 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
return
}
uid := uint(claims["uid"].(float64))
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"])
injectUserContext(c, claims)
c.Next()
}
}
@ -73,39 +45,70 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行(仅清除过期 Cookie
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr, err := c.Cookie(common.CookieName)
claims, err := am.authenticateToken(c)
if err != nil {
log.Println("[AuthOptional] no cookie for", c.Request.URL.Path)
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但仍放行页面视图前端自行处理
log.Printf("[AuthOptional] token invalid for %s: %v", c.Request.URL.Path, err)
c.Next()
return
}
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("username", claims["username"])
c.Set("role", claims["role"])
c.Next()
}
}
// 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 {
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
}
// 角色常量
// 角色常量(仅用作字符串标识符,层级和权限由配置驱动)
const (
RoleUser = "user"
RoleModerator = "moderator"
@ -31,15 +31,43 @@ const (
StatusLocked = "locked" // 永久锁定(管理员删除或注销满 7 天)
)
// roleLevel 角色层级映射(数字越大权限越高)
var roleLevel = map[string]int{
RoleUser: 0,
RoleModerator: 1,
RoleAdmin: 2,
RoleOwner: 3,
// --- 以下由 InitRoles 从配置初始化,无硬编码默认值 ---
var roleLevel map[string]int
var operableRoles map[string][]string
var RoleDisplayNames map[string]string
// InitRoles 从配置初始化角色系统(唯一数据源)
// 必须在服务启动前调用,不提供默认值
func InitRoles(levels map[string]int, names map[string]string, operRoles map[string][]string) {
roleLevel = levels
RoleDisplayNames = names
operableRoles = operRoles
}
// HasMinRole 检查 role 是否达到 minRole 的权限级别
func HasMinRole(role, minRole string) bool {
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
}
// SearchUsers 综合搜索(含软删除用户)
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
// buildSearchQuery 构建搜索/筛选的公共查询条件
func (r *UserRepo) buildSearchQuery(keyword, role, status string) *gorm.DB {
query := r.db.Unscoped().Model(&model.User{})
if keyword != "" {
like := "%" + keyword + "%"
@ -152,26 +152,21 @@ func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int)
if 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
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
}
// CountSearchUsers 搜索结果总数(含软删除用户)
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
err := query.Count(&count).Error
err := r.buildSearchQuery(keyword, role, status).Count(&count).Error
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)
rateLimiter := middleware.NewRateLimiter()
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
settingsCtrl := controller.NewSettingsController(authService)
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",
}))
})
// 个人设置页(需登录,/: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 跳首页) ---
adminPages := r.Group("/admin")
adminPages.Use(middleware.AdminAuth(cfg, userRepo))
adminPages.Use(authMdw.AdminAuth())
adminPages.Use(middleware.RequirePageRole(model.RoleModerator))
adminPages.Use(middleware.CSRF(cfg))
adminPages.Use(func(c *gin.Context) {

View File

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

View File

@ -6,7 +6,6 @@ import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@ -14,13 +13,13 @@ import (
// AuthService 认证业务逻辑
type AuthService struct {
userRepo *repository.UserRepo
tokenService *TokenService
userRepo userAuthStore
tokenService tokenProvider
cfg *config.Config
}
// 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}
}
@ -175,6 +174,11 @@ func (s *AuthService) CheckEmail(email string) (bool, error) {
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强制所有设备重新登录
// 适用场景:修改密码、账号被盗、管理员强制下线
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/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/repository"
"github.com/golang-jwt/jwt/v5"
)
@ -16,11 +15,11 @@ import (
// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用
type TokenService struct {
cfg *config.Config
userRepo *repository.UserRepo
userRepo userTokenStore
}
// NewTokenService 构造函数
func NewTokenService(cfg *config.Config, userRepo *repository.UserRepo) *TokenService {
func NewTokenService(cfg *config.Config, userRepo userTokenStore) *TokenService {
return &TokenService{cfg: cfg, userRepo: userRepo}
}
@ -110,11 +109,6 @@ func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *mod
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

View File

@ -3,7 +3,8 @@
{{template "layout/nav.html" .}}
<main class="hero">
<div class="home-layout">
<main class="hero">
<div class="container">
<h1>Meta<span>Lab</span></h1>
<p class="hero-tagline">下一代开发者社区</p>
@ -13,7 +14,26 @@
<p class="hero-desc">一个为构建者准备的纯粹空间</p>
{{end}}
</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" .}}

View File

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

View File

@ -7,7 +7,7 @@
<ul class="nav-links" id="navLinks">
<li><a href="/">首页</a></li>
{{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>
{{else}}
<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;
}
html {
scroll-snap-type: y mandatory;
}
html {}
body {
background: var(--color-bg);

View File

@ -3,16 +3,23 @@
仅在首页加载,通过 ExtraCSS 注入
============================================================ */
/* ---------- Two-column layout ---------- */
.home-layout {
display: flex;
min-height: 100vh;
align-items: stretch;
}
/* ---------- Hero ---------- */
.hero {
min-height: 100vh;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 4rem 0;
text-align: center;
background: linear-gradient(180deg, #fff 0%, #f0f4f8 100%);
scroll-snap-align: start;
min-width: 0;
}
.hero h1 {
@ -105,7 +112,6 @@
flex-direction: column;
justify-content: center;
padding: 1.5rem 0;
scroll-snap-align: start;
}
.plans .section-title {
@ -185,7 +191,6 @@
justify-content: center;
padding: 4rem 0;
background: var(--color-surface);
scroll-snap-align: start;
}
.features-grid {
@ -220,7 +225,6 @@
height: 100vh;
display: flex;
flex-direction: column;
scroll-snap-align: start;
}
.timeline-vision {
@ -316,6 +320,84 @@
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 (首页专属) ---------- */
@media (max-width: 768px) {
.hero h1 {
@ -327,16 +409,15 @@
.hero-desc {
font-size: 1.05rem;
}
.timeline {
justify-content: flex-start;
gap: 1.5rem;
padding: 2rem 20px 1.5rem;
scroll-snap-type: x mandatory;
}
.timeline-item {
min-width: 240px;
max-width: 280px;
scroll-snap-align: start;
}
.timeline::before {
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);
};
// ---- CSRF token helper (reads from <meta> tag rendered by server) ----
window.MetaLab.csrfToken = function () {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
};
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
window.MetaLab.csrfToken = getCSRFToken;
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
// 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>
<div class="admin-header-right">
<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>
</header>
<div class="admin-container">
<aside class="admin-sidebar">
<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>
管理首页
</a>

View File

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

View File

@ -9,17 +9,15 @@
<input type="text" id="searchInput" class="filter-input" placeholder="搜索邮箱或用户名..." autocomplete="off">
<select id="roleFilter" class="filter-select">
<option value="">全部角色</option>
<option value="user">普通用户</option>
<option value="moderator">版主</option>
<option value="admin">管理员</option>
<option value="owner">站长</option>
{{range $val, $label := .RoleNames}}
<option value="{{$val}}">{{$label}}</option>
{{end}}
</select>
<select id="statusFilter" class="filter-select">
<option value="">全部状态</option>
<option value="active">正常</option>
<option value="banned">已封禁</option>
<option value="deleted">已注销</option>
<option value="locked">已锁定</option>
{{range $val, $label := .StatusNames}}
<option value="{{$val}}">{{$label}}</option>
{{end}}
</select>
<button id="searchBtn" class="btn btn-primary">搜索</button>
</div>
@ -46,5 +44,17 @@
<!-- 分页 -->
<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" .}}

View File

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

View File

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