1. 增加 yudao-spring-boot-starter-tenant 租户的组件

2. 改造 UserDO,接入多租户
This commit is contained in:
YunaiV 2021-12-04 21:09:49 +08:00
parent ccb56b3b99
commit 7c8fe2fc50
35 changed files with 426 additions and 15 deletions

View File

@ -117,6 +117,11 @@
<artifactId>yudao-spring-boot-starter-excel</artifactId>
</dependency>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-spring-boot-starter-tenant</artifactId>
</dependency>
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>

View File

@ -1,11 +1,12 @@
### 请求 /login 接口 => 成功
POST {{baseUrl}}/login
Content-Type: application/json
tenant-id: 0
{
"username": "admin",
"password": "admin123",
"uuid": "9b2ffbc1-7425-4155-9894-9d5c08541d62",
"uuid": "3acd87a09a4f48fb9118333780e94883",
"code": "1024"
}

View File

@ -12,6 +12,13 @@ import java.time.Duration;
@Data
public class CaptchaProperties {
private static final Boolean ENABLE_DEFAULT = true;
/**
* 是否开启
* 注意这里仅仅是后端 Server 是否校验暂时不控制前端的逻辑
*/
private Boolean enable = ENABLE_DEFAULT;
/**
* 验证码的过期时间
*/

View File

@ -133,9 +133,13 @@ public class SysAuthServiceImpl implements SysAuthService {
}
private void verifyCaptcha(String username, String captchaUUID, String captchaCode) {
// 如果验证码关闭则不进行校验
if (!captchaService.isCaptchaEnable()) {
return;
}
// 验证码不存在
final SysLoginLogTypeEnum logTypeEnum = SysLoginLogTypeEnum.LOGIN_USERNAME;
String code = captchaService.getCaptchaCode(captchaUUID);
// 验证码不存在
if (code == null) {
// 创建登录失败日志验证码不存在
this.createLoginLog(username, logTypeEnum, SysLoginResultEnum.CAPTCHA_NOT_FOUND);

View File

@ -14,6 +14,13 @@ public interface SysCaptchaService {
*/
SysCaptchaImageRespVO getCaptchaImage();
/**
* 是否开启图片验证码
*
* @return 是否
*/
Boolean isCaptchaEnable();
/**
* 获得 uuid 对应的验证码
*

View File

@ -35,6 +35,11 @@ public class SysCaptchaServiceImpl implements SysCaptchaService {
return SysCaptchaConvert.INSTANCE.convert(uuid, captcha);
}
@Override
public Boolean isCaptchaEnable() {
return captchaProperties.getEnable();
}
@Override
public String getCaptchaCode(String uuid) {
return captchaRedisDAO.get(uuid);

View File

@ -166,6 +166,8 @@ logging:
# 芋道配置项,设置当前项目所有自定义的配置
yudao:
captcha:
enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试
security:
token-header: Authorization
token-secret: abcdefghijklmnopqrstuvwxyz

View File

@ -73,5 +73,7 @@ yudao:
constants-class-list:
- cn.iocoder.yudao.adminserver.modules.infra.enums.InfErrorCodeConstants
- cn.iocoder.yudao.adminserver.modules.system.enums.SysErrorCodeConstants
tenant:
tables: sys_user
debug: false

View File

@ -17,6 +17,7 @@ import cn.iocoder.yudao.coreservice.modules.system.service.user.SysUserCoreServi
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.test.core.util.AssertUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
@ -71,6 +72,11 @@ public class SysAuthServiceImplTest extends BaseDbUnitTest {
@MockBean
private SysPostService postService;
@BeforeEach
public void setUp() {
when(captchaService.isCaptchaEnable()).thenReturn(true);
}
@Test
public void testLoadUserByUsername_success() {
// 准备参数

View File

@ -21,6 +21,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import org.junit.jupiter.api.Test;
import org.mockito.stubbing.Answer;
import org.springframework.boot.test.mock.mockito.MockBean;

View File

@ -287,6 +287,7 @@ CREATE TABLE IF NOT EXISTS "sys_user" (
"updater" varchar(64) default '',
"update_time" timestamp not null default current_timestamp,
"deleted" bit not null default false,
"tenant_id" bigint not null default '0',
primary key ("id")
) comment '用户信息表';

View File

@ -91,6 +91,11 @@
</dependency>
<!-- 工具类相关 -->
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-spring-boot-starter-tenant</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>

View File

@ -2,8 +2,8 @@ package cn.iocoder.yudao.coreservice.modules.system.dal.dataobject.user;
import cn.iocoder.yudao.coreservice.modules.system.enums.common.SysSexEnum;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.mybatis.core.type.JsonLongSetTypeHandler;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@ -24,7 +24,7 @@ import java.util.Set;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SysUserDO extends BaseDO {
public class SysUserDO extends TenantBaseDO {
/**
* 用户ID

View File

@ -48,6 +48,7 @@
<velocity.version>2.2</velocity.version>
<screw.version>1.0.5</screw.version>
<guava.version>30.1.1-jre</guava.version>
<transmittable-thread-local.version>2.12.2</transmittable-thread-local.version>
<!-- 三方云服务相关 -->
<aliyun-java-sdk-core.version>4.5.25</aliyun-java-sdk-core.version>
<aliyun-java-sdk-dysmsapi.version>2.1.0</aliyun-java-sdk-dysmsapi.version>
@ -350,6 +351,12 @@
<version>${revision}</version>
</dependency>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-spring-boot-starter-tenant</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@ -402,6 +409,12 @@
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId> <!-- 解决 ThreadLocal 父子线程的传值问题 -->
<version>${transmittable-thread-local.version}</version>
</dependency>
<!-- 三方云服务相关 -->
<!-- SMS SDK begin -->

View File

@ -32,6 +32,7 @@
<module>yudao-spring-boot-starter-biz-pay</module>
<module>yudao-spring-boot-starter-biz-weixin</module>
<module>yudao-spring-boot-starter-extension</module>
<module>yudao-spring-boot-starter-tenant</module>
</modules>
<artifactId>yudao-framework</artifactId>

View File

@ -122,6 +122,17 @@
<artifactId>jakarta.validation-api</artifactId>
<scope>provided</scope> <!-- 设置为 provided主要是 PageParam 使用到 -->
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -17,9 +17,11 @@ public interface WebFilterOrderEnum {
// OrderedRequestContextFilter 默认为 -105用于国际化上下文等等
int API_ACCESS_LOG_FILTER = -104; // 需要保证在 RequestBodyCacheFilter
int TENANT_FILTER = - 100; // 需要保证在 ApiAccessLogFilter
int XSS_FILTER = -103; // 需要保证在 RequestBodyCacheFilter 后面
int API_ACCESS_LOG_FILTER = -90; // 需要保证在 RequestBodyCacheFilter 后面
int XSS_FILTER = -80; // 需要保证在 RequestBodyCacheFilter 后面
// Spring Security Filter 默认为 -100可见 SecurityProperties 配置属性类

View File

@ -10,9 +10,11 @@ import java.util.Date;
/**
* 基础实体对象
*
* @author 芋道源码
*/
@Data
public class BaseDO implements Serializable {
public abstract class BaseDO implements Serializable {
/**
* 创建时间

View File

@ -4,9 +4,13 @@ import cn.hutool.core.collection.CollectionUtil;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.SortingField;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
@ -30,4 +34,17 @@ public class MyBatisUtils {
return page;
}
/**
* 将拦截器添加到链中
* 由于 MybatisPlusInterceptor 不支持添加拦截器所以只能全量设置
*
* @param interceptor
* @param inner 拦截器
*/
public static void addInterceptor(MybatisPlusInterceptor interceptor, InnerInterceptor inner) {
List<InnerInterceptor> inners = new ArrayList<>(interceptor.getInterceptors());
inners.add(0, inner);
interceptor.setInterceptors(inners);
}
}

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>yudao-framework</artifactId>
<groupId>cn.iocoder.boot</groupId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>yudao-spring-boot-starter-tenant</artifactId>
<packaging>jar</packaging>
<name>${artifactId}</name>
<description>多租户</description>
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<dependencies>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-common</artifactId>
</dependency>
<!-- Web 相关 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- DB 相关 -->
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-spring-boot-starter-mybatis</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,25 @@
package cn.iocoder.yudao.framework.tenant.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.Set;
/**
* 多租户配置
*
* @author 芋道源码
*/
@ConfigurationProperties(prefix = "yudao.tenant")
@Data
public class TenantProperties {
/**
* 需要多租户的表
*
* 由于多租户并不作为 yudao 项目的重点功能更多是扩展性的功能所以采用正向配置需要多租户的表
* 如果需要你可以改成 ignoreTables 来取消部分不需要的表
*/
private Set<String> tables;
}

View File

@ -0,0 +1,43 @@
package cn.iocoder.yudao.framework.tenant.config;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
/**
* 多租户针对 DB 的自动配置
*
* @author 芋道源码
*/
@EnableConfigurationProperties(TenantProperties.class)
public class YudaoTenantDatabaseAutoConfiguration {
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties) {
return new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
}
@Bean
public BeanPostProcessor mybatisPlusInterceptorBeanPostProcessor(TenantLineInnerInterceptor tenantLineInnerInterceptor) {
return new BeanPostProcessor() {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (!(bean instanceof MybatisPlusInterceptor)) {
return bean;
}
// TenantDatabaseInterceptor 添加到最前面
MybatisPlusInterceptor interceptor = (MybatisPlusInterceptor) bean;
MyBatisUtils.addInterceptor(interceptor, tenantLineInnerInterceptor);
return bean;
}
};
}
}

View File

@ -0,0 +1,23 @@
package cn.iocoder.yudao.framework.tenant.config;
import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum;
import cn.iocoder.yudao.framework.tenant.core.web.TenantWebFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
/**
* 多租户针对 Web 的自动配置
*
* @author 芋道源码
*/
public class YudaoTenantWebAutoConfiguration {
@Bean
public FilterRegistrationBean<TenantWebFilter> tenantWebFilter() {
FilterRegistrationBean<TenantWebFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new TenantWebFilter());
registrationBean.setOrder(WebFilterOrderEnum.TENANT_FILTER);
return registrationBean;
}
}

View File

@ -0,0 +1,26 @@
package cn.iocoder.yudao.framework.tenant.core.context;
import com.alibaba.ttl.TransmittableThreadLocal;
/**
* 多租户上下文 Holder
*
* @author 芋道源码
*/
public class TenantContextHolder {
private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();
public static Long getTenantId() {
return TENANT_ID.get();
}
public static void setTenantId(Long tenantId) {
TENANT_ID.set(tenantId);
}
public static void clear() {
TENANT_ID.remove();
}
}

View File

@ -0,0 +1,21 @@
package cn.iocoder.yudao.framework.tenant.core.db;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 拓展多租户的 BaseDO 基类
*
* @author 芋道源码
*/
@Data
@EqualsAndHashCode(callSuper = true)
public abstract class TenantBaseDO extends BaseDO {
/**
* 多租户编号
*/
private Long tenantId;
}

View File

@ -0,0 +1,33 @@
package cn.iocoder.yudao.framework.tenant.core.db;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.tenant.config.TenantProperties;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import lombok.AllArgsConstructor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.StringValue;
/**
* 基于 MyBatis Plus 多租户的功能实现 DB 层面的多租户的功能
*
* @author 芋道源码
*/
@AllArgsConstructor
public class TenantDatabaseInterceptor implements TenantLineHandler {
private final TenantProperties properties;
@Override
public Expression getTenantId() {
// TODO 芋艿暂时不考虑获取不到的情况此时会存在 NPE 的报错
return new StringValue(TenantContextHolder.getTenantId().toString());
}
@Override
public boolean ignoreTable(String tableName) {
// 不包含说明要过滤
return !CollUtil.contains(properties.getTables(), tableName);
}
}

View File

@ -0,0 +1,39 @@
package cn.iocoder.yudao.framework.tenant.core.web;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 多租户 Web 过滤器
* 将请求 Header 中的 tenant-id 解析出来添加到 {@link TenantContextHolder} 这样后续的 DB 等操作可以获得到租户编号
*
* @author 芋道源码
*/
public class TenantWebFilter extends OncePerRequestFilter {
private static final String HEADER_TENANT_ID = "tenant-id";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 设置
String tenantId = request.getHeader(HEADER_TENANT_ID);
if (StrUtil.isNotEmpty(tenantId)) {
TenantContextHolder.setTenantId(Long.valueOf(tenantId));
}
try {
chain.doFilter(request, response);
} finally {
// 清理
TenantContextHolder.clear();
}
}
}

View File

@ -0,0 +1,8 @@
/**
* 多租户支持如下层面
* 1. DB基于 MyBatis Plus 多租户的功能实现
* 2. JobTODO
* 3. MQTODO
* 4. WebTODO
*/
package cn.iocoder.yudao.framework.tenant;

View File

@ -0,0 +1,3 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
cn.iocoder.yudao.framework.tenant.config.YudaoTenantDatabaseAutoConfiguration,\
cn.iocoder.yudao.framework.tenant.config.YudaoTenantWebAutoConfiguration

View File

@ -41,8 +41,8 @@ public class SysUserProfileController {
@PutMapping("/update-nickname")
@ApiOperation("修改用户昵称")
@PreAuthenticated
public CommonResult<Boolean> updateNickname(@RequestParam("nickName") String nickName) {
userService.updateNickname(getLoginUserId(), nickName);
public CommonResult<Boolean> updateNickname(@RequestParam("nickname") String nickname) {
userService.updateNickname(getLoginUserId(), nickname);
return success(true);
}

View File

@ -51,9 +51,9 @@ public interface MbrUserService {
/**
* 修改用户昵称
* @param userId 用户id
* @param nickName 用户新昵称
* @param nickname 用户新昵称
*/
void updateNickname(Long userId, String nickName);
void updateNickname(Long userId, String nickname);
/**
* 修改用户头像

View File

@ -86,15 +86,15 @@ public class MbrUserServiceImpl implements MbrUserService {
}
@Override
public void updateNickname(Long userId, String nickName) {
public void updateNickname(Long userId, String nickname) {
MbrUserDO user = this.checkUserExists(userId);
// 仅当新昵称不等于旧昵称时进行修改
if (nickName.equals(user.getNickname())){
if (nickname.equals(user.getNickname())){
return;
}
MbrUserDO userDO = new MbrUserDO();
userDO.setId(user.getId());
userDO.setNickname(nickName);
userDO.setNickname(nickname);
userMapper.updateById(userDO);
}

View File

@ -6,4 +6,18 @@ export function getUserInfo() {
url: 'member/user/profile/get',
method: 'get'
})
}
// 修改
export function updateNickname(nickname) {
return request({
url: 'member/user/profile/update-nickname',
method: 'post',
header: {
"Content-Type": "application/x-www-form-urlencoded"
},
data: {
nickname
}
})
}

View File

@ -12,6 +12,7 @@ export const request = (options) => {
method: options.method || 'GET',
data: options.data || {},
header: {
...options.header,
'Authorization': authToken ? `Bearer ${authToken}` : ''
}
}).then(res => {

View File

@ -20,8 +20,20 @@
<text class="tit fill">昵称</text>
<input class="input" v-model="userInfo.nickname" type="text" maxlength="8" placeholder="请输入昵称" placeholder-class="placeholder">
</view>
<u-cell-group>
<u-cell title="昵称" :value="userInfo.nickname" isLink @click="nicknameClick()"></u-cell>
</u-cell-group>
<u-modal :show="nicknameOpen" title="修改昵称" showCancelButton @confirm="nicknameSubmit" @cancel="nicknameCancel">
<view class="slot-content">
<u--form labelPosition="left" :model="nicknameForm" :rules="nicknameRules" ref="nicknameForm" errorType="toast">
<u-form-item prop="nickname">
<u--input v-model="nicknameForm.nickname" placeholder="请输入昵称" border="none"></u--input>
</u-form-item>
</u--form>
</view>
</u-modal>
<mix-button ref="confirmBtn" text="保存资料" marginTop="80rpx" @onConfirm="confirm"></mix-button>
</view>
</template>
@ -32,6 +44,16 @@
uploadProgress: 100, //
tempAvatar: '',
userInfo: {},
nicknameOpen: false,
nicknameForm: {
nickname: ''
},
nicknameRules: {
nickname: [{
required: true,
message: '请输入昵称'
}]
}
}
},
computed: {
@ -50,6 +72,30 @@
this.userInfo = {avatar, nickname, gender};
},
methods: {
nicknameClick() {
this.nicknameOpen = true;
this.nicknameForm.nickname = this.userInfo.nickname;
},
nicknameCancel() {
this.nicknameOpen = false;
},
nicknameSubmit() {
this.$refs.nicknameForm.validate().then(() => {
this.loading = true;
//
const { mobile, code, password} = this.form;
const loginPromise = this.loginType == 'password' ? login(mobile, password) :
smsLogin(mobile, code);
loginPromise.then(data => {
//
this.loginSuccessCallBack(data);
}).catch(errors => {
}).finally(() => {
this.loading = false;
})
}).catch(errors => {
});
},
//
async confirm() {
//