在业务系统中,控制用户对数据的访问权限是非常重要的。不同用户角色需要根据其关联的维度(如国家、地区等)来访问特定的数据。为实现这一功能,我们可以通过自定义注解和 MyBatis 拦截器来动态拼接 SQL 语句,将维度过滤逻辑灵活地应用于数据库查询。
本篇文章将详细展示如何通过 Spring Boot、MyBatis、自定义注解等技术,实现通用的维度过滤器,并结合用户、角色和维度表的设计,来动态控制数据访问权限。
1. 项目环境
使用以下技术栈:
- Spring Boot 2.x
- MyBatis
- Maven
- JDK 8+
2. 数据库表设计
为了实现用户、角色与维度之间的关联,我们设计了四个表:
users
表:存储用户基本信息。roles
表:存储角色信息。dimensions
表:存储维度信息,如国家、地区等。user_role_dimension
表:绑定用户、角色和维度的关系。
2.1 users
表
存储用户的基本信息,如用户名和密码。
CREATE TABLE `users` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(255) NOT NULL,
`password` VARCHAR(255) NOT NULL
);
2.2 roles
表
存储系统中的角色信息,如管理员(ADMIN
)、客户(CUSTOMER
)等。
CREATE TABLE `roles` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`role_name` VARCHAR(50) NOT NULL
);
2.3 dimensions
表
维度表用于存储可供过滤的数据维度,例如国家、地区、组织等。
CREATE TABLE `dimensions` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`dimension_key` VARCHAR(255) NOT NULL, -- 维度的键,例如 country、region
`dimension_value` VARCHAR(255) NOT NULL -- 维度的值,例如 CN、US、EU 等
);
2.4 user_role_dimension
表
这个表用于将用户、角色和维度进行关联。每个用户可以通过角色与维度关联,从而确定该用户在特定角色下能够访问的数据范围。
CREATE TABLE `user_role_dimension` (
`user_id` BIGINT NOT NULL,
`role_id` BIGINT NOT NULL,
`dimension_id` BIGINT NOT NULL,
PRIMARY KEY (`user_id`, `role_id`, `dimension_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`),
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`),
FOREIGN KEY (`dimension_id`) REFERENCES `dimensions`(`id`)
);
3. 功能目标
通过自定义注解 @DimensionFilter
,在 MyBatis 的 Mapper 方法中动态添加维度过滤逻辑,过滤维度值由用户在当前角色下的维度关系决定。拦截器会在查询时根据用户、角色和维度表中的关联关系拼接 SQL 语句。
4. Maven 依赖
首先,配置项目的 Maven 依赖,确保 Spring Boot、MyBatis 和 Lombok 的使用。
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis Starter -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
5. 自定义注解 @DimensionFilter
首先定义一个注解 @DimensionFilter
,用于在 MyBatis 的查询方法上标注需要过滤的维度字段。
package com.example.ecommerce.annotation;
import java.lang.annotation.*;
/**
* 用于在 Mapper 查询方法中动态添加维度过滤条件。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DimensionFilter {
/**
* 指定需要过滤的维度列名,例如 country、region 等。
*/
String column();
}
注解的 column
参数表示 SQL 查询中需要过滤的字段,例如 country
或 region
。
6. 用户上下文 UserContext
为了在查询时能够动态获取当前用户的角色和维度信息,我们可以使用 ThreadLocal
来存储当前请求的用户上下文。
package com.example.ecommerce.util;
public class UserContext {
private static final ThreadLocal<Long> userId = new ThreadLocal<>();
private static final ThreadLocal<Long> roleId = new ThreadLocal<>();
public static void setUserId(Long id) {
userId.set(id);
}
public static Long getUserId() {
return userId.get();
}
public static void setRoleId(Long id) {
roleId.set(id);
}
public static Long getRoleId() {
return roleId.get();
}
public static void clear() {
userId.remove();
roleId.remove();
}
}
在实际场景中,UserContext
可以在用户登录或请求开始时设置,确保每个请求有其独立的用户和角色上下文。
7. 查询用户角色维度关系
我们通过 user_role_dimension
表查找当前用户和角色绑定的维度,并将这些维度值应用于 SQL 查询。
import com.example.ecommerce.model.Dimension;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface UserRoleDimensionMapper {
// 查询当前用户在指定角色下的维度
@Select("SELECT d.dimension_value FROM dimensions d " +
"JOIN user_role_dimension urd ON d.id = urd.dimension_id " +
"WHERE urd.user_id = #{userId} AND urd.role_id = #{roleId}")
List<String> findUserDimensions(Long userId, Long roleId);
}
8. 实现 MyBatis 拦截器
MyBatis 提供了拦截器(Interceptor
)接口,允许我们在 SQL 执行之前或之后对 SQL 进行动态修改。这里我们使用拦截器在执行 SQL 之前,拼接维度过滤条件。
package com.example.ecommerce.interceptor;
import com.example.ecommerce.annotation.DimensionFilter;
import com.example.ecommerce.util.UserContext;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.util.List;
import java.util.Properties;
/**
* MyBatis 拦截器,动态拼接维度过滤条件。
*/
@Component
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class DimensionInterceptor implements Interceptor {
@Autowired
private UserRoleDimensionMapper userRoleDimensionMapper;
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();
String originalSql = boundSql.getSql();
// 获取 Mapper 方法上的注解
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
DimensionFilter dimensionFilter = getDimensionFilter(mappedStatement);
// 如果注解存在,动态添加维度过滤条件
if (dimensionFilter != null) {
Long userId = UserContext.getUserId();
Long roleId = UserContext.getRoleId();
// 查询当前用户和角色的维度
List<String> dimensionValues = userRoleDimensionMapper.findUserDimensions(userId, roleId);
if (!dimensionValues.isEmpty()) {
// 动态拼接 WHERE 条件
String dimensionClause = dimensionFilter.column() + " IN (" +
String.join(",", dimensionValues.stream().map(v -> "'" + v + "'").toArray(String[]::new)) + ")";
String newSql = originalSql + " WHERE " + dimensionClause;
setFieldValue(boundSql, "sql", newSql);
}
}
return invocation.proceed();
}
private DimensionFilter getDimensionFilter(MappedStatement mappedStatement) {
try {
// 通过反射获取 Mapper 方法上的注解
Class<?> clazz = Class.forName(mappedStatement.getId().substring(0, mappedStatement.getId().lastIndexOf(".")));
String methodName = mappedStatement.getId().substring(mappedStatement.getId().lastIndexOf(".") + 1);
for (Method method : clazz.getDeclaredMethods()) {
if (method.getName().equals(methodName)) {
return method.getAnnotation(DimensionFilter.class);
}
}
} catch (Exception e) {
// ignore
}
return null;
}
private void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
}
9. 使用示例
假设我们有一个 ProductMapper
,通过 @DimensionFilter
注解指定需要过滤的维度字段。
import com.example.ecommerce.annotation.DimensionFilter;
import com.example.ecommerce.model.Product;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface ProductMapper {
@DimensionFilter(column = "country")
@Select("SELECT * FROM products")
List<Product> findProducts();
}
在实际运行时,根据用户的上下文,MyBatis 拦截器将动态为查询语句添加维度过滤条件,例如:
SELECT * FROM products WHERE country IN ('CN', 'US');
结论
通过上述方案,结合 MyBatis 拦截器、自定义注解和用户角色维度表,我们可以实现基于维度的动态数据过滤。这个设计既保证了代码的可维护性,也让系统对不同角色、维度的访问控制更加灵活。