Go语言全栈实战:基于 Gin + Vue + JWT + RBAC 从零搭建前后端分离权限管理系统

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 基本模型是:用户分配角色,角色分配权限。在后台系统里"权限"通常又被拆成三层:

  1. 目录:侧边栏分组,不对应页面;
  2. 菜单:对应前端路由与页面;
  3. 按钮/权限点 :对应页面内的操作,用权限标识表达,例如 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 或仅主动删除 第六章
模板生成器默认拒绝覆盖 防止模板漂移吞掉人工修改 强制覆盖 第九章

推荐阅读顺序是:先读第二、三章把模型想清楚,再读第五章实现认证,接着第六章做授权,第七章打通动态路由,最后用第八章的生成器批量产出业务模块并用第十章部署上线。整条链路的每一个环节都以前一环的约定为基础,一旦某一层的权限语义模糊,后面所有代码都会变成补丁。

参考资料

  1. 基于 Gin + Vue + JWT 的前后端分离权限管理系统,CSDN,https://blog.csdn.net/KKK_868686/article/details/165617889
  2. 英雄哥/gin-vue-admin:基于 gin+vue 搭建的后台管理系统框架,集成 JWT 鉴权、权限管理、动态路由、Casbin 鉴权、代码生成器等,Gitee,https://gitee.com/YingXiongGe/gin-vue-admin
  3. flipped-aurora/gin-vue-admin:全栈前后端分离开发基础平台,集成 JWT 鉴权、动态路由、动态菜单、Casbin 鉴权、表单生成器、代码生成器,Gitee,https://gitee.com/RoseKissYou/github-gin-vue-admin
  4. EasyGoAdmin_Gin_EleVue:基于 Gin、Vue2、ElementUI 的前后端分离权限管理系统,含 RBAC 架构与一键 CRUD 代码生成器,Gitee,https://gitee.com/easygoadmin/EasyGoAdmin_Gin_EleVue
  5. todayliao/cutego:前端 Vue、Element UI,后端 Gin、Xorm、自定义 RBAC、Redis & JWT,自述未使用 Casbin,Gitee,https://gitee.com/todayliao/cutego
  6. Ruoyi-Go:基于 Go、Gin、JWT、Vue 前后端分离的权限管理系统,同时提供原生 Android 版本,Gitee,https://gitee.com/alimei/Ruoyi-Go
  7. szluyu99/gin-vue-blog:Go + Gin + GORM + Redis 后端,Vue3 + Vite 前台与后台管理,JWT 鉴权 + RBAC 动态菜单,Docker Compose 部署,内置 mock 模式与单元测试,GitHub,https://github.com/szluyu99/gin-vue-blog
  8. 快速启动 Go-Admin(Gin + Vue3 + Element UI)脚手架管理系统,CSDN,https://blog.csdn.net/cnzzs/article/details/144522075
  9. 基于 Gin + Vue 的全栈 Web 应用实战:实现登录与分类管理功能,CSDN,https://blog.csdn.net/weixin_36012152/article/details/155428454
  10. 基于 Gin+Gorm 的 bubble 清单项目前后端实战合集,CSDN,https://blog.csdn.net/weixin_30415591/article/details/154873620
  11. zindle:使用 go-zero 开发的完整全端系统,后台含权限角色管理、菜单管理等模块,GitHub,https://github.com/xiaopenggithub/zindle
  12. ve-blog-golang:基于 Go + Go-Zero + gRPC 的博客微服务后端,集成 RBAC 权限、WebSocket、OAuth2.0,Docker 到 Kubernetes 部署,GitHub,https://github.com/ve-weiyi/ve-blog-golang
  13. Wails + Vue3 构建现代桌面 UI 框架------基于 Go 的跨平台桌面应用实战,掘金,https://juejin.cn/post/7578889811333840937
  14. mldong-goframe:基于 GoFrame + Vben5 的全栈快速开发框架,掘金,https://juejin.cn/post/7527477655345954862
相关推荐
ZealSinger1 小时前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go
丹宇码农2 小时前
Go 与 Python 协程(Coroutine)对比演示项目
开发语言·python·golang
ttwuai2 小时前
Go开源后台管理系统推荐:3个官方仓库怎么按技术栈和适用边界比较?
开发语言·golang·开源
陈拉斯3 小时前
Vue SPA 站百度收录“全军覆没“?一次预渲染抢救的完整实战(2026 踩坑版)
vue.js
福兮说3 小时前
Go encoding/json 的八个默认行为,以及 Go 1.25 的 json/v2 改了哪几个(附实测输出)
go
合橱瑰3 小时前
踩坑实录:包是好的,代码却报错?一次“数字ID”引发的插件启动血案
架构·go
茉莉玫瑰花茶4 小时前
GO [ 并发 · 调度器 ]
开发语言·后端·golang
恋喵大鲤鱼4 小时前
go list command
golang·list
NetX行者5 小时前
五大主流语言:Go、Python、Rust、Java、C# 的比较
go