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:
119
README.md
119
README.md
@ -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>© 2024-2026 MetaZone | Built with ❤️ by <a href="https://metazone.cc">MetaZone</a> </sub>
|
||||
</p>
|
||||
|
||||
@ -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)
|
||||
|
||||
21
config.yaml
21
config.yaml
@ -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
2
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
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
10
internal/controller/admin/interfaces.go
Normal file
10
internal/controller/admin/interfaces.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
25
internal/controller/interfaces.go
Normal file
25
internal/controller/interfaces.go
Normal file
@ -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)
|
||||
}
|
||||
57
internal/controller/settings_controller.go
Normal file
57
internal/controller/settings_controller.go
Normal 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 层的最小依赖(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],
|
||||
}))
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
6
internal/middleware/repository.go
Normal file
6
internal/middleware/repository.go
Normal file
@ -0,0 +1,6 @@
|
||||
package middleware
|
||||
|
||||
// tokenVersionStore 最小接口:仅暴露 AuthMiddleware 需要的 1 个方法
|
||||
type tokenVersionStore interface {
|
||||
FindTokenVersion(userID uint) (int, error)
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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: "已锁定",
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
9
internal/service/provider.go
Normal file
9
internal/service/provider.go
Normal file
@ -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)
|
||||
}
|
||||
35
internal/service/repository.go
Normal file
35
internal/service/repository.go
Normal file
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" .}}
|
||||
|
||||
|
||||
@ -9,4 +9,5 @@
|
||||
{{if .ExtraCSS}}
|
||||
<link rel="stylesheet" href="{{.ExtraCSS}}">
|
||||
{{end}}
|
||||
<script src="/shared/static/js/utils.js"></script>
|
||||
</head>
|
||||
|
||||
@ -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>
|
||||
|
||||
105
templates/MetaLab-2026/html/settings/index.html
Normal file
105
templates/MetaLab-2026/html/settings/index.html
Normal 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>
|
||||
@ -26,9 +26,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
html {}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
|
||||
@ -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;
|
||||
|
||||
215
templates/MetaLab-2026/static/css/settings.css
Normal file
215
templates/MetaLab-2026/static/css/settings.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" .}}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>';
|
||||
}
|
||||
|
||||
14
templates/shared/static/js/utils.js
Normal file
14
templates/shared/static/js/utils.js
Normal 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') : '';
|
||||
}
|
||||
Reference in New Issue
Block a user