Go语言全栈实战:基于 Gin + Vue + JWT + RBAC 从零搭建前后端分离权限管理系统
Go 语言全栈实战:基于 Gin + Vue + JWT + RBAC 从零搭建前后端分离权限管理系统
一、导读:我们到底要交付什么
本文要从零搭建一套前后端分离的后台权限管理系统,技术栈为 Gin + GORM + MySQL + Redis + Vue 3 + Vite + Pinia + Vue Router 4,最终交付物包括:
- 一套完整的权限模型:用户、角色、菜单/按钮、权限点,以及它们之间的多对多关系;
- 一套可用的认证体系:bcrypt 密码存储、JWT 双令牌签发与校验、刷新、注销与失效处理;
- 一套接口级授权体系:权限标识校验中间件、超级管理员旁路、Redis 权限缓存与失效策略;
- 一条打通的动态路由链路:后端菜单树 → 前端
addRoute→ 动态侧边栏 → 按钮级权限; - 一个轻量代码生成器:读取 MySQL 表元数据,生成 Go model/service/handler/router 与 Vue 列表页,并同步产出菜单种子 SQL;
- 一套可复现的部署方案:Docker Compose 编排 MySQL、Redis、Gin 后端与 Nginx 前端。
在动手之前需要交代素材边界。本次研究采集了 GitHub、Gitee、掘金、CSDN 共 73 条来源,但原始数据中的热度值全部为 0、发布时间全部为空,因此本文不做任何"热门程度""增长比例""近期爆火"类判断 ,涉及第三方开源项目时仅陈述其仓库 README 自述的功能定位,不引用其内部 API、表结构或实现细节;文中示例依赖的具体版本以 go.mod、package.json 实际解析结果为准,不在此硬编码版本号。
阅读地图如下,可按需跳读:
| 你的目标 | 建议阅读 |
|---|---|
| 只想搞懂权限模型怎么建 | 第二章 |
| 只想实现登录与 Token | 第三、四章 |
| 只想做接口鉴权与越权防护 | 第五章 |
| 只想做动态菜单与动态路由 | 第六、七章 |
| 只想做代码生成器 | 第八章 |
| 只想上线部署 | 第九、十章 |

二、技术选型:自建还是套脚手架
2.1 Go 后台系统的三种形态
当前 Go 后台生态里,权限管理类项目大致分成三类:
| 形态 | 代表 | 自述能力 | 优点 | 代价 |
|---|---|---|---|---|
| 纯后端脚手架/快速开发平台 | GoFlyAdmin、go-user-api 等 | 通用后台模块、用户中心 | 后端起点快 | 前端仍需自建 |
| 前后端一体脚手架 | gin-vue-admin、EasyGoAdmin_Gin_EleVue、Ruoyi-Go、cutego | JWT、RBAC、动态路由/菜单、代码生成器 | 功能齐、可直接二开 | 结构复杂,学习成本高 |
| 从零自建 | 本文路线 | 自定义最小闭环 | 结构清晰、可控可解释 | 需要自己补齐工程细节 |
gin-vue-admin 的仓库说明中列举了 JWT 鉴权、动态路由、动态菜单、Casbin 鉴权、表单生成器、代码生成器等能力23;EasyGoAdmin_Gin_EleVue 自述以 Golang、Gin、Xorm、Vue、ElementUI、MySQL 构建,内置 RBAC 架构与代码生成器,支持"一键 CRUD 生成整个模块的全部代码"4;Ruoyi-Go 自述为 Go + Gin + JWT + Vue 的前后端分离权限管理系统,并额外提供原生 Android 版本6;cutego 则明确自述采用 Gin、Xorm、自定义 RBAC、Redis 与 JWT,未使用 Casbin5。这几条自述恰好说明:同一种需求可以有 Casbin 与自研 RBAC 两条路线,而且自研路线在生产中是被实际采用的。
本文选择自研 RBAC,理由是教学场景下必须让读者看清每一条授权判断的来源;Casbin 作为可替换组件放在演进章节讨论。
2.2 Casbin 与自研 RBAC 的取舍维度
| 维度 | 自研 RBAC | Casbin |
|---|---|---|
| 策略表达力 | 够用,主要覆盖"角色---资源---动作" | 支持 ACL、RBAC、ABAC 等多种模型,表达力更强 |
| 可读性与调试 | 判断链路全在自己代码里,堆栈清晰 | 需理解模型文件、策略加载与适配器 |
| 可审计性 | 策略存于业务表,易于和菜单表联动 | 策略在 casbin_rule 表,需要额外对账 |
| 学习成本 | 低 | 中,需要掌握 model.conf 语法 |
| 适合场景 | 中小型后台、权限结构稳定 | 权限模型多变、需要动态策略或数据权限扩展 |
关键不是"谁更高级",而是权限语义必须先在数据模型里定清楚。模型不清楚,换什么库都会越写越乱。
2.3 项目非目标
为了让主线聚焦,本项目明确不做:微服务拆分、工作流引擎、多租户、行级数据权限、消息队列异步化。这些方向属于另一档工程复杂度,例如基于 go-zero 与 gRPC 的微服务博客系统会同时处理 RBAC、WebSocket、OAuth2.0 与 Kubernetes 部署12,适合在单体闭环跑通后再演进。
三、权限模型设计:用户、角色、菜单与权限点
3.1 先把术语定准
**认证(Authentication)**回答"你是谁",由登录与 JWT 完成;**授权(Authorization)**回答"你能做什么",由 RBAC 完成。初学者最常见的错误是把两者混在一起:登录成功只代表身份合法,不代表可以访问所有接口。
RBAC0 基本模型是:用户分配角色,角色分配权限。在后台系统里"权限"通常又被拆成三层:
- 目录:侧边栏分组,不对应页面;
- 菜单:对应前端路由与页面;
- 按钮/权限点 :对应页面内的操作,用权限标识表达,例如
system:user:add。
菜单权限决定"你能不能看见并进入这个页面",接口权限决定"你能不能调用这个接口"。二者必须分开校验:前端隐藏按钮只是体验优化,后端接口仍要独立拦截。
3.2 表结构设计
下面是完整 DDL。为了简化部署与迁移,本项目不使用物理外键,关系完整性由 service 层与事务保证,理由是物理外键会让批量导入、软删除和分库分表都变得笨重,而 GORM 项目通常也不依赖数据库级级联。
sql
-- 用户表
CREATE TABLE `sys_user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NOT NULL COMMENT '登录名,唯一',
`password` VARCHAR(100) NOT NULL COMMENT 'bcrypt 哈希,禁止存明文',
`nickname` VARCHAR(64) NOT NULL DEFAULT '',
`email` VARCHAR(128) NOT NULL DEFAULT '',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
`last_login_at` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME DEFAULT NULL COMMENT '软删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户';
-- 角色表
CREATE TABLE `sys_role` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`role_name` VARCHAR(64) NOT NULL COMMENT '显示名,如 超级管理员',
`role_key` VARCHAR(64) NOT NULL COMMENT '程序标识,如 admin',
`sort_no` INT NOT NULL DEFAULT 0,
`status` TINYINT NOT NULL DEFAULT 1,
`remark` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_key` (`role_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色';
-- 用户-角色
CREATE TABLE `sys_user_role` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`role_id` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联';
-- 菜单/按钮(同时承载前端路由元信息与权限标识)
CREATE TABLE `sys_menu` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=根节点',
`menu_type` TINYINT NOT NULL COMMENT '1=目录 2=菜单 3=按钮',
`title` VARCHAR(64) NOT NULL COMMENT '菜单显示名',
`name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '路由 name,需唯一',
`path` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '路由 path',
`component` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '前端组件标识,如 system/user/index',
`permission` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '权限标识,如 system:user:add',
`icon` VARCHAR(64) NOT NULL DEFAULT '',
`sort_no` INT NOT NULL DEFAULT 0,
`visible` TINYINT NOT NULL DEFAULT 1 COMMENT '1=显示 0=隐藏',
`keep_alive` TINYINT NOT NULL DEFAULT 0 COMMENT '是否缓存页面',
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_parent` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单与权限点';
-- 角色-菜单
CREATE TABLE `sys_role_menu` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`role_id` BIGINT UNSIGNED NOT NULL,
`menu_id` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联';
设计要点 :sys_menu 一张表同时承载两件事------面向前端的路由元信息(name/path/component/icon/keep_alive)和面向后端的权限标识(permission)。这是动态路由与接口鉴权能共用一套权限数据的根基。若把菜单与权限点拆成两张表,理论上更"纯粹",但每次授权都要维护两份关联,中小项目收益有限。
sys_menu 字段含义速查:
| 字段 | 目录 | 菜单 | 按钮 |
|---|---|---|---|
menu_type |
1 | 2 | 3 |
path |
分组路径 | 页面路由 | 通常为空 |
component |
为空 | 组件标识 | 为空 |
permission |
通常为空 | 可为空或页面级标识 | 必填,如 system:user:delete |
3.3 种子数据与超管保护
sql
INSERT INTO `sys_role` (`id`, `role_name`, `role_key`, `sort_no`, `remark`) VALUES
(1, '超级管理员', 'admin', 1, '拥有全部权限,走旁路逻辑'),
(2, '普通用户', 'user', 9, '仅可查看示例模块');
-- 初始管理员密码为随机生成后写入,此处仅为占位哈希,部署时必须替换
INSERT INTO `sys_user` (`id`, `username`, `password`, `nickname`) VALUES
(1, 'admin', '$2a$10$REPLACE_ME_WITH_REAL_BCRYPT_HASH', '管理员');
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (1, 1);
超管保护策略有三条:role_key='admin' 的角色禁止删除、禁止改 role_key;拥有该角色的最后一个用户禁止停用;初始密码必须由部署脚本生成随机值并通过安全渠道下发。任何"后门账号""测试账号"都应在上线检查中清除。
四、工程骨架与统一约定
4.1 目录结构
text
server/
├── cmd/server/main.go
├── config/config.yaml
├── internal/
│ ├── handler/ # HTTP 层:参数绑定、响应
│ ├── service/ # 业务逻辑、事务边界
│ ├── repository/ # 数据访问
│ ├── model/ # GORM 模型与 DTO
│ ├── router/ # 路由注册
│ └── middleware/ # 认证、鉴权、跨域、日志、恢复
├── pkg/
│ ├── auth/ # JWT 签发与解析
│ ├── response/ # 统一响应
│ └── errcode/ # 业务错误码
├── scripts/ # 初始化 SQL、迁移脚本
└── deploy/ # Dockerfile、compose、nginx
web/
├── src/
│ ├── api/ # 接口封装
│ ├── router/ # 静态路由 + 动态路由构建
│ ├── store/ # Pinia:user、permission
│ ├── layout/ # 布局框架
│ ├── views/ # 页面组件
│ └── directives/ # v-permission
└── vite.config.ts
分层原则是依赖单向:handler 只做协议转换,service 承担业务规则与事务,repository 只拼 SQL,model 不含业务逻辑。权限校验属于横切逻辑,全部放在 middleware 与 service 层,不散落在 handler 中。
4.2 配置加载
配置使用 YAML 作为默认值,环境变量用于覆盖敏感项。JWT 密钥、数据库密码一律不允许出现在仓库文件里,只通过环境变量或部署平台的密钥管理注入。
go
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Redis RedisConfig `yaml:"redis"`
JWT JWTConfig `yaml:"jwt"`
}
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := yaml.Unmarshal(b, &c); err != nil {
return nil, err
}
// 敏感项只接受环境变量覆盖
if v := os.Getenv("JWT_SECRET"); v != "" {
c.JWT.Secret = v
}
if v := os.Getenv("DB_PASSWORD"); v != "" {
c.Database.Password = v
}
if c.JWT.Secret == "" {
return nil, errors.New("JWT_SECRET 未配置")
}
return &c, nil
}
4.3 统一响应与错误码
所有接口返回 {code, msg, data},HTTP 状态码与业务码分离:HTTP 状态表达传输层结果,业务码表达语义。
go
type Body struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Body{Code: 0, Msg: "ok", Data: data})
}
func Fail(c *gin.Context, httpStatus int, code int, msg string) {
c.JSON(httpStatus, Body{Code: code, Msg: msg})
}
错误码分段约定:
| 区间 | 含义 | 示例 |
|---|---|---|
| 0 | 成功 | --- |
| 401xx | 认证失败 | 40101 Token 过期、40102 Token 无效 |
| 403xx | 授权失败 | 40301 无权限、40302 账号停用 |
| 400xx | 参数错误 | 40001 参数校验失败 |
| 500xx | 业务/系统错误 | 50001 数据库异常 |
前端只需要识别 code === 401xx 走登录跳转、code === 403xx 走无权限提示,其余统一弹错误消息。
五、登录认证:JWT 签发、校验、刷新与注销
5.1 密码存储
密码使用 bcrypt 单向哈希,禁止使用可逆加密或裸 MD5/SHA。
go
func HashPassword(raw string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost)
return string(b), err
}
func CheckPassword(hash, raw string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(raw)) == nil
}
登录失败时统一返回"用户名或密码错误" ,不要区分"用户不存在"和"密码错误",否则接口可用于枚举有效用户名。bcrypt 的 cost 因子决定了计算开销,bcrypt.DefaultCost 是库提供的默认值;在高并发登录场景下应通过压测调整,而不是凭感觉写死。
5.2 双 Token 设计
单令牌的问题是:过期时间短则频繁跳登录,过期时间长则泄露后风险窗口大。本文采用 Access Token + Refresh Token:
| 令牌 | 用途 | 载荷 | 存储 | 失效方式 |
|---|---|---|---|---|
| Access Token | 调用业务接口 | uid、username、typ=access、jti、exp | 前端内存或短期存储 | 短期过期 + 注销黑名单 |
| Refresh Token | 换发新令牌 | uid、typ=refresh、jti、exp | 前端受保护存储 | 轮换 + Redis 记录有效 jti |
具体过期时长属于业务决策,不由"业界标准"决定:内部管理后台可以相对宽松,面向公网的系统应更短并配合刷新。原则是Access Token 生命周期覆盖一次典型会话的操作时长,Refresh Token 覆盖可接受的重新登录间隔。
go
type Claims struct {
UserID int64 `json:"uid"`
Username string `json:"uname"`
TokenType string `json:"typ"` // access | refresh
jwt.RegisteredClaims
}
func (s *Service) Sign(userID int64, username, typ string, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Username: username,
TokenType: typ,
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
Issuer: "rbac-admin",
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString([]byte(s.secret))
}
func (s *Service) Parse(token string) (*Claims, error) {
t, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (interface{}, error) {
// 只接受 HMAC,防止 alg 混淆攻击
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(s.secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := t.Claims.(*Claims); ok && t.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
安全要点有三条:算法白名单 (不接受 alg=none 或 RS/HS 混淆);载荷不放敏感信息 (JWT 只做 Base64 编码,不是加密,别放密码、身份证号);jti 必须存在,注销与黑名单都依赖它。
5.3 认证中间件
go
func Auth(jwtSvc *auth.Service, rdb *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
token := extractBearer(c.GetHeader("Authorization"))
if token == "" {
response.Fail(c, http.StatusUnauthorized, 40100, "未登录")
c.Abort()
return
}
claims, err := jwtSvc.Parse(token)
if err != nil {
response.Fail(c, http.StatusUnauthorized, 40101, "登录状态无效")
c.Abort()
return
}
if claims.TokenType != "access" {
response.Fail(c, http.StatusUnauthorized, 40102, "令牌类型错误")
c.Abort()
return
}
// 注销黑名单:jti 命中即视为失效
if exists, _ := rdb.Exists(c.Request.Context(), "auth:bl:"+claims.ID).Result(); exists > 0 {
response.Fail(c, http.StatusUnauthorized, 40103, "登录已注销")
c.Abort()
return
}
c.Set("uid", claims.UserID)
c.Set("uname", claims.Username)
c.Next()
}
}
401 与 403 的区分:401 表示身份不可信(没带 Token、Token 过期、Token 无效),403 表示身份可信但权限不足。前端对 401 跳登录,对 403 提示无权限,两者的用户体验完全不同。
5.4 注销与刷新
注销的难点在于:JWT 无状态,Access Token 在过期前天然有效。解决方案是把注销时刻签发过的 Access Token 的 jti 写入 Redis 黑名单,TTL 设为该 Token 剩余有效期:
go
func (s *Service) Logout(ctx context.Context, claims *auth.Claims) error {
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
return nil
}
return s.rdb.Set(ctx, "auth:bl:"+claims.ID, "1", ttl).Err()
}
Refresh Token 采用轮换策略:每次刷新签发新的 Refresh Token,旧的 jti 立即作废,并在 Redis 中记录当前有效 jti。这样一旦旧 Refresh Token 被截获重放,可以被检测出来。这套方案的代价是引入了 Redis 状态,但换取了可控的注销能力;如果完全无状态,就只能接受"注销后到过期前仍有效"的窗口期。
六、RBAC 授权:从数据到接口拦截
6.1 权限查询与缓存
查询链路是:用户 → 角色集合 → 角色关联的菜单集合 → 去重后的权限标识集合。用一条联表 SQL 即可完成:
sql
SELECT DISTINCT m.permission
FROM sys_menu m
JOIN sys_role_menu rm ON rm.menu_id = m.id
JOIN sys_user_role ur ON ur.role_id = rm.role_id
WHERE ur.user_id = ? AND m.status = 1 AND m.permission <> '';
缓存键与失效策略需要全项目统一:
| Key 模式 | 内容 | TTL | 失效时机 |
|---|---|---|---|
rbac:perm:user:{uid} |
权限标识集合 | 10 分钟 | 用户角色变更、角色菜单变更、角色停用时主动删除 |
rbac:menus:user:{uid} |
菜单树 JSON | 10 分钟 | 同上 |
auth:bl:{jti} |
注销黑名单 | Token 剩余有效期 | 自然过期 |
auth:refresh:{jti} |
有效 Refresh Token | Token 有效期 | 刷新轮换时替换 |
主动删除 + 短 TTL 双保险:主动删除保证权限变更即时生效,短 TTL 兜底防止遗漏删除导致长期脏数据。权限缓存不能只靠 TTL,否则用户被降权后仍可能在 TTL 内继续操作。
6.2 接口鉴权中间件
路由注册时绑定权限标识,请求时比对:
go
func Authorize(perm string, loader *service.PermLoader) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.GetInt64("uid")
if uid == 0 {
response.Fail(c, http.StatusUnauthorized, 40100, "未登录")
c.Abort()
return
}
perms, err := loader.PermissionsOf(c.Request.Context(), uid)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50010, "权限加载失败")
c.Abort()
return
}
if perms.Has("sys:all") {
c.Next()
return
}
if perm != "" && !perms.Has(perm) {
response.Fail(c, http.StatusForbidden, 40301, "无操作权限")
c.Abort()
return
}
c.Next()
}
}
路由注册示例:
go
g := r.Group("/api/v1/system")
g.Use(middleware.Auth(jwtSvc, rdb))
{
g.GET("/users", middleware.Authorize("system:user:list", loader), userHandler.List)
g.POST("/users", middleware.Authorize("system:user:add", loader), userHandler.Create)
g.PUT("/users/:id", middleware.Authorize("system:user:edit", loader), userHandler.Update)
g.DELETE("/users/:id", middleware.Authorize("system:user:delete", loader), userHandler.Delete)
}
权限标识命名采用 模块:资源:动作 三段式,动作固定为 list/add/edit/delete/export 等有限集合,避免出现 system:user:doSomething 这类难以维护的标识。
6.3 超级管理员旁路
超管旁路有两种实现:一是给超管角色分配全部菜单,二是代码里对 role_key='admin' 直接放行。本文采用显式旁路 (perms.Has("sys:all"),由超管角色固定授予),好处是旁路逻辑可被审计、可被测试覆盖,坏处是必须严格限制 sys:all 的授予对象。无论哪种方式,都要满足三条要求:旁路行为写入审计日志;超管角色不可删除、不可改 key;超管账号启用多因素认证或强密码策略。
6.4 越权测试矩阵
权限系统最容易被忽略的测试就是越权用例。至少覆盖以下矩阵:
| 用户 | 角色 | 请求 | 期望 |
|---|---|---|---|
| admin | admin | GET /api/v1/system/users | 200 |
| alice | user | GET /api/v1/system/users | 200(若有 list 权限) |
| alice | user | POST /api/v1/system/users | 403 |
| alice | user | DELETE /api/v1/system/users/1(他人数据) | 403 或业务层拒绝 |
| 未登录 | --- | GET /api/v1/system/users | 401 |
| 已注销 Token | --- | GET /api/v1/system/users | 401 |
其中"删除他人数据"属于水平越权 ,必须在 service 层按数据归属校验;"普通用户调管理员接口"属于垂直越权,由鉴权中间件拦截。两者防线不同,缺一不可。
七、前端基础:Vue 3 + Pinia + 路由守卫
7.1 请求封装
ts
// src/api/request.ts
import axios from 'axios'
import { useUserStore } from '@/store/user'
import router from '@/router'
const request = axios.create({ baseURL: '/api/v1', timeout: 15000 })
request.interceptors.request.use((config) => {
const user = useUserStore()
if (user.accessToken) {
config.headers.Authorization = `Bearer ${user.accessToken}`
}
return config
})
request.interceptors.response.use(
(resp) => {
const { code, msg, data } = resp.data
if (code === 0) return data
if (String(code).startsWith('401')) {
useUserStore().reset()
router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
}
return Promise.reject(new Error(msg || '请求失败'))
},
(error) => Promise.reject(error),
)
export default request
Token 存放位置是安全与体验的取舍:放 localStorage 实现简单、刷新不丢,但易受 XSS 窃取;放内存最安全但刷新即失效。折中做法是 Access Token 存内存、Refresh Token 放 httpOnly Cookie 由后端写入,代价是需要处理跨站 Cookie 与 CSRF。本文示例为控制篇幅使用 localStorage,但生产环境应根据威胁模型重新评估,并务必配合严格的 CSP 与输出转义。
7.2 权限 Store
ts
// src/store/permission.ts
import { defineStore } from 'pinia'
export const usePermissionStore = defineStore('permission', {
state: () => ({
menus: [] as MenuNode[],
perms: [] as string[],
routesLoaded: false,
}),
actions: {
async load() {
const { menus, perms } = await fetchPermissions()
this.menus = menus
this.perms = perms
this.routesLoaded = true
},
reset() {
this.$reset()
},
},
})
7.3 按钮级权限
ts
// src/directives/permission.ts
import type { Directive } from 'vue'
import { usePermissionStore } from '@/store/permission'
export const vPermission: Directive<HTMLElement, string | string[]> = {
mounted(el, binding) {
const store = usePermissionStore()
const required = Array.isArray(binding.value) ? binding.value : [binding.value]
const ok = required.some((p) => store.perms.includes(p) || store.perms.includes('sys:all'))
if (!ok) el.parentNode?.removeChild(el)
},
}
使用方式:<el-button v-permission="'system:user:add'">新增</el-button>。必须强调:前端隐藏不等于后端放行 ,v-permission 只影响用户体验,真正的安全边界在后端鉴权中间件。
八、动态路由与菜单:后端菜单树到前端路由表
这是整套系统最容易出问题的一环,也是"会写 CRUD"和"会做权限系统"的分水岭。
8.1 前后端约定
后端菜单树节点返回结构:
json
{
"id": 1,
"parentId": 0,
"menuType": 2,
"title": "用户管理",
"name": "SystemUser",
"path": "user",
"component": "system/user/index",
"icon": "User",
"sortNo": 1,
"visible": 1,
"keepAlive": 1,
"permission": "",
"children": []
}
关键约定是 component 只传组件标识字符串 (如 system/user/index),由前端映射到实际组件文件,后端绝不传文件绝对路径或远程 URL。这样做有两个好处:后端不知道前端文件布局,前后端可独立部署;前端可以用构建期工具统一收集组件,避免动态 import() 被打包器忽略。
8.2 组件映射与路由构建
ts
// src/router/dynamic.ts
const modules = import.meta.glob('../views/**/*.vue')
function resolveComponent(key: string) {
const path = `../views/${key}.vue`
const loader = modules[path]
if (!loader) {
console.warn(`[router] 未找到组件: ${key}`)
return () => import('../views/error/404.vue')
}
return loader
}
export function buildRoutes(nodes: MenuNode[], parentPath = ''): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = []
for (const node of nodes) {
if (node.menuType === 3) continue // 按钮不生成路由
if (node.menuType === 1) { // 目录:只作为父级
routes.push(...buildRoutes(node.children || [], `${parentPath}/${node.path}`))
continue
}
routes.push({
path: `${parentPath}/${node.path}`,
name: node.name,
component: resolveComponent(node.component),
meta: {
title: node.title,
icon: node.icon,
hidden: node.visible === 0,
keepAlive: node.keepAlive === 1,
permission: node.permission,
},
})
}
return routes
}
8.3 注册时序与刷新重建
ts
// src/router/index.ts
const WHITE_LIST = ['/login', '/404']
router.beforeEach(async (to) => {
const user = useUserStore()
const perm = usePermissionStore()
if (!user.accessToken) {
return WHITE_LIST.includes(to.path) ? true : { path: '/login', query: { redirect: to.fullPath } }
}
if (to.path === '/login') return { path: '/' }
if (!perm.routesLoaded) {
await perm.load()
const routes = buildRoutes(perm.menus)
routes.forEach((r) => router.addRoute('Layout', r))
// 404 兜底必须最后注册
router.addRoute({ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/error/404.vue') })
return { ...to, replace: true } // 重新进入目标路由,确保新路由已生效
}
return true
})
时序必须是:进入守卫 → 发现路由未构建 → 拉取菜单 → addRoute → 重新导航 ({ ...to, replace: true })→ 放行。少了"重新导航"这一步,用户刷新页面会直接命中 404 或白屏,这是最高频的动态路由问题。
8.4 常见坑清单
| 现象 | 成因 | 处理 |
|---|---|---|
| 刷新页面白屏 | 路由未重建就被放行,或 component 映射失败 |
守卫中先重建再重进;打印 resolveComponent 未命中警告 |
addRoute 重复警告 |
登出后未清 routesLoaded 标志,重新登录重复注册 |
登出时 perm.reset(),并用 router.removeRoute 清理 |
| 404 先于业务路由命中 | 通配路由注册过早 | 把 404 放在所有动态路由注册之后 |
| 菜单变了页面没变 | 权限缓存未失效或前端未重新拉取 | 见第六章缓存失效策略;前端在角色变更后重拉菜单 |
| 外链菜单打不开 | 把外链当成内部路由 | path 以 http 开头时用 window.open 处理,不进 addRoute |
sys_menu 字段与前端 route meta 的映射关系在第二章已经定义,此处只做消费,不再重复建表。
九、代码生成器:从表结构到一套可运行的 CRUD
代码生成器的价值不在"少敲几行代码",而在于保证新模块的目录结构、错误码、权限标识、菜单注册与既有模块完全一致。它生成的是骨架,不是业务逻辑。
9.1 输入与输出
输入是一张 MySQL 表,通过 information_schema 读取列元数据:
sql
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE,
COLUMN_COMMENT, COLUMN_KEY, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION;
输出清单如下:
| 文件 | 作用 | 是否需人工修改 |
|---|---|---|
internal/model/{table}.go |
GORM 模型 | 通常不需要 |
internal/repository/{table}.go |
基础查询 | 复杂查询需补 |
internal/service/{table}.go |
CRUD 业务 | 需要,补业务规则 |
internal/handler/{table}.go |
HTTP 入口 | 复杂参数需补 |
internal/router/{table}.go |
路由与权限绑定 | 通常不需要 |
web/src/views/{module}/{table}/index.vue |
列表页 | 需要,调整列与表单 |
web/src/api/{module}/{table}.ts |
接口封装 | 通常不需要 |
scripts/{table}_menu.sql |
菜单与权限点种子 | 需人工审核后执行 |
9.2 模板实现
使用 Go 标准库 text/template,模板文件独立成目录,便于维护与 diff。
go
// 模板片段:model.tpl
type {{.StructName}} struct {
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
{{- range .Columns}}
{{.GoName}} {{.GoType}} `gorm:"column:{{.ColumnName}}" json:"{{.JSONName}}"`
{{- end}}
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func ({{.StructName}}) TableName() string { return "{{.TableName}}" }
go
// 模板片段:handler.tpl(节选)
// @Summary {{.Comment}}列表
// @Tags {{.ModuleName}}
// @Param page query int false "页码"
// @Param pageSize query int false "每页数量"
// @Success 200 {object} response.Body
// @Router /api/v1/{{.Module}}/{{.RouteName}} [get]
func (h *{{.StructName}}Handler) List(c *gin.Context) {
var req pageRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 40001, "参数错误")
return
}
list, total, err := h.svc.List(c.Request.Context(), req.Page, req.PageSize)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50001, "查询失败")
return
}
response.OK(c, gin.H{"list": list, "total": total})
}
生成器主流程:
go
func (g *Generator) Run(ctx context.Context, table string) error {
cols, err := g.repo.LoadColumns(ctx, table) // information_schema
if err != nil {
return err
}
meta := buildMeta(table, cols) // 类型映射、命名转换
for _, tpl := range g.templates {
buf := new(bytes.Buffer)
if err := tpl.Execute(buf, meta); err != nil {
return err
}
path := tpl.TargetPath(meta)
if _, err := os.Stat(path); err == nil && !g.overwrite {
return fmt.Errorf("目标文件已存在: %s,使用 -overwrite 显式覆盖", path)
}
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return err
}
}
return nil
}
覆盖策略必须保守 :默认拒绝覆盖已存在文件,-overwrite 显式开启;生成器每轮运行输出文件清单与内容哈希,方便 diff。一旦生成后的代码被人工修改过,再次生成会产生"模板漂移",此时应把人工修改回写到模板或改为手写,而不是反复覆盖。
9.3 与权限体系联动
生成器同时产出菜单种子 SQL,把新模块直接接入动态菜单:
sql
INSERT INTO sys_menu (parent_id, menu_type, title, name, path, component, permission, icon, sort_no) VALUES
(0, 1, '示例模块', 'Demo', 'demo', '', '', 'Menu', 90),
(LAST_INSERT_ID(), 2, '文章管理', 'DemoArticle', 'article', 'demo/article/index', '', 'List', 1),
(LAST_INSERT_ID(), 3, '新增', '', '', '', 'demo:article:add', '', 1),
(LAST_INSERT_ID(), 3, '编辑', '', '', '', 'demo:article:edit', '', 2),
(LAST_INSERT_ID(), 3, '删除', '', '', '', 'demo:article:delete', '', 3);
执行后给目标角色分配这些菜单,刷新前端即可看到新模块。这条链路正是前面各章约定的闭环:表结构 → 生成代码 → 权限标识 → 菜单 → 动态路由。
9.4 边界与风险
生成器不适合的场景:业务规则复杂、表之间有强一致性约束、需要大量自定义查询、表结构频繁变更。此时生成器产出的代码会被大量重写,收益归零。参考生态中 EasyGoAdmin 自述"一键 CRUD 生成整个模块的全部代码"4、gin-vue-admin 自述集成表单生成器与代码生成器2,但具体实现方式各不相同,本文只借鉴"元数据驱动 + 模板落盘"这一思路,不复制任何第三方实现。
十、质量保障:单测、接口测试、文档与 mock
10.1 测试重点:越权用例
权限系统的测试重点不是 CRUD 正确性,而是授权边界。service 层鉴权逻辑用表驱动单测覆盖:
go
func TestAuthorizeDelete(t *testing.T) {
cases := []struct {
name string
actor string
target string
wantErr bool
}{
{"管理员删除任意用户", "admin", "alice", false},
{"普通用户删除他人", "alice", "bob", true},
{"普通用户删除自己", "alice", "alice", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := svc.DeleteUser(ctx, tc.actor, tc.target)
if (err != nil) != tc.wantErr {
t.Fatalf("got err=%v, wantErr=%v", err, tc.wantErr)
}
})
}
}
测试清单:
| 模块 | 测试类型 | 是否覆盖越权 |
|---|---|---|
| 密码哈希与比对 | 单测 | 否 |
| JWT 签发/解析/过期 | 单测 | 是(伪造、过期、类型错误) |
| 权限集合计算 | 单测 | 是 |
| 鉴权中间件 | 接口测试 | 是(401/403 矩阵) |
| 动态菜单生成 | 单测 | 是(过滤无权节点) |
| CRUD 接口 | 接口测试 | 部分 |
10.2 文档与 mock
接口文档使用 swaggo 注解生成,注解写在 handler 上(见 9.2 模板片段),生成命令以项目 go.mod 中实际引入的 swag 版本为准。前端 mock 模式让页面可脱离后端独立运行,思路是按接口路径拦截请求返回固定数据;开源项目 gin-vue-blog 的 README 自述内置 mock 模式、可脱离后端运行,并提供 Docker Compose 与 CI7,可作为"前端独立可跑"的工程参考,但实现细节需读源码确认,本文不作推断。
十一、部署:Docker Compose 一键起环境
11.1 后端镜像
dockerfile
# server/Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server
FROM alpine:3.20
RUN adduser -D -u 10001 app
WORKDIR /app
COPY --from=builder /out/server /app/server
COPY config/config.yaml /app/config/config.yaml
USER app
EXPOSE 8080
ENTRYPOINT ["/app/server"]
注意:镜像中不包含 JWT 密钥与数据库密码,只通过环境变量注入;以非 root 用户运行;静态编译避免运行时依赖。
11.2 前端镜像与 Nginx
dockerfile
# web/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /src/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
nginx
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
try_files ... /index.html 是前端路由刷新不 404 的关键;/api 反代路径必须与前端 baseURL 保持一致,开发环境的 Vite 代理也要指向同一前缀,否则会出现"本地能跑、线上 404"。
11.3 Compose 编排
yaml
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: rbac_admin
volumes:
- mysql_data:/var/lib/mysql
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redis_data:/data
server:
build: ./server
environment:
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
REDIS_ADDR: redis:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on:
mysql:
condition: service_healthy
restart: unless-stopped
web:
build: ./web
ports:
- "80:80"
depends_on:
- server
volumes:
mysql_data:
redis_data:
新版 Compose 规范已弱化 version 字段,可不写;healthcheck 保证后端在数据库就绪后再启动,避免启动即崩溃的竞态。
11.4 上线检查清单
| 检查项 | 要求 |
|---|---|
| HTTPS | 生产强制 TLS,HTTP 仅做跳转 |
| JWT secret | 足够长度的随机值,通过密钥管理注入,不入仓库 |
| 初始账号 | 随机密码,首次登录强制修改 |
| 数据库 | 定期备份并演练恢复,最小权限账号 |
| 日志 | 结构化日志,集中采集,禁止打印 Token 与密码 |
| 健康检查 | /healthz 覆盖数据库与 Redis 连通性 |
| 迁移脚本 | 版本化、可回滚 |
| 审计日志 | 超管操作与权限变更必须留痕 |
十二、演进路线与故障排查
12.1 演进项
- Casbin 接入 :把
Authorize中的perms.Has(perm)替换为 Casbin 的Enforce(sub, obj, act),权限数据从sys_role_menu同步到casbin_rule,适合权限模型开始出现 ABAC、数据权限需求时引入; - 操作审计日志:记录谁在什么时间对什么资源做了什么,超管旁路必须全量记录;
- 行级数据权限:在 service 层增加数据范围过滤(本人/本部门/全部),这是菜单权限之外的独立维度;
- SSO/OAuth2:外部身份源接入,Token 体系需要重新设计;
- 多端扩展:同一套 API 可被 Web、桌面与移动端复用,Ruoyi-Go 提供原生 Android 版本6、Wails + Vue3 可构建 Go 跨平台桌面应用13,说明权限 API 的多端复用是可行方向。
12.2 故障排查表
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 登录后立即 401 循环 | Token 未正确携带、401 处理重复触发 | 看请求头是否有 Authorization;看拦截器是否在跳转后仍继续发请求 |
| 刷新页面白屏 | 动态路由未重建或组件映射未命中 | 看守卫日志;看 resolveComponent 警告 |
| 菜单不更新 | 权限缓存未失效、前端未重新拉取 | 检查 Redis key 是否删除;重新登录验证 |
| 修改角色权限不生效 | 只删了菜单缓存没删权限缓存 | 检查 rbac:perm:user:* 与 rbac:menus:user:* 是否同时清理 |
| 生成器覆盖了改过的代码 | 开启了 -overwrite |
用 Git diff 恢复;改为维护模板 |
| 本地正常、线上接口 404 | Nginx 反代路径与前端 baseURL 不一致 | 对比 /api 前缀与 proxy_pass 配置 |
十三、总结:关键决策回顾
| 决策 | 理由 | 备选方案 | 出处 |
|---|---|---|---|
| 自研 RBAC 而非 Casbin | 判断链路可读、可测、易审计 | Casbin 适合模型多变场景 | 第二章 |
| 双 Token + 刷新轮换 | 平衡体验与泄露风险 | 单 Token + 长有效期 | 第五章 |
| 注销黑名单 | 消除"注销后仍有效"窗口 | 接受无状态残留 | 第五章 |
sys_menu 承载路由元信息与权限标识 |
一套数据支撑菜单与接口双重校验 | 拆菜单表与权限点表 | 第三章 |
component 用字符串标识 + 前端映射 |
前后端解耦、构建期可收集组件 | 后端下发绝对路径 | 第八章 |
| 权限缓存主动删除 + 短 TTL | 变更即时生效且有兜底 | 仅 TTL 或仅主动删除 | 第六章 |
| 模板生成器默认拒绝覆盖 | 防止模板漂移吞掉人工修改 | 强制覆盖 | 第九章 |
推荐阅读顺序是:先读第二、三章把模型想清楚,再读第五章实现认证,接着第六章做授权,第七章打通动态路由,最后用第八章的生成器批量产出业务模块并用第十章部署上线。整条链路的每一个环节都以前一环的约定为基础,一旦某一层的权限语义模糊,后面所有代码都会变成补丁。
参考资料
- 基于 Gin + Vue + JWT 的前后端分离权限管理系统,CSDN,https://blog.csdn.net/KKK_868686/article/details/165617889
- 英雄哥/gin-vue-admin:基于 gin+vue 搭建的后台管理系统框架,集成 JWT 鉴权、权限管理、动态路由、Casbin 鉴权、代码生成器等,Gitee,https://gitee.com/YingXiongGe/gin-vue-admin
- flipped-aurora/gin-vue-admin:全栈前后端分离开发基础平台,集成 JWT 鉴权、动态路由、动态菜单、Casbin 鉴权、表单生成器、代码生成器,Gitee,https://gitee.com/RoseKissYou/github-gin-vue-admin
- EasyGoAdmin_Gin_EleVue:基于 Gin、Vue2、ElementUI 的前后端分离权限管理系统,含 RBAC 架构与一键 CRUD 代码生成器,Gitee,https://gitee.com/easygoadmin/EasyGoAdmin_Gin_EleVue
- todayliao/cutego:前端 Vue、Element UI,后端 Gin、Xorm、自定义 RBAC、Redis & JWT,自述未使用 Casbin,Gitee,https://gitee.com/todayliao/cutego
- Ruoyi-Go:基于 Go、Gin、JWT、Vue 前后端分离的权限管理系统,同时提供原生 Android 版本,Gitee,https://gitee.com/alimei/Ruoyi-Go
- szluyu99/gin-vue-blog:Go + Gin + GORM + Redis 后端,Vue3 + Vite 前台与后台管理,JWT 鉴权 + RBAC 动态菜单,Docker Compose 部署,内置 mock 模式与单元测试,GitHub,https://github.com/szluyu99/gin-vue-blog
- 快速启动 Go-Admin(Gin + Vue3 + Element UI)脚手架管理系统,CSDN,https://blog.csdn.net/cnzzs/article/details/144522075
- 基于 Gin + Vue 的全栈 Web 应用实战:实现登录与分类管理功能,CSDN,https://blog.csdn.net/weixin_36012152/article/details/155428454
- 基于 Gin+Gorm 的 bubble 清单项目前后端实战合集,CSDN,https://blog.csdn.net/weixin_30415591/article/details/154873620
- zindle:使用 go-zero 开发的完整全端系统,后台含权限角色管理、菜单管理等模块,GitHub,https://github.com/xiaopenggithub/zindle
- ve-blog-golang:基于 Go + Go-Zero + gRPC 的博客微服务后端,集成 RBAC 权限、WebSocket、OAuth2.0,Docker 到 Kubernetes 部署,GitHub,https://github.com/ve-weiyi/ve-blog-golang
- Wails + Vue3 构建现代桌面 UI 框架------基于 Go 的跨平台桌面应用实战,掘金,https://juejin.cn/post/7578889811333840937
- mldong-goframe:基于 GoFrame + Vben5 的全栈快速开发框架,掘金,https://juejin.cn/post/7527477655345954862