若依分离版 + Sa-Token 单点登录实战教程
从零到一,手把手教你将若依前后端分离项目改造为SSO单点登录系统
一、背景介绍
1.1 什么是单点登录(SSO)?
单点登录(Single Sign-On)是一种身份验证机制,用户只需登录一次,就可以访问多个相互信任的应用系统。
┌─────────────────────────────────────────────────────────────┐
│ SSO 架构示意图 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ 用户 │ │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ SSO认证中心│ ← 只需要登录一次 │
│ └─────┬─────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 系统A │ │ 系统B │ │ 系统C │ │
│ │ (若依) │ │ (ERP) │ │ (OA) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 用户登录一次,即可访问所有系统 │
└─────────────────────────────────────────────────────────────┘
1.2 为什么选择 Sa-Token?
| 特性 | Sa-Token | CAS | Spring Security OAuth2 |
|---|---|---|---|
| 上手难度 | 简单 | 复杂 | 中等 |
| 文档质量 | 优秀 | 一般 | 一般 |
| 与若依融合 | 天然适配 | 需改造 | 需改造 |
| 功能丰富度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 社区活跃度 | 高 | 中 | 高 |
Sa-Token 优势:
- 若依官方已支持 Sa-Token 版本
- API 简洁,一行代码实现登录
- 支持多账号体系(用户、管理员分开登录)
- 内置 SSO 单点登录模块
- 支持踢人下线、同端互斥登录等
二、整体架构设计
2.1 系统角色
本教程涉及三个系统角色:
| 角色 | 说明 | 端口 |
|---|---|---|
| SSO-Server | 认证中心(若依后台) | 8080 |
| SSO-Client1 | 客户端应用1 | 8081 |
| SSO-Client2 | 客户端应用2 | 8082 |
2.2 认证流程
┌──────────────────────────────────────────────────────────────────┐
│ SSO 登录流程 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 用户 Client应用 SSO-Server 数据库 │
│ │ │ │ │ │
│ │ 1.访问资源 │ │ │ │
│ │ ──────────────▶ │ │ │
│ │ │ 2.检查登录状态 │ │ │
│ │ │ ─────────────────▶ │ │ │
│ │ │ │ 3.查询token │ │
│ │ │ │ ───────────────▶│ │
│ │ │ 4.未登录,跳转 │ │ │
│ │ │◀───────────────── │ │ │
│ │ 5.跳转登录页 │ │ │ │
│ │◀───────────────│ │ │ │
│ │ │ │ │ │
│ │ 6.输入账号密码 │ │ │ │
│ │ ──────────────────────────────────▶│ │ │
│ │ │ │ 7.验证用户 │ │
│ │ │ │ ───────────────▶│ │
│ │ │ │◀─────────────── │ │
│ │ │ │ 8.生成token │ │
│ │ 9.携带ticket │ │ │ │
│ │ ──────────────────────────────────▶│ │ │
│ │ │ 10.回调通知 │ │ │
│ │ │◀───────────────── │ │ │
│ │ │ 11.验证ticket │ │ │
│ │ │ ─────────────────▶ │ │ │
│ │ │◀───────────────── │ │ │
│ │ │ 12.返回用户信息 │ │ │
│ │ 13.登录成功 │ │ │ │
│ │◀───────────────│ │ │ │
│ │ │ │ │ │
└──────────────────────────────────────────────────────────────────┘
三、环境准备
3.1 技术栈要求
- JDK 1.8+
- MySQL 5.7+
- Redis 3.0+
- Maven 3.6+
- Node.js 14+
3.2 基础项目
下载若依前后端分离版本:
# 后端
git clone https://gitee.com/y_project/RuoYi-Vue.git
# 前端
git clone https://gitee.com/y_project/RuoYi-Vue.git RuoYi-Vue-UI
四、SSO-Server 认证中心改造
4.1 添加 Sa-Token 依赖
在 ruoyi-admin/pom.xml 中添加:
<!-- Sa-Token 核心 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.37.0</version>
</dependency>
<!-- Sa-Token 整合 JWT -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-jwt</artifactId>
<version>1.37.0</version>
</dependency>
<!-- Sa-Token 整合 Redis (使用 jdk 序列化) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-dao-redis-jackson</artifactId>
<version>1.37.0</version>
</dependency>
<!-- Sa-Token SSO 模块 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-sso</artifactId>
<version>1.37.0</version>
</dependency>
<!-- Redis 连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
4.2 配置 Sa-Token
在 application.yml 中添加配置:
# Sa-Token 配置
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: Authorization
# token 有效期(单位:秒),默认30天,-1代表永不过期
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1代表不限制
active-timeout: -1
# 是否允许同一账号多地同时登录(为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 多人登录同一账号时,共用一个 token(为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
is-share: false
# token 风格(uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: uuid
# 是否输出操作日志
is-log: true
# 是否从 cookie 中读取 token
is-read-cookie: false
# 是否从请求体里读取 token
is-read-body: false
# 是否从 header 中读取 token
is-read-header: true
# token 前缀
token-prefix: Bearer
# SSO 配置
sa-token-sso:
# SSO Server 端地址
server-url: http://localhost:8080/sso/auth
# 是否打开单点注销功能
is-slo: true
# 接口调用秘钥
secret-key: ruoyi-sso-secret-key-2024
# 允许的授权回调地址(多个用逗号分隔)
allow-url: http://localhost:8081/*,http://localhost:8082/*
# Redis 配置(Sa-Token 存储)
spring:
redis:
host: localhost
port: 6379
password:
database: 0
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
4.3 创建 Sa-Token 配置类
创建 SaTokenConfig.java:
package com.ruoyi.framework.config;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.sso.config.SaSsoConfig;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.service.ISysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
/**
* Sa-Token SSO 配置
*/
@Configuration
public class SaTokenConfig
{
@Autowired
private ISysUserService userService;
/**
* 配置 SSO 相关参数
*/
@Autowired
private void configSso(SaSsoConfig ssoConfig) {
// 配置:未登录时返回的 View
ssoConfig.notLoginView = () -> {
return new SaResult()
.setCode(401)
.setMsg("请先登录")
.setData("/login");
};
// 配置:登录处理函数
ssoConfig.doLoginHandle = (name, pwd) -> {
// 1. 验证账号密码(使用若依的认证逻辑)
SysUser user = userService.selectUserByUserName(name);
if (user == null) {
return SaResult.error("账号不存在");
}
// 验证密码(使用若依的加密方式)
// if (!SecurityUtils.matchesPassword(pwd, user.getPassword())) {
// return SaResult.error("密码错误");
// }
// 2. 执行登录
StpUtil.login(user.getUserId());
// 3. 返回登录信息
return SaResult.ok("登录成功")
.set("token", StpUtil.getTokenValue())
.set("userId", user.getUserId())
.set("userName", user.getUserName());
};
// 配置:查询用户信息
ssoConfig.getUserinfoHandle = (loginId, ssoToken) -> {
SysUser user = userService.selectUserById(Long.parseLong(loginId.toString()));
if (user == null) {
return SaResult.error("用户不存在");
}
return SaResult.data(user);
};
}
}
4.4 创建 SSO 控制器
创建 SsoServerController.java:
package com.ruoyi.web.controller.sso;
import cn.dev33.satoken.sso.SaSsoHandle;
import cn.dev33.satoken.stp.StpUtil;
import com.ruoyi.common.core.domain.AjaxResult;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* SSO Server 控制器
*/
@RestController
@RequestMapping("/sso")
public class SsoServerController
{
/**
* SSO 认证中心统一认证地址
*/
@RequestMapping("/auth")
public Object auth() {
return SaSsoHandle.ssoAuth();
}
/**
* SSO 认证中心校验 ticket 地址
*/
@RequestMapping("/ticket")
public Object ticket() {
return SaSsoHandle.ssoCheckTicket();
}
/**
* SSO 认证中心单点注销地址
*/
@RequestMapping("/logout")
public Object logout() {
StpUtil.logout();
return AjaxResult.success("注销成功");
}
/**
* 获取当前用户信息
*/
@RequestMapping("/userinfo")
public Object userinfo() {
if (!StpUtil.isLogin()) {
return AjaxResult.error("请先登录");
}
return AjaxResult.success(StpUtil.getSession());
}
}
4.5 修改若依登录逻辑
修改 SysLoginService.java,集成 Sa-Token:
package com.ruoyi.framework.web.service;
import cn.dev33.satoken.stp.StpUtil;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import com.ruoyi.system.service.ISysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 登录服务(Sa-Token 版本)
*/
@Component
public class SysLoginService
{
@Autowired
private ISysUserService userService;
@Autowired
private SysPasswordService passwordService;
@Autowired
private TokenService tokenService;
/**
* 登录验证
*/
public String login(String username, String password, String code, String uuid)
{
// 1. 验证码校验
// validateCaptcha(username, code, uuid);
// 2. 用户验证
SysUser user = userService.selectUserByUserName(username);
if (user == null) {
throw new ServiceException("用户不存在");
}
if (user.getDelFlag().equals("2")) {
throw new ServiceException("用户已被删除");
}
if (user.getStatus().equals("1")) {
throw new ServiceException("用户已被停用");
}
// 3. 密码验证
passwordService.validate(user, password);
// 4. 使用 Sa-Token 执行登录
StpUtil.login(user.getUserId());
// 5. 记录登录日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(
username, Constants.LOGIN_SUCCESS, "登录成功"));
// 6. 返回 token
return StpUtil.getTokenValue();
}
/**
* 登出
*/
public void logout()
{
if (StpUtil.isLogin()) {
AsyncManager.me().execute(AsyncFactory.recordLogininfor(
StpUtil.getLoginIdAsString(), Constants.LOGOUT, "退出成功"));
StpUtil.logout();
}
}
}
4.6 创建 Sa-Token 权限验证类
创建 StpInterfaceImpl.java,实现 Sa-Token 的权限验证接口:
package com.ruoyi.framework.satoken;
import cn.dev33.satoken.stp.StpInterface;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.framework.web.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Sa-Token 权限验证实现
*/
@Component
public class StpInterfaceImpl implements StpInterface
{
@Autowired
private TokenService tokenService;
/**
* 返回一个账号所拥有的权限码集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType)
{
LoginUser loginUser = getLoginUser(loginId);
if (loginUser == null) {
return new ArrayList<>();
}
return new ArrayList<>(loginUser.getPermissions());
}
/**
* 返回一个账号所拥有的角色标识集合
*/
@Override
public List<String> getRoleList(Object loginId, String loginType)
{
LoginUser loginUser = getLoginUser(loginId);
if (loginUser == null) {
return new ArrayList<>();
}
List<String> roles = new ArrayList<>();
loginUser.getUser().getRoles().forEach(role -> {
roles.add(role.getRoleKey());
});
return roles;
}
/**
* 获取登录用户信息
*/
private LoginUser getLoginUser(Object loginId)
{
// 从 Sa-Token Session 中获取用户信息
return (LoginUser) StpUtil.getSession().get("loginUser");
}
}
4.7 配置全局异常处理
创建 SaTokenExceptionHandler.java:
package com.ruoyi.framework.exception;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import com.ruoyi.common.core.domain.AjaxResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Sa-Token 异常处理
*/
@RestControllerAdvice
public class SaTokenExceptionHandler
{
/**
* 未登录异常
*/
@ExceptionHandler(NotLoginException.class)
public AjaxResult handleNotLoginException(NotLoginException e)
{
return AjaxResult.error(401, "未登录或登录已过期,请重新登录");
}
/**
* 无权限异常
*/
@ExceptionHandler(NotPermissionException.class)
public AjaxResult handleNotPermissionException(NotPermissionException e)
{
return AjaxResult.error(403, "您没有操作权限");
}
}
五、SSO-Client 客户端配置
5.1 客户端依赖配置
在客户端项目的 pom.xml 中添加:
<!-- Sa-Token 核心 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.37.0</version>
</dependency>
<!-- Sa-Token SSO 模块 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-sso</artifactId>
<version>1.37.0</version>
</dependency>
<!-- Sa-Token 整合 Redis -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-dao-redis-jackson</artifactId>
<version>1.37.0</version>
</dependency>
5.2 客户端配置文件
在 application.yml 中配置:
server:
port: 8081
# Sa-Token 配置
sa-token:
token-name: Authorization
timeout: 2592000
is-concurrent: true
is-share: false
token-style: uuid
is-read-cookie: false
is-read-header: true
token-prefix: Bearer
# SSO 客户端配置
sa-token-sso:
# SSO Server 认证地址
auth-url: http://localhost:8080/sso/auth
# SSO Server 校验 ticket 地址
check-ticket-url: http://localhost:8080/sso/ticket
# SSO Server 单点注销地址
slo-url: http://localhost:8080/sso/logout
# 当前 Client 名称标识
client: client1
# 接口调用秘钥(需与 Server 端一致)
secret-key: ruoyi-sso-secret-key-2024
# SSO-Server 端的 ticket 查询地址
ticket-url: http://localhost:8080/sso/ticket
spring:
redis:
host: localhost
port: 6379
database: 0
5.3 客户端控制器
创建 SsoClientController.java:
package com.ruoyi.web.controller.sso;
import cn.dev33.satoken.sso.SaSsoHandle;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* SSO Client 控制器
*/
@RestController
@RequestMapping("/sso")
public class SsoClientController
{
/**
* SSO 客户端登录地址
*/
@RequestMapping("/login")
public Object login() {
return SaSsoHandle.ssoLogin();
}
/**
* SSO 客户端注销地址
*/
@RequestMapping("/logout")
public Object logout() {
StpUtil.logout();
return "注销成功";
}
/**
* 检查登录状态
*/
@RequestMapping("/isLogin")
public Object isLogin() {
return StpUtil.isLogin();
}
}
5.4 客户端登录拦截器
创建 SsoClientInterceptor.java:
package com.ruoyi.framework.interceptor;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* SSO 客户端登录拦截器
*/
public class SsoClientInterceptor implements HandlerInterceptor
{
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
{
// 判断是否已登录
if (!StpUtil.isLogin()) {
// 未登录,重定向到 SSO 认证中心
String ssoAuthUrl = "http://localhost:8080/sso/auth?redirect=" + request.getRequestURL();
response.sendRedirect(ssoAuthUrl);
return false;
}
return true;
}
}
5.5 注册拦截器
package com.ruoyi.framework.config;
import com.ruoyi.framework.interceptor.SsoClientInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer
{
@Override
public void addInterceptors(InterceptorRegistry registry)
{
// 注册 SSO 拦截器
registry.addInterceptor(new SsoClientInterceptor())
.addPathPatterns("/**")
.excludePathPatterns(
"/sso/**",
"/login",
"/logout",
"/static/**",
"/favicon.ico"
);
}
}
六、前端改造
6.1 登录页面改造
修改 login.vue,支持 SSO 登录:
<template>
<div class="login">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form">
<h3 class="title">若依后台管理系统</h3>
<!-- SSO 登录按钮 -->
<div class="sso-login">
<el-button type="primary" @click="ssoLogin" :loading="ssoLoading">
统一认证登录
</el-button>
</div>
<el-divider>或</el-divider>
<!-- 本地账号登录 -->
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="账号" prefix-icon="el-icon-user" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="密码" prefix-icon="el-icon-lock" @keyup.enter.native="handleLogin" />
</el-form-item>
<el-form-item>
<el-button :loading="loading" type="primary" style="width:100%" @click.native.prevent="handleLogin">
登录
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { getToken, setToken } from '@/utils/auth'
export default {
name: 'Login',
data() {
return {
loginForm: {
username: '',
password: ''
},
loginRules: {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
},
loading: false,
ssoLoading: false,
redirect: undefined
}
},
created() {
// 检查 URL 中是否携带 ticket(SSO 回调)
this.checkSsoTicket()
},
methods: {
// SSO 登录
ssoLogin() {
this.ssoLoading = true
// 跳转到 SSO 认证中心
const currentUrl = window.location.href
const ssoAuthUrl = process.env.VUE_APP_SSO_AUTH_URL + '?redirect=' + encodeURIComponent(currentUrl)
window.location.href = ssoAuthUrl
},
// 检查 SSO Ticket
async checkSsoTicket() {
const ticket = this.$route.query.ticket
if (ticket) {
try {
// 验证 ticket 并获取 token
const res = await this.$store.dispatch('SsoLogin', ticket)
if (res.code === 200) {
setToken(res.token)
this.$router.push({ path: this.redirect || '/' })
}
} catch (error) {
this.$message.error('SSO 登录失败')
}
}
},
// 本地登录
handleLogin() {
this.$refs.loginForm.validate(async valid => {
if (valid) {
this.loading = true
try {
await this.$store.dispatch('Login', this.loginForm)
this.$router.push({ path: this.redirect || '/' })
} catch (error) {
this.loading = false
}
}
})
}
}
}
</script>
<style scoped>
.sso-login {
text-align: center;
margin-bottom: 20px;
}
</style>
6.2 配置环境变量
创建 .env.development:
# SSO 配置
VUE_APP_SSO_AUTH_URL=http://localhost:8080/sso/auth
VUE_APP_SSO_TICKET_URL=http://localhost:8080/sso/ticket
6.3 Store 改造
在 store/modules/user.js 中添加 SSO 登录方法:
import { login, logout, getInfo, ssoLogin } from '@/api/login'
const user = {
actions: {
// SSO 登录
SsoLogin({ commit }, ticket) {
return new Promise((resolve, reject) => {
ssoLogin(ticket).then(res => {
commit('SET_TOKEN', res.token)
setToken(res.token)
resolve(res)
}).catch(error => {
reject(error)
})
})
}
}
}
export default user
6.4 API 接口
在 api/login.js 中添加:
import request from '@/utils/request'
// SSO 登录验证 ticket
export function ssoLogin(ticket) {
return request({
url: '/sso/login',
method: 'post',
params: { ticket }
})
}
七、测试验证
7.1 启动顺序
# 1. 启动 Redis
redis-server
# 2. 启动 SSO Server(若依后台,端口 8080)
java -jar ruoyi-admin.jar
# 3. 启动 SSO Client1(端口 8081)
java -jar client1.jar
# 4. 启动 SSO Client2(端口 8082)
java -jar client2.jar
# 5. 启动前端
npm run dev
7.2 测试流程
- 访问 Client1:
http://localhost:8081 - 自动跳转到 SSO 认证中心登录页
- 输入账号密码登录
- 登录成功后自动跳回 Client1
- 再访问 Client2:
http://localhost:8082 - 无需再次登录,直接进入系统
7.3 测试单点注销
- 在任意系统点击注销
- 所有系统的登录状态都会失效
- 再次访问任意系统需要重新登录
八、常见问题与解决方案
Q1:跨域问题如何解决?
在 SSO Server 中配置跨域:
@Configuration
public class CorsConfig implements WebMvcConfigurer
{
@Override
public void addCorsMappings(CorsRegistry registry)
{
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
Q2:如何实现记住登录?
// 登录时设置
StpUtil.login(userId, new SaLoginModel()
.setTimeout(60 * 60 * 24 * 30) // 30天有效
.setDevice("PC") // 指定设备类型
);
Q3:如何踢人下线?
// 踢指定用户下线
StpUtil.kickout(userId);
// 踢指定 token 下线
StpUtil.kickoutByTokenValue(token);
Q4:如何实现同端互斥登录?
sa-token:
is-concurrent: false # 不允许同端多登录
九、总结
本文完整介绍了将若依前后端分离项目改造为 Sa-Token SSO 单点登录系统的过程:
| 改造内容 | 说明 |
|---|---|
| 后端改造 | 添加 Sa-Token 依赖、配置 SSO Server |
| 认证改造 | 集成若依用户体系 |
| 客户端配置 | SSO Client 配置和拦截器 |
| 前端改造 | 支持 SSO 登录流程 |
| 测试验证 | 单点登录和单点注销 |
改造优势:
- 改造成本低(约 2-3 天完成)
- 与若依权限体系完美融合
- 支持多客户端接入
- 可扩展性强(支持踢人下线、同端互斥等功能)


1459

被折叠的 条评论
为什么被折叠?



