芋道实战|34k开源项目如何新建模块?

前言

我将持续更新 yudao-vue-pro 芋道源码笔记,记录"全局架构 → 模块拆解 → 源码细节 → 最佳实践"的完整过程,每一步保留真实思考与踩坑记录,欢迎一起打卡。

为什么选它

  1. 34.1k Star,社区体量足够,遇到问题能搜到现成讨论。** yudao-vue-pro 是 在 RuoYi-Vue 若依 基础上"二次开发"的社区版增强项目**
  2. 官方把 RBAC、多租户、工作流、支付、短信、商城等模块做成可插拔 starter,不必自己补业务场景,直接调试即可。
  3. 同一套后端接口同时供给 Vue3 管理端 + UniApp 小程序,前端部分可一次性验证 PC 与移动端,减少重复对接。

技术栈:Spring Boot、MyBatis Plus、Vue3、Element Plus、UniApp。

目标:借完整项目把后端零散知识串成体系,并夯实前端工程化能力。

本文从新增一个用户组功能开始作为入口来梳理下项目的结构和涉及到的前后端的功能模块。

新增用户组模块

下面简单列一下新增模块的过程,具体的可查阅网上相关资料。本文重点是来聊聊技术实现相关的内容。

第一步:数据库表设计

在数据库中设计 system_group 表,作为用户组模块的基础数据载体。

第二步:代码生成

进入管理后天台,先导入我们的设计表 system_group,然后在 "代码生成" 页面找到已设计好的 system_group 表,一鍵生成前后端代码和对应的 sql 文件。

第三步:代码复制与项目运行

将生成的代码分别复制到项目对应的目录中。

這是生成好的代码。将它们复制到对应的项目中进行运行。

运行成功后效果如下:

后端 yudao-module-system 目录结构

我们先从后端的项目代码来看起,首先来看看项目结构。这边仅列举了 yudao.module.system 包下的内容。这是本次新增用户组功能涉及到的模块。

csharp 复制代码
cn.iocoder.yudao.module.system
├── api // 提供给其它模块的 API 接口(供其他模块/服务调用)
│   ├── dept        # 部门模块的服务间接口目录
│   │   ├── dto     # 服务间数据传输对象(DTO)目录
│   │   │   ├── DeptRespDTO.java  # 部门服务间响应 DTO,定义部门模块对外暴露的核心业务字段
│   │   │   └── PostRespDTO.java  # 岗位服务间响应 DTO
│   │   ├── DeptApi.java  # 部门服务间接口,定义部门模块可被外部调用的方法(如查询部门列表)
│   │   └── DeptApiImpl.java  # 部门服务间接口实现类,封装部门服务的调用逻辑(内部依赖 DeptService)
│   ├── ... // 其他业务模块的 api 目录
├── controller
│   ├── admin // 管理后台
│   │   ├── captcha
│   │   ├── dept
│   │   ├── dict
│   │   ├── group      # 你新增的用户组模块控制器目录
│   │   │   ├── vo     # 数据传输对象(DTO/VO)目录
│   │   │   │   ├── GroupPageReqVO.java  # 分页查询用户组的请求参数
│   │   │   │   ├── GroupRespVO.java     # 用户组详情的响应参数
│   │   │   │   ├── GroupSaveReqVO.java  # 新增/修改用户组的请求参数
│   │   │   └── GroupController.java     # 用户组模块的控制器(接口入口)
│   │   ├── ip
│   │   ├── logger
│   │   │   ├── dto     # 日志模块的请求/响应 DTO 目录(供 Controller 与 Service 交互)
│   │   │   │   ├── OperateLogPageReqDTO.java  # 操作日志分页查询请求 DTO
│   │   │   │   └── OperateLogRespDTO.java     # 操作日志响应 DTO
│   │   ├── mail
│   │   ├── notice
│   │   ├── notify
│   │   ├── oauth2
│   │   ├── permission
│   │   ├── sms
│   │   ├── social
│   │   ├── tenant
│   │   └── user
│   └── app // C端客户
│       ├── dict
│       ├── ip
│       └── tenant
├── dal // 数据访问层 Data Access Layer
│   ├── dataobject // 数据库实体对象(DO)目录,等同于 nest 中的 entity 
│   │   ├── dept
│   │   ├── dict
│   │   ├── group      # 用户组模块的数据库实体目录
│   │   │   └── GroupDO.java  # 系统用户组 DO,映射数据库表 `system_group`,封装用户组的基础字段(如ID、名称、状态等)
│   │   ├── logger
│   │   ├── mail
│   │   ├── notice
│   │   ├── notify
│   │   ├── oauth2
│   │   └── ... // 其他业务模块的 DO 目录
│   ├── convert // 数据对象转换工具(可选,用于 DO、VO、DTO 之间的转换)
│   ├── mapper // MyBatis 映射接口目录
│   ├── mysql // 数据库方言/特定实现目录(针对 MySQL 数据库的扩展)
│   │   ├── dict
│   │   ├── group      # 用户组模块的 MySQL 专属映射目录
│   │   │   └── GroupMapper.java  # 系统用户组 Mapper 接口,基于 MyBatis-Plus 实现数据库表 `system_group` 的 CRUD 操作,包含自定义分页查询等逻辑
│   │   ├── logger
│   │   ├── mail
│   │   ├── notice
│   │   ├── notify
│   │   ├── oauth2
│   │   ├── user
│   │   └── ... // 其他业务模块的 MySQL 专属 Mapper 目录
│   └── ... // 其他数据访问层组件(如 Repository)
├── service // 业务逻辑层
│   ├── group      # 用户组模块的业务服务目录
│   │   ├── GroupService.java  # 用户组业务接口,定义用户组的核心操作(新增、删除、查询、绑定用户等)
│   │   └── impl
│   │       └── GroupServiceImpl.java  # 用户组业务接口实现类,封装具体业务逻辑
│   └── ... // 其他业务模块的服务目录
├── enums // 枚举类目录
│   ├── group      # 用户组模块的枚举目录(可选,如用户组状态枚举 `GroupStatusEnum`)
│   └── ... // 其他业务模块的枚举
  • api 是跨模块 / 服务的 "对外接口" ,供其他系统调用;
  • service 是模块内的 "业务逻辑中心" ,供本模块的 Controller 调用;
  • controller 是前端的 "入口" ,接收前端请求并调用 Service 处理。

Debug 执行流程

在新增完代码以后我们可以通过 Debug 模式启动 Java 项目,设置断点进行接口调试。我们从一次前端请求作为入口来说明整个调试的流程。

服务端可以简单分为三层

  1. Controller 层定义了暴露给前端的接口。
  2. Service 层定义了业务逻辑相关的代码。
  3. Mapper 层定义了数据库相关的操作,由于使用了 mybatisplus 一些基础的增删改成都是集成自 BaseMapper 无需自己定义,简化了很多代码。
  1. 前端请求 发送 POST /system/group/create 请求,携带 GroupSaveReqVO 格式的 JSON 参数
  2. Controller 层
java 复制代码
@PostMapping("/create") // Spring MVC 匹配POST请求路由
@Operation(summary = "创建系统用户组")
@PreAuthorize("@ss.hasPermission('system:group:create')") // Spring Security 权限校验
public CommonResult<Long> createGroup(
    @Valid @RequestBody GroupSaveReqVO createReqVO // @Valid参数校验,@RequestBody解析请求体为VO
) {
    // 调用Service层处理业务,返回结果包装为统一响应格式
    return success(groupService.createGroup(createReqVO)); 
}
  1. Service 层
java 复制代码
@Override
public Long createGroup(GroupSaveReqVO createReqVO) {
    // 将前端传递的VO转换为数据库实体DO(匹配表结构)
    GroupDO group = BeanUtils.toBean(createReqVO, GroupDO.class);
    // 调用Mapper层插入数据到数据库
    groupMapper.insert(group);
    // 返回插入后的自增ID
    return group.getId();
}
  1. Mapper 层
java 复制代码
// 继承MyBatis-Plus的BaseMapper,获得通用CRUD方法
public interface BaseMapper <T> extends com.baomidou.mybatisplus.core.mapper.Mapper<T> {
    int insert(T entity); // MyBatis-Plus自动生成INSERT SQL,执行数据库插入
}

概念 VO、DTO、DO

在代码中经常涉及到这三者的定义和使用,有些项目使用不规范的话可能并不区分的这么细,但是我对于三者的关系和实际的使用场景简单罗列如下:

维度 ReqVO/RespVO(视图对象) ReqDTO/RespDTO(数据传输对象)
核心定位 前端与控制层(Controller)的交互载体 服务层(或模块间)的数据传输契约
使用场景 前端提交请求参数、接收前端展示数据 服务间调用、模块内数据传递
字段设计 贴合前端页面需求(如表单字段、格式化数据) 贴合业务逻辑(如核心业务字段、校验规则)
变更驱动 前端页面需求变更(如新增输入框、展示列) 服务接口契约变更(如新增业务字段、逻辑)
数据来源 从 ReqDTO 转换而来(或直接接收前端参数) 从 DO(数据库实体)转换而来
典型示例 DeptPageReqVO(含分页 + 前端查询条件)DeptRespVO(含负责人姓名、格式化时间) DeptSaveReqDTO(含部门名称、父部门 ID)DeptRespDTO(含部门 ID、名称、状态)

VO(View Object) :聚焦前端视图交互,是控制层与前端的专属载体,字段设计贴合页面需求。

DTO(Data Transfer Object) :聚焦服务间数据传输,是服务层的标准化契约,字段设计贴合业务逻辑

DO 是数据库表的代码镜像,是持久层与业务层的内部数据载体,仅负责数据存储映射,无业务逻辑。

维度 DO(数据对象)
核心定位 数据库表在代码中的实体映射
使用场景 数据访问层与业务层的内部数据载体
字段设计 与数据库表字段一一对应(含所有底层字段)
变更驱动 数据库表结构变更(如新增字段、修改类型)
数据来源 直接映射数据库表,由持久层操作生成
典型示例 GroupDO(映射system_group表,含 id、name、status 等字段)

前端 yudao-ui-admin-vue3

项目结构

  • 业务模块 :在 api 和 views 层均按 "系统、bpm、crm" 等业务域拆分子模块(如 system/group 同时管理接口与页面),让每个业务功能的代码链路高度集中,便于独立维护与迭代。
  • 权限管控 :通过 store/permission 和 store/user 深度联动,不仅支持动态路由的后端驱动生成,还能实现按钮级的权限校验,结合自定义指令完成细粒度操作控制,满足企业级后台的安全要求。
  • 工程化分层 :以 hooks 封装可复用业务逻辑、directives 封装通用交互行为,store 层通过 modules 区分业务与全局状态,既保证架构的扩展性,又让复杂后台的状态管理更有条理,特别适配多业务域的中大型后台系统开发。
csharp 复制代码
yudao-ui-admin-vue3
├── src
│   ├── api                  # 接口封装层,按业务模块拆分,统一管理前后端接口交互
│   │   ├── system           # 系统管理模块接口
│   │   │   ├── group        # 用户组管理接口模块,封装用户组增删改查等接口
│   │   │   │   └── index.ts # 用户组管理接口实现
│   ├── assets               # 静态资源,存储图标、图片、全局样式等
│   ├── components           # 全局通用组件,可在各业务模块复用
│   ├── config/axios         # 请求封装与拦截器,配置请求全局规则、响应拦截逻辑
│   ├── directives           # 自定义指令,封装权限控制、拖拽、防抖等通用指令
│   ├── hooks                # 组合式 Hook,封装 web 交互、事件处理、业务逻辑复用等逻辑
│   ├── layout               # 框架布局,管理系统整体布局结构(侧边栏、顶部导航、标签页等)
│   ├── router               # 路由中心,管理静态路由和动态路由的注册、守卫逻辑
│   ├── store                # Pinia 模块,管理应用状态(核心模块如下)
│   │   ├── modules          # 业务模块状态(如 bpm、mall 等)
│   │   ├── permission.ts    # 权限状态(路由权限过滤、按钮权限校验,控制用户可访问页面和操作)
│   │   ├── user.ts          # 用户状态(用户信息、token、登录态管理,对接权限校验逻辑)
│   │   └── index.ts         # Pinia 模块注册入口
│   ├── styles               # 全局样式与 SCSS 变量,定义系统样式规范
│   ├── utils                # 工具函数,封装权限、字典、树结构、路由辅助等通用工具
│   └── views                # 业务页面,核心业务模块集合
│       ├── system           # 系统管理模块,包含各类系统配置与管理页面
│       │   ├── group        # 用户组管理业务页面模块
│       │   │   ├── index.vue # 用户组列表页,展示用户组数据、提供查询、操作入口
│       │   │   └── GroupForm.vue # 用户组表单页,实现用户组的新增、编辑功能
└── public                   # 公共静态资源,存储无需编译的静态文件

路由守卫核心流程解析

  1. 身份验证:校验访问令牌存在性,已登录且目标为登录页时重定向至根路径。
  2. 依赖初始化:按需拉取全局枚举字典、用户基础信息及权限菜单树,完成 Pinia 状态持久化与缓存写入。
  3. 动态路由注入:依据后端菜单数据递归生成符合 Vue Router 规范的 RouteRecordRaw,通过 router.addRoute 在运行时注入路由表,并追加通配符 404 兜底。
  4. 导航重置:利用 next({ path, query, replace: true }) 触发新一轮路由匹配,解决刷新后因 matcher 快照未更新导致的 404 问题,同时保留查询参数。
js 复制代码
router.beforeEach(async (to, from, next) => {
  start()                 // 顶部进度条开始转
  loadStart()             // 页面 loading 蒙层

  if (getAccessToken()) { /* ===== ① 有 token ===== */
    if (to.path === '/login') {
      next({ path: '/' }) // 已登录再进登录页 → 直接踢走
      return
    }

    // 字典对应枚举
    const dictStore = useDictStoreWithOut()
    // 用户信息
    const userStore = useUserStoreWithOut()
    // 权限信息
    const permissionStore = usePermissionStoreWithOut()

    /* ③ 全局字典(性别、状态等下拉选项)只拉一次 */
    if (!dictStore.getIsSetDict) await dictStore.setDictMap()

    /* ④ 只要刷新页面 isSetUser 就是 false → 去拿用户数据 */
    if (!userStore.getIsSetUser) { // 是否设置过用户信息
      isRelogin.show = true                       // 显示"正在加载用户信息"遮罩
      await userStore.setUserInfoAction()         // 拿:用户详情 + 权限 + 菜单树
      isRelogin.show = false

      /* ⑤ 根据后端返回的菜单树 → 现拼前端路由 */
      await permissionStore.generateRoutes()      // 返回 addRouters[]
      permissionStore.getAddRouters.forEach(r =>  // 逐条塞进 vue-router
        router.addRoute(r as RouteRecordRaw))

      /* ⑥ 正确加载动态路由,必须使用 next(pathParams) 才能正确渲染新添加的路由  */
      const redirect = decodeURIComponent((from.query.redirect as string) || to.fullPath)
      const { paramsObject: query } = parseURL(redirect)
      next(to.path === redirect ? { ...to, replace: true } : { path: redirect, query })
    } else {
      next() // 用户、路由、字典都已就绪 → 直接放行
    }
  } else { /* ===== 无 token ===== */
    whiteList.includes(to.path)
      ? next()                           // 白名单页面直接过
      : next(`/login?redirect=${to.fullPath}`) // 其余统一跳登录
  }
})

"有 token → 拉用户 → 拼路由 → addRoute → 重跳带参 → 页面正常"

setUserInfoAction

setUserInfoAction 完成用户身份、权限、角色及菜单树的一次性拉取与持久化缓存 ,为后续路由生成、按钮级权限控制及全局用户信息渲染提供可复用的单一数据源。

ts 复制代码
export const useUserStore = defineStore('admin-user', {
  actions: {
    async setUserInfoAction() {
      /* 1. 如果连 token 都没有 → 直接 reset,后面代码不再执行 */
      if (!getAccessToken()) {
        this.resetState()
        return
      }

      /* 2. 先读本地缓存,保证刷新页面瞬间就能渲染,不会白屏 */
      let userInfo = wsCache.get(CACHE_KEY.USER)

      /* 3. 无论缓存是否存在,都去后端拉一次最新数据(失败也不踢人) */
      if (!userInfo) {
        userInfo = await getInfo()          // 第一次:必须等接口
      } else {
        try { userInfo = await getInfo() }  // 非第一次:后台悄悄更新
        catch {}
      }

      /* ===== 下面 4 行分别对应"4 件事" ===== */

      /* ① 拿权限标识集合 → 按钮级 v-hasPerm 会用它 */
      this.permissions = new Set(userInfo.permissions || [])

      /* ② 拿角色数组 → 按钮级 v-hasRole 会用它 */
      this.roles = userInfo.roles

      /* ③ 拿用户基本信息 → 顶部栏头像、个人中心等页面显示 */
      this.user = userInfo.user

      /* ④ 标记"我已经知道你是谁了",路由守卫下次不再重复跑 */
      this.isSetUser = true

      /* ⑤ 把最新数据写缓存,下次刷新直接读,省一次接口 */
      wsCache.set(CACHE_KEY.USER, userInfo)

      /* ⑥ 把菜单树单独再写一份缓存 → permissionStore 会用它来拼装路由 */
      wsCache.set(CACHE_KEY.ROLE_ROUTERS, userInfo.menus)
    }
  }
})

generateRoutes

基于后端菜单树一次性生成可访问动态路由及侧边栏菜单数据。

ts 复制代码
export const usePermissionStore = defineStore('permission', {
  actions: {
    /**
     * usePermissionStore.generateRoutes()
     * 把【后端菜单】→【前端可访问路由】→【侧边栏菜单】
     * 一次性全算完,只在前端刷新后跑一遍。
     */
    async generateRoutes(): Promise<void> {
      return new Promise<void>(async (resolve) => {
        /* 1. 拿后端给的菜单树(登录时已缓存) */
        let res: AppCustomRouteRecordRaw[] = [];
        const roleRouters = wsCache.get(CACHE_KEY.ROLE_ROUTERS);
        if (roleRouters) res = roleRouters as AppCustomRouteRecordRaw[];
    
        /* 2. 递归把后端 JSON 转成 vue-router 需要的 RouteRecordRaw */
        const routerMap: AppRouteRecordRaw[] = generateRoute(res);
    
        /* 3. 追加"通配符 404",保证任意非法路径都能落到 404 组件 */
        this.addRouters = routerMap.concat([
          {
            path: '/:path(.*)*',
            component: () => import('@/views/Error/404.vue'),
            name: '404Page',
            meta: { hidden: true, breadcrumb: false }
          }
        ]);
    
        /* 4. 拼出"完整菜单":静态路由(login、404、dashboard)+ 动态路由
              用于侧边栏渲染、标签页、面包屑 */
        this.routers = cloneDeep(remainingRouter).concat(routerMap);
    
        /* 5. 通知外部"路已铺完",可以 forEach(router.addRoute) 了 */
        resolve();
      });
    }
  }
}

拿到后端菜单相关数据,根据数据生成一份前端路由相关的配置。

动态加载路由

  1. 将 permissionStore.getAddRouters 中的动态路由动态添加的路由注册表
  2. 通过 next(routeParams) 重新渲染路由视图,否则动态路由不会正确被渲染
js 复制代码
/* ④ 只要刷新页面 isSetUser 就是 false → 去拿用户数据 */
    if (!userStore.getIsSetUser) { // 是否设置过用户信息
      isRelogin.show = true                       // 显示"正在加载用户信息"遮罩
      await userStore.setUserInfoAction()         // 拿:用户详情 + 权限 + 菜单树
      isRelogin.show = false

      /* ⑤ 根据后端返回的菜单树 → 现拼前端路由 */
      await permissionStore.generateRoutes()      // 返回 addRouters[]
      permissionStore.getAddRouters.forEach(r =>  // 逐条塞进 vue-router
        router.addRoute(r as RouteRecordRaw)
      )

      /* ⑥ 正确加载动态路由,必须使用 next(pathParams) 才能正确渲染新添加的路由  */
      const redirect = decodeURIComponent((from.query.redirect as string) || to.fullPath)
      const { paramsObject: query } = parseURL(redirect)
      next(to.path === redirect ? { ...to, replace: true } : { path: redirect, query })
    } else {
      next() // 用户、路由、字典都已就绪 → 直接放行
    }

next() 使用分析

如果动态路由,直接使用 next() 未重新指定路由地址和参数。则无法匹配新添加的路由导致 404 。

使用 next({...to, replace: true }) 指定跳转动态路由的地址和参数。

结尾

就从"新增一个用户组"这道小入口出发,我把它当成芋道源码学习的第一课:后端以 Spring Boot 的调用链、权限模型;前端以 Vue3 的动态路由、表单校验与权限回显。如果你也在用芋道搭建自己的技术体系,欢迎持续关注,我们一起把这条主线走深、走实。

相关推荐
不是株4 天前
零到全栈(网页从哪来,服务器怎么架)
全栈
Ticnix4 天前
42 天 71 次提交之后,我重新看了一遍自己的架构决策
python·agent·全栈
LEE5 天前
前端转型全栈 03:接口失败也返回 200,OpenAPI 契约与错误码怎么定
前端·后端·全栈
SL_staff5 天前
MQTT Topic权限越界:JVS-IOT中系统Topic与自定义Topic的权责边界与验证实践
java·物联网·全栈
SL_staff6 天前
财务系统慎用低代码?从数据模型闭环看合规落地的技术实践
java·低代码·全栈
老王以为6 天前
走进 AI Agent 第四篇(上):知识获取管道——RAG 基础
前端·人工智能·全栈
小星星_20269 天前
16 万行代码是怎么“跑”出来的?
人工智能·全栈
SL_staff10 天前
JVS-Logic:从页面配置工具到企业级业务逻辑中枢的技术演进
java·算法·全栈
万敏10 天前
Vue3 全栈实战第九周:Node.js + Express 后端从零搭建实战记录
vue.js·node.js·全栈
MingQi3910 天前
DevFlow Harness 全栈实践 · M1.5:用户体系、多对话与聊天分享
全栈