-
-MetaLab
-下一代开发者社区
- {{if .IsLoggedIn}} -欢迎回来,{{.Username}}
- {{else}} -一个为构建者准备的纯粹空间
- {{end}} -diff --git a/README.md b/README.md index 58c238c..aabbc6c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,118 @@ -# MetaLab +
+
+
+
+
+
+
+
下一代开源技术社区——为构建者准备的纯粹空间
+ +--- + +## 关于项目 + +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) 获取商业授权。 + +--- + ++ © 2024-2026 MetaZone | Built with ❤️ by MetaZone +
diff --git a/cmd/server/main.go b/cmd/server/main.go index fcf24e5..d4856d3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/config.yaml b/config.yaml index 273025e..e41ecdc 100644 --- a/config.yaml +++ b/config.yaml @@ -3,13 +3,13 @@ server: port: 8080 - mode: debug # debug | release | test + mode: debug # debug | release | test database: host: 127.0.0.1 port: 5432 user: metazone - password: "" # 敏感值由 .env 注入 + password: "" # 敏感值由 .env 注入 dbname: metalab_dev sslmode: disable @@ -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"] diff --git a/go.mod b/go.mod index 6ad1a69..0edaa7a 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/common/response.go b/internal/common/response.go index 60f3e8d..5d17f65 100644 --- a/internal/common/response.go +++ b/internal/common/response.go @@ -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}) } diff --git a/internal/config/config.go b/internal/config/config.go index 55b9b82..cdb1cb0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/controller/admin/admin_controller.go b/internal/controller/admin/admin_controller.go index bc48c9e..a75244f 100644 --- a/internal/controller/admin/admin_controller.go +++ b/internal/controller/admin/admin_controller.go @@ -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} } @@ -31,9 +33,11 @@ func (ac *AdminController) Dashboard(c *gin.Context) { // UsersPage 用户管理页面 func (ac *AdminController) UsersPage(c *gin.Context) { c.HTML(http.StatusOK, "admin/users/index.html", common.BuildAdminPageData(c, gin.H{ - "Title": "用户管理", - "ExtraCSS": "/admin/static/css/users.css", - "ExtraJS": "/admin/static/js/users.js", + "Title": "用户管理", + "ExtraCSS": "/admin/static/css/users.css", + "ExtraJS": "/admin/static/js/users.js", + "RoleNames": model.RoleDisplayNames, + "StatusNames": model.StatusDisplayNames, })) } diff --git a/internal/controller/admin/interfaces.go b/internal/controller/admin/interfaces.go new file mode 100644 index 0000000..cd6f984 --- /dev/null +++ b/internal/controller/admin/interfaces.go @@ -0,0 +1,10 @@ +package admin + +import "metazone.cc/metalab/internal/service" + +// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:3 个方法) +type adminUseCase interface { + ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error) + UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error + ResetUserToken(operatorUID, targetUID uint) error +} diff --git a/internal/controller/auth_controller.go b/internal/controller/auth_controller.go index 87e995c..d7be4a5 100644 --- a/internal/controller/auth_controller.go +++ b/internal/controller/auth_controller.go @@ -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) } diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go new file mode 100644 index 0000000..4ca648b --- /dev/null +++ b/internal/controller/interfaces.go @@ -0,0 +1,25 @@ +package controller + +import ( + "metazone.cc/metalab/internal/middleware" + "metazone.cc/metalab/internal/model" +) + +// authUseCase AuthController 对 AuthService 的最小依赖(ISP:3 个方法) +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 的最小依赖(ISP:1 个方法) +type tokenRefresher interface { + RefreshAccessToken(refreshTokenStr string) (string, *model.User, error) +} + +// rateLimiter AuthController 对 RateLimiter 的最小依赖(ISP:3 个方法) +type rateLimiter interface { + AllowAccount(email string) (middleware.RateLimitResult, func()) + AllowIP(ip string) (middleware.RateLimitResult, func()) + Clear(email, ip string) +} diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go new file mode 100644 index 0000000..dfc74aa --- /dev/null +++ b/internal/controller/settings_controller.go @@ -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 层的最小依赖(ISP:1 个方法) +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], + })) +} diff --git a/internal/middleware/admin.go b/internal/middleware/admin.go index e9e3aa2..32ecab4 100644 --- a/internal/middleware/admin.go +++ b/internal/middleware/admin.go @@ -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 管理后台页面认证中间件 -// 逻辑同 AuthRequired(JWT 校验 + 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 { diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 0f1aacc..4bbd54d 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -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,41 +45,72 @@ 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) - c.Set("email", claims["email"]) - c.Set("username", claims["username"]) - c.Set("role", claims["role"]) + 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"]) +} + // parseToken 解析并验证 JWT func parseToken(tokenStr, secret string) (jwt.MapClaims, error) { now := time.Now() diff --git a/internal/middleware/repository.go b/internal/middleware/repository.go new file mode 100644 index 0000000..621a289 --- /dev/null +++ b/internal/middleware/repository.go @@ -0,0 +1,6 @@ +package middleware + +// tokenVersionStore 最小接口:仅暴露 AuthMiddleware 需要的 1 个方法 +type tokenVersionStore interface { + FindTokenVersion(userID uint) (int, error) +} diff --git a/internal/model/dto.go b/internal/model/dto.go index 3246f04..f99ae78 100644 --- a/internal/model/dto.go +++ b/internal/model/dto.go @@ -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"` -} diff --git a/internal/model/user.go b/internal/model/user.go index 753d939..df816cc 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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: "已锁定", +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index b5ba1e9..4644b6f 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -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 } diff --git a/internal/router/router.go b/internal/router/router.go index ecbd80c..471f9d1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) { diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index 68ca9c1..919fcf1 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -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 } diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 598f6fd..9736d53 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -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 { diff --git a/internal/service/provider.go b/internal/service/provider.go new file mode 100644 index 0000000..278202e --- /dev/null +++ b/internal/service/provider.go @@ -0,0 +1,9 @@ +package service + +import "metazone.cc/metalab/internal/model" + +// tokenProvider AuthService 对 TokenService 的最小依赖(ISP:2 个方法) +type tokenProvider interface { + BuildAccessToken(user *model.User) (string, error) + BuildRefreshToken(user *model.User, rememberMe bool) (string, error) +} diff --git a/internal/service/repository.go b/internal/service/repository.go new file mode 100644 index 0000000..e1a0c8b --- /dev/null +++ b/internal/service/repository.go @@ -0,0 +1,35 @@ +package service + +import "metazone.cc/metalab/internal/model" + +// userAuthStore AuthService 所需的最小仓储接口(ISP:7 个方法) +// 同时满足 common.UsernameChecker(ExistsByUsername),可传入 GenerateUsername +type userAuthStore interface { + ExistsByEmail(email string) (bool, error) + Create(user *model.User) error + FindByEmail(email string) (*model.User, error) + FindByID(id uint) (*model.User, error) + Update(user *model.User) error + IncrementTokenVersion(userID uint) error + ExistsByUsername(username string) (bool, error) +} + +// userTokenStore TokenService 所需的最小仓储接口(ISP:1 个方法) +type userTokenStore interface { + FindByIDForAuth(userID uint) (*model.User, error) +} + +// userAdminStore AdminService 所需的最小仓储接口(ISP:8 个方法) +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 +} + + + diff --git a/internal/service/token_service.go b/internal/service/token_service.go index ca900be..c4c3da9 100644 --- a/internal/service/token_service.go +++ b/internal/service/token_service.go @@ -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 diff --git a/templates/MetaLab-2026/html/home/index.html b/templates/MetaLab-2026/html/home/index.html index 2b258fe..c00e929 100644 --- a/templates/MetaLab-2026/html/home/index.html +++ b/templates/MetaLab-2026/html/home/index.html @@ -3,17 +3,37 @@ {{template "layout/nav.html" .}} -下一代开发者社区
- {{if .IsLoggedIn}} -欢迎回来,{{.Username}}
- {{else}} -一个为构建者准备的纯粹空间
- {{end}} -下一代开发者社区
+ {{if .IsLoggedIn}} +欢迎回来,{{.Username}}
+ {{else}} +一个为构建者准备的纯粹空间
+ {{end}} +