大数跨境

7天学会SpringBoot+Vue3企业级项目RuoyiOffice(五):权限篇——把登录、角色、数据权限与租户隔离讲透

7天学会SpringBoot+Vue3企业级项目RuoyiOffice(五):权限篇——把登录、角色、数据权限与租户隔离讲透 企业软件源码
2026-10-05
13
导读:沿着一条请求的真实链路,讲清 RuoYi Office 的登录令牌、功能权限、数据权限与租户隔离分别在哪一层生效,为什么权限不能只靠前端,以及超级管理员与跨租户访问两个边界。

7天学会SpringBoot+Vue3企业级项目RuoyiOffice(五):权限篇——把登录、角色、数据权限与租户隔离讲透

🌐 文档地址:https://ruoyioffice.com
 👇 文章底部获取源码和演示地址 👇
 💬 :17156169080(获取产品咨询)

这是《7天学会SpringBoot+Vue3企业级项目RuoyiOffice》系列第 5 篇。前四天页面和接口都做出来了,第五天回答一个绕不开的问题:这些功能到底是谁、在什么范围内可以用。权限不是登录页加几个按钮开关,而是一条贯穿请求头、过滤器、注解和 SQL 的完整链路。读完你能说清三件事:一次请求会穿过哪几道门、每道门拦的是什么、哪些事情前端绝对替代不了后端。

▲ 本篇核心视觉:左边是同心的五层,越往里越靠近数据;右边写明每一层对应的源码类和失败时的表现,下方两个方框是前端边界与两个特殊边界。

引言:先把四个词分清

权限相关的词很容易混用,先用一张表把边界划清:

概念
回答的问题
典型载体
失败时的表现
认证
你是谁
访问令牌、LoginUser
按未登录处理
功能权限
你能执行哪个操作
菜单与按钮的权限标识,@PreAuthorize
403 无权限
数据权限
你能看到哪些行
角色的数据范围,部门与本人条件
不报错,只是查不到
租户隔离
你属于哪家企业
tenant-id
 请求头,tenant_id 列
403 或查不到

同一张用车申请单列表,认证决定能不能打开页面,功能权限决定有没有新增按钮,数据权限决定列表里出现哪些单据,租户隔离决定看到的是不是自己企业的数据。四层各管一件事,缺哪一层都会出问题。

说明:本文代码节选自 ruoyi-office/yudao-framework 与 yudao-module-system,为便于阅读省略了部分导入与空行。配置只引用结构,不涉及任何密钥。

一、认证:令牌怎么变成登录用户

登录页除了账号密码,还有一个租户选择框。这不是装饰:系统在登录前就要知道你属于哪个租户,后面的所有请求头里都会带上它。

▲ 登录页:最上面的下拉框是租户,其后是账号密码;手机号、扫码和第三方登录是同一个认证体系下的不同入口。

登录相关接口(如 /login、/refresh-token、/sms-login)都标注了 @PermitAll,其余接口在安全配置里统一要求认证:

配置项
实际写法
含义
会话策略
SessionCreationPolicy.STATELESS
服务端不保存会话,每次请求靠令牌识别身份
CSRF
关闭
令牌放在请求头里,不依赖浏览器自动携带 Cookie
放行规则
带 @PermitAll 的接口与配置的放行路径
登录、刷新令牌等无需已登录
兜底规则
anyRequest().authenticated()
其余全部要求已认证
令牌过滤器
加在 UsernamePasswordAuthenticationFilter 之前
先识别身份,再进入后续授权判断

令牌过滤器做的事情,是把请求变成一个 LoginUser:

@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)        throws ServletException, IOException {    // 情况一,基于 header[login-user] 获得用户,例如说来自 Gateway 或者其它服务透传    LoginUser loginUser = buildLoginUserByHeader(request);    // 情况二,基于 Token 获得用户    if (loginUser == null) {        String token = SecurityFrameworkUtils.obtainAuthorization(request,                securityProperties.getTokenHeader(), securityProperties.getTokenParameter());        if (StrUtil.isNotEmpty(token)) {            Integer userType = WebFrameworkUtils.getLoginUserType(request);            try {                // 1.1 基于 token 构建登录用户                loginUser = buildLoginUserByToken(token, userType);                // 1.2 模拟 Login 功能,方便日常开发调试                if (loginUser == null) {                    loginUser = mockLoginUser(request, token, userType);                }            } catch (Throwable ex) {                // ... 写出错误响应并 return,省略            }        }    }    if (loginUser != null) {        SecurityFrameworkUtils.setLoginUser(loginUser, request);    }    chain.doFilter(request, response);}

这段代码里有三个值得停下来想的点:

  1. 令牌无效不等于直接报错。buildLoginUserByToken
     校验不通过时返回 null,请求继续往下走,由后面的授权规则决定该接口是否需要登录。这样登录接口自己就能被正常访问。
  2. 用户类型要与路径匹配。
     管理端路径与 App 端路径对应不同的用户类型,不匹配会抛出“错误的用户类型”,避免拿 App 的令牌调管理端接口。
  3. 模拟登录只用于开发。
     代码注释明确写着线上必须关闭。仓库里 application-dev.yaml 与 application-demo.yaml 的 mock-enable 为 true,application-prod.yaml 为 false,上线前要核对生产配置。

还有一个必须说明的细节:过滤器的“情况一”会直接信任请求头 login-user。这个头是微服务模式下网关校验令牌后写入、再透传给下游服务的。它只应由受信的网关写入,对外入口不应让客户端自己带这个头进来。本文检索了仓库中的 Nginx 示例配置,没有发现处理该头的写法,所以部署时需要自行确认入口层的行为,第七篇的上线清单会再次提到。

二、身份:登录之后前端拿到什么

登录成功后,前端会调用 /get-permission-info,一次拿到用户、角色、菜单和权限标识。后端在组装菜单时有一个关于超级管理员的分支:

List<RoleDO> roles = roleService.getRoleList(roleIds);roles.removeIf(role -> !CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus())); // 移除禁用的角色// 1.3 获得菜单列表// 超管:直接取全量菜单(带 Redis 缓存),避免先查全量 ID 再 IN 二次查询List<MenuDO> menuList;if (roleService.hasAnySuperAdmin(convertSet(roles, RoleDO::getId))) {    menuList = menuService.getMenuList();} else {    Set<Long> menuIds = permissionService.getRoleMenuListByRoleId(convertSet(roles, RoleDO::getId));    menuList = menuService.getMenuList(menuIds);}menuList = menuService.filterDisableMenus(menuList);

对应的授权操作在角色管理页面。点击某个角色的“菜单权限”,会弹出一棵可勾选的菜单树:

▲ 菜单权限:勾选的是菜单与按钮节点,保存后写入角色与菜单的关联;用户登录时按其全部角色合并出菜单和权限标识。

菜单树里有三类节点:目录、菜单和按钮。按钮节点没有页面,只携带一个权限标识,比如 oa:car-apply-bill:create。前端的按钮显隐、后端的接口校验,用的是同一个标识。

三、功能权限:@PreAuthorize 与 @ss

后端用 @PreAuthorize("@ss.hasPermission('...')") 声明接口所需权限,@ss 是安全框架里注册的 Bean。例如角色管理的几个接口:

接口
权限标识
创建角色
system:role:create
修改角色
system:role:update
删除角色
system:role:delete
赋予角色菜单
system:permission:assign-role-menu
赋予角色数据权限
system:permission:assign-role-data-scope

判定逻辑在 SecurityFrameworkServiceImpl:

@Override@SneakyThrowspublic boolean hasAnyPermissions(String... permissions) {    // 特殊:跨租户访问    if (skipPermissionCheck()) {        return true;    }    // 权限校验    Long userId = getLoginUserId();    if (userId == null) {        return false;    }    KeyValue<String, List<String>> key = new KeyValue<>(buildCacheKey(userId), Arrays.asList(permissions));    Boolean cached = hasAnyPermissionsCache.getIfPresent(key);    if (Boolean.TRUE.equals(cached)) {        return true;    }    // 不信任本地 false / 未命中:每次回源;仅缓存 true    Boolean result = permissionApi.hasAnyPermissions(userId, permissions).getCheckedData();    if (Boolean.TRUE.equals(result)) {        hasAnyPermissionsCache.put(key, true);        return true;    }    hasAnyPermissionsCache.invalidate(key);    return false;}

读这段代码能得到四条规律:

  1. 判定从用户编号出发,而不是从令牌里读权限,所以修改角色授权后无需让用户重新登录,只受一分钟本地缓存影响。
  2. 只缓存“通过”的结果,不缓存“拒绝”,避免角色修复后仍被旧的拒绝结果卡住。
  3. 缓存键包含用户和当前部门。多组织场景下同一用户切换组织,权限码会变化,只按用户缓存会让切换后的短时间内仍沿用旧组织的权限。
  4. 第一行的 skipPermissionCheck() 是跨租户访问的特殊通道,后文单独讲。

服务端是怎么判断“有没有权限”的?PermissionServiceImpl.hasAnyPermissions 先取用户的有效角色,逐个权限标识去匹配;匹配不到再看这些角色里有没有超级管理员,有则直接通过。因此超级管理员不是“拥有所有权限标识”,而是“跳过了标识判断”。

前端对应的是按钮上的 auth:

层
写法
作用
能被绕过吗
菜单树
登录时拿到的菜单
决定侧栏里有哪些入口
能,直接输入地址即可到达页面
按钮 auth
TableAction
 的 auth 数组
决定按钮是否显示
能,直接调用接口即可
后端注解
@PreAuthorize("@ss.hasPermission(...)")
决定接口能否执行
不能

结论只有一句:前两层是体验,第三层才是安全。

四、数据权限:同一个接口,不同的人看到不同的行

功能权限通过之后,还要决定能看到哪些行。角色上有一个“数据范围”,对应枚举 DataScopeEnum:

取值
名称
含义
1
ALL
全部数据
2
DEPT_CUSTOM
指定部门(并自动包含本人所在部门)
3
DEPT_ONLY
仅本部门
4
DEPT_AND_CHILD
本部门及下级部门
5
SELF
仅本人

在角色管理里点击“数据权限”,就能给角色选择范围:

▲ 数据权限弹窗:权限范围是角色的属性,不是用户的属性;一个用户有多个角色时,各角色的范围会合并。

范围怎么变成部门编号集合,由 PermissionServiceImpl.getDeptDataPermission 计算:

for (RoleDO role : roles) {    if (role.getDataScope() == null) {        continue;    }    // 情况一,ALL    if (Objects.equals(role.getDataScope(), DataScopeEnum.ALL.getScope())) {        result.setAll(true);        continue;    }    // 情况二,DEPT_CUSTOM    if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_CUSTOM.getScope())) {        CollUtil.addAll(result.getDeptIds(), role.getDataScopeDeptIds());        CollectionUtils.addIfNotNull(result.getDeptIds(), userDeptId.get());        continue;    }    // 情况三,DEPT_ONLY    if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_ONLY.getScope())) {        CollectionUtils.addIfNotNull(result.getDeptIds(), userDeptId.get());        continue;    }    // 情况四,DEPT_DEPT_AND_CHILD(子部门编号集合的计算省略)    // 情况五,SELF    if (Objects.equals(role.getDataScope(), DataScopeEnum.SELF.getScope())) {        result.setSelf(true);        continue;    }}

有三个细节:用户没有任何角色时,结果只允许查看自己;多个角色的结果是取并集,不是取交集;“指定部门”会自动把本人所在部门也加进去,避免用户连自己所在部门的数据都看不到。

范围算出来之后,由 DeptDataPermissionRule 把它翻译成 SQL 条件:

@Overridepublic Expression getExpression(String tableName, Alias tableAlias) {    // 只有有登陆用户的情况下,才进行数据权限的处理    LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();    if (loginUser == null) {        return null;    }    // 只有管理员类型的用户,才进行数据权限的处理    if (ObjectUtil.notEqual(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) {        return null;    }    // ... 获取并缓存 deptDataPermission,省略    // 情况一,如果是 ALL 可查看全部,则无需拼接条件    if (deptDataPermission.getAll()) {        return null;    }    // 情况二,即不能查看部门,又不能查看自己,则说明 100% 无权限    if (CollUtil.isEmpty(deptDataPermission.getDeptIds())            && Boolean.FALSE.equals(deptDataPermission.getSelf())) {        return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空    }    // 情况三,拼接 Dept 和 User 的条件,最后组合    // ... 目前,如果有指定部门 + 可查看自己,采用 OR 条件。即,WHERE (dept_id IN ? OR user_id = ?)}

这里藏着本文最重要的一个提醒:这条规则只对“登记过的表”生效。登记发生在各模块的 DeptDataPermissionRuleCustomizer 里,仓库里目前只有 system 模块做了登记:

@Beanpublic DeptDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() {    return rule -> {        // dept        rule.addDeptColumn(AdminUserDO.class);        rule.addDeptColumn(DeptDO.class, "id");        // user        rule.addUserColumn(AdminUserDO.class, "id");    };}

也就是说,用户表按 dept_id 过滤、部门表按 id 过滤、用户表还支持按本人过滤。OA 用车的几张表并没有登记,所以它们不会被这条规则自动拼条件。第三篇已经看到,用车申请单列表之所以只显示自己创建的单据,是因为 Service 层把 creator 强制改成了当前登录用户,这属于业务代码里的显式过滤,而不是框架的数据权限。

这给新模块一个明确的检查项:

如果你希望
就需要做
业务表也受角色数据范围控制
在模块里提供 DeptDataPermissionRuleCustomizer,登记表的 dept_id 或 user_id 列
只让用户看到自己的单据
在 Service 里显式按当前用户过滤,并写进测试
管理员看全部
另做管理视图或管理接口,用独立权限标识保护

五、租户隔离:同一套代码,多家企业

多租户的第一步,是让每个请求都带上租户编号。前端的请求拦截器统一加请求头:

client.addRequestInterceptor({  fulfilled: async (config) => {    const accessStore = useAccessStore();    config.headers.Authorization = formatToken(accessStore.accessToken);    config.headers['Accept-Language'] = preferences.app.locale;    // 添加租户编号    config.headers['tenant-id'] = tenantEnable      ? accessStore.tenantId      : undefined;    // 只有登录时,才设置 visit-tenant-id 访问租户    config.headers['visit-tenant-id'] = tenantEnable      ? accessStore.visitTenantId      : undefined;    const currentDeptId = useUserStore().userInfo?.deptId;    if (currentDeptId) {      config.headers['current-dept-id'] = String(currentDeptId);    }

后端第一站是 TenantContextWebFilter,把 tenant-id 解析出来放进 TenantContextHolder,请求结束时清理。第二站是令牌过滤器之后的 TenantSecurityWebFilter,它负责防止越权:

Long tenantId = TenantContextHolder.getTenantId();// 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。LoginUser user = SecurityFrameworkUtils.getLoginUser();if (user != null) {    // 如果获取不到租户编号,则尝试使用登陆用户的租户编号    if (tenantId == null) {        tenantId = user.getTenantId();        TenantContextHolder.setTenantId(tenantId);        // 如果传递了租户编号,则进行比对租户编号,避免越权问题    } else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {        log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",                user.getTenantId(), user.getId(), user.getUserType(),                TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());        ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),                "您无权访问该租户的数据"));        return;    }}

关键在比对:令牌里的租户才是可信的,请求头里的租户只是“声明”。一个登录用户即使把 tenant-id 改成别家企业,也会被这里挡下。过滤器的执行顺序由 WebFilterOrderEnum 约定:租户上下文过滤器是 -104,租户校验过滤器是 -99,源码注释写明后者需要在 Spring Security 过滤器之后。

第三站是 SQL 层。TenantDatabaseInterceptor 基于 MyBatis-Plus 的租户插件,给每条语句追加租户条件:

@Overridepublic Expression getTenantId() {    return new LongValue(TenantContextHolder.getRequiredTenantId());}@Overridepublic boolean ignoreTable(String tableName) {    // 情况一,全局忽略多租户    if (TenantContextHolder.isIgnore()) {        return true;    }    // 情况二,忽略多租户的表    tableName = SqlParserUtils.removeWrapperSymbol(tableName);    Boolean ignore = ignoreTables.get(tableName.toLowerCase());    if (ignore == null) {        ignore = computeIgnoreTable(tableName);        synchronized (ignoreTables) {            addIgnoreTable(tableName, ignore);        }    }    return ignore;}private boolean computeIgnoreTable(String tableName) {    // 找不到的表,说明不是 yudao 项目里的,不进行拦截(忽略租户)    TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName);    if (tableInfo == null) {        return true;    }    // 如果继承了 TenantBaseDO 基类,显然不忽略租户    if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) {        return false;    }    // 如果添加了 @TenantIgnore 注解,则忽略租户    TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class);    return tenantIgnore != null;}

读完要点出一个容易误解的地方:用车模块的 CarApplyBillDO 继承的是 BaseDO,不是 TenantBaseDO。按上面的逻辑,没有 @TenantIgnore、也不在忽略表配置里的实体,都会被拼接租户条件,所以用车的表同样受隔离保护,前提是表里确实有 tenant_id 列。第三篇展示的建表语句里能看到这一列。

配置侧,yudao.tenant 下可以登记忽略的路径和表,例如积木报表路径、资产扫码短链路径、以及 system_user_role 等不需要租户条件的表。新增忽略项意味着打开一个口子,必须说明原因。

▲ 租户管理:每个租户有套餐、账号额度和到期时间。到期或被禁用的租户,会在 TenantSecurityWebFilter 的合法性校验里被拒绝访问。

5.1 跨租户访问是一个有意设计的“后门”

平台管理员需要进入客户租户排查问题,这靠请求头 visit-tenant-id 实现。TenantVisitContextInterceptor 的处理很克制:

@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {    // 如果和当前租户编号一致,则直接跳过    Long visitTenantId = WebFrameworkUtils.getVisitTenantId(request);    if (visitTenantId == null) {        return true;    }    if (ObjUtil.equal(visitTenantId, TenantContextHolder.getTenantId())) {        return true;    }    // 必须是登录用户    LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();    if (loginUser == null) {        return true;    }    // 校验用户是否可切换租户    if (!securityFrameworkService.hasAnyPermissions(PERMISSION)) {        throw exception0(GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您无权切换租户");    }    // 【重点】切换租户编号    loginUser.setVisitTenantId(visitTenantId);    TenantContextHolder.setTenantId(visitTenantId);    return true;}

切换需要权限标识 system:tenant:visit,请求结束后会还原为原租户。但也要清楚代价:一旦 visitTenantId 与登录租户不同,skipPermissionCheck() 返回真,源码注释写得很直白,“跨租户访问时,无法进行权限校验”。所以这个权限标识应当只授予平台运维人员,并且访问行为要有审计。

六、串起来:一条请求的完整安全链

把前面所有环节放进同一条时间线,就是下面这张图:

▲ 时序图:红色标注的两处是会直接返回 403 的位置;数据权限和租户条件不返回错误,而是悄悄改写 SQL。

步骤
发生了什么
所在位置
失败时
1
浏览器带上令牌、租户和当前部门
Vben 请求拦截器
缺失则后续无法识别
2
租户编号进入上下文
TenantContextWebFilter
无
3
令牌变成登录用户
TokenAuthenticationFilter
按未登录处理
4
比对租户并校验租户可用
TenantSecurityWebFilter
403 或租户状态错误
5
校验功能权限
@PreAuthorize("@ss.hasPermission(...)")
403
6
SQL 拼接数据权限与租户条件
数据权限规则与租户插件
不报错,查不到

实际排查时,这张表比任何文字都有用:报 403 的看第 4、5 步;没报错但数据不对的看第 6 步;一直跳登录的看第 3 步。

七、常见故障矩阵

现象
最可能的根因层
如何确认
正确的修法
登录后立刻被踢回登录页
令牌无效或已过期
看接口返回码与刷新令牌流程
查令牌有效期与刷新逻辑,不要在前端忽略错误
按钮看不到
角色缺少该按钮的权限标识
角色菜单权限里核对 auth 数组
给角色勾选对应按钮节点
按钮能点但提示无权限
前端 auth 与后端注解标识不一致
对照 Controller 的 @PreAuthorize
统一两端的权限标识
改了角色授权没立即生效
一分钟本地缓存
等待缓存过期或重新进入页面
属于预期,不要为此缩短缓存
返回“您无权访问该租户的数据”
请求头 tenant-id 与令牌租户不一致
浏览器网络面板里看请求头
检查登录租户选择与本地缓存的租户编号
列表是空的但接口成功
数据权限范围过窄,或业务显式过滤
看角色数据范围与 Service 里的过滤条件
调整角色范围或确认业务过滤是否符合预期
新业务表看到了别人部门的数据
表未登记到数据权限规则
看模块里是否有 DeptDataPermissionRuleCustomizer
登记表的 dept_id 或 user_id 列
超级管理员什么都能做
超管跳过功能权限判断
看角色的超管标识
属于设计,日常运营请使用普通管理员角色
平台管理员切到客户租户后权限异常
跨租户访问跳过权限校验
看请求头 visit-tenant-id
属于设计;严格控制 system:tenant:visit

八、本文发现的几处不足

  1. 用车模块的表没有登记到框架的数据权限规则,角色“数据权限”配置对它们不起作用,现状靠 Service 层按创建人过滤。
  2. 令牌过滤器会信任 login-user 请求头。在微服务模式下由网关写入没有问题,但单体或直连部署需要确保入口层不会放行客户端自带的这个头。
  3. 跨租户访问期间功能权限与数据权限整体跳过,只靠 system:tenant:visit 一个标识把关。
  4. 配置文件里存在示例性质的第三方密钥项,实际部署必须通过环境变量或密钥管理注入,不能依赖仓库里的默认值,相关做法放在第七篇。

九、今天学完你应该能做到

  1. 能画出认证、租户、功能权限、数据权限、租户条件五道门,并说出各自的源码位置。
  2. 能解释为什么 auth 属于体验,而 @PreAuthorize 才属于安全。
  3. 能说出五种数据范围的含义,以及多个角色时为什么取并集。
  4. 能判断一张新业务表需不需要登记数据权限,以及不登记的后果。
  5. 能区分“报 403”和“查不到数据”分别该查链路的哪一步。

常见问题(FAQ)

前端已经隐藏了按钮,后端还需要加注解吗?

必须加。前端隐藏只是体验优化,任何人都可以用接口工具直接请求。后端注解是唯一真正的功能权限边界。

数据权限和功能权限哪个先判断?

功能权限先判断,决定接口能不能执行;执行过程中到 SQL 层,数据权限再决定能查到哪些行。前者失败返回 403,后者不报错,只是结果变少。

为什么租户编号既在请求头里,又在令牌里?

请求头是客户端的声明,令牌里的是服务端签发时确定的事实。后端用后者校验前者,不一致就拒绝,这样即使有人修改请求头也无法越权。

超级管理员能不能也受数据权限约束?

数据范围由角色决定。超级管理员在功能权限上被直接放行,但数据范围仍取决于其角色配置。日常运营建议使用普通管理员角色,超管仅用于初始化与应急。

新增业务表一定要有 tenant_id 吗?

在启用多租户的前提下,默认所有被 MyBatis-Plus 管理的表都会拼接租户条件,没有该列会导致 SQL 报错。确实全局共享的表需要用 @TenantIgnore 或配置忽略,并写明原因。

系列进度与下一篇预告

篇目
主题
状态
(一)
架构篇——一个底座,多端通达
已发布
(二)
启动篇——从源码到前后端联调,跑通开发环境
已发布
(三)
后端篇——从业务建模到接口开发,做出一个完整模块
已发布
(四)
前端篇——从菜单路由到表单列表,完成业务页面
已发布
(五)
权限篇——把登录、角色、数据权限与租户隔离讲透
本篇
(六)
流程篇——接入审批,让业务单据真正流转起来
下一篇
(七)
上线篇——从测试验收到部署上线,完成企业项目交付
预告

谁能操作、谁能看到已经讲清了,接下来是企业应用最有代表性的一块:审批。下一篇进入流程篇,看清业务单据与流程实例的关系,沿着“提交、发起、审批、回写”走完一条闭环,并说明业务模块为什么不能把全部状态都交给流程引擎。

如果这篇对你有用,点个「在看」或收藏。

🌐 演示地址:https://ruoyioffice.com/web
 📦 GitHub 源码:https://github.com/yuqing2026/ruoyi-office
 📦 Gitee 源码:https://gitee.com/yqzy1688/ruoyi-office
 💬 微信:17156169080(获取产品咨询)

打开演示地址直接查看系统。

【声明】内容源于网络
0
0
企业软件源码
RuoyiOffice 是一套基于 Spring Boot + Vue3 +Uniapp 的企业一体化管理平台,集 OA、CRM、ERP、工作流、HR、资产、合同、项目、AI应用等业务于一体,帮助企业用一个系统协同管理多类核心业务。
内容 142
粉丝 0
企业软件源码 RuoyiOffice 是一套基于 Spring Boot + Vue3 +Uniapp 的企业一体化管理平台,集 OA、CRM、ERP、工作流、HR、资产、合同、项目、AI应用等业务于一体,帮助企业用一个系统协同管理多类核心业务。
总阅读2.4k
粉丝0
内容142