505 lines
15 KiB
Markdown
505 lines
15 KiB
Markdown
# 租户字段添加指南
|
||
|
||
## 概述
|
||
pigx 是一个多租户 SaaS 平台,所有业务数据需要通过租户ID(tenant_id)进行隔离。本指南详细说明如何为所有业务表添加租户字段并实现租户隔离。
|
||
|
||
## 1. 租户字段规范
|
||
|
||
### 1.1 字段定义
|
||
```sql
|
||
`tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID'
|
||
```
|
||
|
||
### 1.2 字段特性
|
||
- **类型**:BIGINT
|
||
- **非空**:NOT NULL
|
||
- **默认值**:1(默认租户)
|
||
- **索引**:建议添加索引以提高查询性能
|
||
- **注释**:'租户ID'
|
||
|
||
## 2. 为现有表添加租户字段
|
||
|
||
### 2.1 通用 SQL 模板
|
||
```sql
|
||
-- 添加租户字段
|
||
ALTER TABLE `表名`
|
||
ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID'
|
||
AFTER `某个字段`;
|
||
|
||
-- 添加索引
|
||
ALTER TABLE `表名`
|
||
ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
-- 如果需要复合索引(常用查询条件+租户)
|
||
ALTER TABLE `表名`
|
||
ADD INDEX `idx_status_tenant` (`status`, `tenant_id`);
|
||
```
|
||
|
||
### 2.2 批量添加脚本
|
||
```sql
|
||
-- 工单模块
|
||
ALTER TABLE `tb_workcase` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_workcase` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_workcase_process` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_workcase_process` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_workcase_device` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_workcase_device` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_chat_room` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_chat_room` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_chat_room_member` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_chat_room_member` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_chat_room_message` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_chat_room_message` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
-- AI模块
|
||
ALTER TABLE `tb_agent` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_agent` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_chat` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_chat` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_chat_message` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_chat_message` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_knowledge` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_knowledge` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
-- 招标模块
|
||
ALTER TABLE `tb_bidding_project` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_bidding_project` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_bidding_document` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_bidding_document` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
-- 消息模块
|
||
ALTER TABLE `tb_message` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_message` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_message_range` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_message_range` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
|
||
ALTER TABLE `tb_message_receiver` ADD COLUMN `tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID';
|
||
ALTER TABLE `tb_message_receiver` ADD INDEX `idx_tenant_id` (`tenant_id`);
|
||
```
|
||
|
||
## 3. 实体类添加租户字段
|
||
|
||
### 3.1 实体类模板
|
||
```java
|
||
import com.baomidou.mybatisplus.annotation.TableField;
|
||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||
|
||
@Data
|
||
@TableName("tb_workcase")
|
||
public class TbWorkcase extends Model<TbWorkcase> {
|
||
|
||
// ... 其他字段 ...
|
||
|
||
/**
|
||
* 租户ID
|
||
*/
|
||
@TableField(fill = FieldFill.INSERT)
|
||
private Long tenantId;
|
||
}
|
||
```
|
||
|
||
### 3.2 MyBatis-Plus 自动填充配置
|
||
```java
|
||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||
import com.pig4cloud.pigx.common.security.util.SecurityUtils;
|
||
import org.apache.ibatis.reflection.MetaObject;
|
||
import org.springframework.stereotype.Component;
|
||
|
||
@Component
|
||
public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
|
||
|
||
@Override
|
||
public void insertFill(MetaObject metaObject) {
|
||
// 获取当前用户的租户ID
|
||
PigxUser user = SecurityUtils.getUser();
|
||
if (user != null) {
|
||
// 自动填充租户ID
|
||
this.strictInsertFill(metaObject, "tenantId", Long.class, user.getTenantId());
|
||
} else {
|
||
// 默认租户ID
|
||
this.strictInsertFill(metaObject, "tenantId", Long.class, 1L);
|
||
}
|
||
|
||
// 填充其他字段
|
||
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
|
||
this.strictInsertFill(metaObject, "createBy", String.class, user != null ? user.getUsername() : "system");
|
||
this.strictInsertFill(metaObject, "delFlag", String.class, "0");
|
||
}
|
||
|
||
@Override
|
||
public void updateFill(MetaObject metaObject) {
|
||
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
|
||
|
||
PigxUser user = SecurityUtils.getUser();
|
||
if (user != null) {
|
||
this.strictUpdateFill(metaObject, "updateBy", String.class, user.getUsername());
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 4. 配置租户拦截器
|
||
|
||
### 4.1 MyBatis-Plus 租户拦截器配置
|
||
```java
|
||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||
import com.pig4cloud.pigx.common.security.util.SecurityUtils;
|
||
import net.sf.jsqlparser.expression.Expression;
|
||
import net.sf.jsqlparser.expression.LongValue;
|
||
import net.sf.jsqlparser.schema.Column;
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.context.annotation.Configuration;
|
||
|
||
import java.util.Arrays;
|
||
import java.util.List;
|
||
|
||
@Configuration
|
||
public class MybatisPlusConfig {
|
||
|
||
@Bean
|
||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||
|
||
// 添加租户拦截器
|
||
interceptor.addInnerInterceptor(tenantLineInnerInterceptor());
|
||
|
||
// 添加分页拦截器等其他拦截器
|
||
// interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||
|
||
return interceptor;
|
||
}
|
||
|
||
private TenantLineInnerInterceptor tenantLineInnerInterceptor() {
|
||
return new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||
|
||
@Override
|
||
public Expression getTenantId() {
|
||
// 从当前用户获取租户ID
|
||
PigxUser user = SecurityUtils.getUser();
|
||
Long tenantId = user != null ? user.getTenantId() : 1L;
|
||
return new LongValue(tenantId);
|
||
}
|
||
|
||
@Override
|
||
public String getTenantIdColumn() {
|
||
// 租户ID字段名
|
||
return "tenant_id";
|
||
}
|
||
|
||
@Override
|
||
public boolean ignoreTable(String tableName) {
|
||
// 忽略不需要租户隔离的表
|
||
List<String> ignoreTables = Arrays.asList(
|
||
"sys_user", // 系统用户表
|
||
"sys_role", // 角色表
|
||
"sys_menu", // 菜单表
|
||
"sys_dict", // 字典表
|
||
"sys_log", // 日志表
|
||
"temp_user_mapping" // 临时映射表
|
||
);
|
||
return ignoreTables.contains(tableName);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
## 5. Service 层实现租户隔离
|
||
|
||
### 5.1 查询时自动添加租户条件
|
||
```java
|
||
@Service
|
||
public class WorkcaseServiceImpl extends ServiceImpl<WorkcaseMapper, TbWorkcase>
|
||
implements WorkcaseService {
|
||
|
||
@Override
|
||
public List<TbWorkcase> listByStatus(String status) {
|
||
// 租户拦截器会自动添加 tenant_id 条件
|
||
return this.list(new QueryWrapper<TbWorkcase>()
|
||
.eq("status", status));
|
||
// 实际SQL: SELECT * FROM tb_workcase WHERE status = ? AND tenant_id = ?
|
||
}
|
||
|
||
@Override
|
||
public TbWorkcase getById(String id) {
|
||
// 自动添加租户条件,确保不会查询到其他租户的数据
|
||
return super.getById(id);
|
||
// 实际SQL: SELECT * FROM tb_workcase WHERE id = ? AND tenant_id = ?
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.2 保存时自动设置租户ID
|
||
```java
|
||
@Service
|
||
public class WorkcaseServiceImpl extends ServiceImpl<WorkcaseMapper, TbWorkcase>
|
||
implements WorkcaseService {
|
||
|
||
@Override
|
||
@Transactional(rollbackFor = Exception.class)
|
||
public Boolean createWorkcase(TbWorkcase workcase) {
|
||
// 通过 MetaObjectHandler 自动填充 tenant_id
|
||
// 不需要手动设置
|
||
return this.save(workcase);
|
||
}
|
||
|
||
// 如果需要手动设置(不推荐)
|
||
@Override
|
||
public Boolean createWorkcaseManual(TbWorkcase workcase) {
|
||
PigxUser user = SecurityUtils.getUser();
|
||
workcase.setTenantId(user.getTenantId());
|
||
workcase.setCreateBy(user.getUsername());
|
||
workcase.setCreateTime(LocalDateTime.now());
|
||
return this.save(workcase);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 6. 特殊场景处理
|
||
|
||
### 6.1 跨租户查询(管理员功能)
|
||
```java
|
||
@Service
|
||
public class AdminService {
|
||
|
||
@Autowired
|
||
private WorkcaseMapper workcaseMapper;
|
||
|
||
/**
|
||
* 管理员查询所有租户的数据
|
||
* 需要特殊权限
|
||
*/
|
||
@PreAuthorize("@pms.hasPermission('admin_cross_tenant')")
|
||
public List<TbWorkcase> listAllTenants() {
|
||
// 使用 @InterceptorIgnore 注解忽略租户拦截
|
||
return workcaseMapper.selectAllWithoutTenant();
|
||
}
|
||
}
|
||
|
||
// Mapper 接口
|
||
@Mapper
|
||
public interface WorkcaseMapper extends BaseMapper<TbWorkcase> {
|
||
|
||
@InterceptorIgnore(tenantLine = "true")
|
||
@Select("SELECT * FROM tb_workcase")
|
||
List<TbWorkcase> selectAllWithoutTenant();
|
||
}
|
||
```
|
||
|
||
### 6.2 定时任务中的租户处理
|
||
```java
|
||
@Component
|
||
public class WorkcaseScheduledTask {
|
||
|
||
@Autowired
|
||
private WorkcaseService workcaseService;
|
||
|
||
/**
|
||
* 定时任务:为每个租户执行任务
|
||
*/
|
||
@Scheduled(cron = "0 0 2 * * ?")
|
||
public void processAllTenants() {
|
||
// 获取所有租户列表
|
||
List<Long> tenantIds = getTenantIds();
|
||
|
||
for (Long tenantId : tenantIds) {
|
||
// 设置当前租户上下文
|
||
TenantContextHolder.setTenantId(tenantId);
|
||
try {
|
||
// 执行业务逻辑
|
||
processForTenant(tenantId);
|
||
} finally {
|
||
// 清除租户上下文
|
||
TenantContextHolder.clear();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.3 异步任务中的租户传递
|
||
```java
|
||
@Service
|
||
public class AsyncService {
|
||
|
||
/**
|
||
* 异步任务需要传递租户ID
|
||
*/
|
||
@Async
|
||
public void processAsync(Long tenantId, String workcaseId) {
|
||
// 在异步线程中设置租户ID
|
||
TenantContextHolder.setTenantId(tenantId);
|
||
try {
|
||
// 执行业务逻辑
|
||
doProcess(workcaseId);
|
||
} finally {
|
||
TenantContextHolder.clear();
|
||
}
|
||
}
|
||
|
||
// 调用异步方法前获取租户ID
|
||
public void callAsync(String workcaseId) {
|
||
PigxUser user = SecurityUtils.getUser();
|
||
Long tenantId = user.getTenantId();
|
||
processAsync(tenantId, workcaseId);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 7. 数据迁移时的租户处理
|
||
|
||
### 7.1 历史数据添加租户ID
|
||
```sql
|
||
-- 为历史数据设置默认租户ID
|
||
UPDATE tb_workcase SET tenant_id = 1 WHERE tenant_id IS NULL;
|
||
UPDATE tb_chat_room SET tenant_id = 1 WHERE tenant_id IS NULL;
|
||
UPDATE tb_agent SET tenant_id = 1 WHERE tenant_id IS NULL;
|
||
```
|
||
|
||
### 7.2 根据部门映射租户
|
||
```sql
|
||
-- 如果有部门与租户的映射关系
|
||
UPDATE tb_workcase w
|
||
JOIN sys_dept d ON w.dept_id = d.dept_id
|
||
SET w.tenant_id = d.tenant_id
|
||
WHERE w.tenant_id = 1;
|
||
```
|
||
|
||
## 8. 测试验证
|
||
|
||
### 8.1 租户隔离测试
|
||
```java
|
||
@SpringBootTest
|
||
@RunWith(SpringRunner.class)
|
||
public class TenantIsolationTest {
|
||
|
||
@Autowired
|
||
private WorkcaseService workcaseService;
|
||
|
||
@Test
|
||
@WithMockUser(authorities = {"workcase_view"})
|
||
public void testTenantIsolation() {
|
||
// 模拟租户1的用户
|
||
mockTenant(1L);
|
||
List<TbWorkcase> tenant1List = workcaseService.list();
|
||
|
||
// 模拟租户2的用户
|
||
mockTenant(2L);
|
||
List<TbWorkcase> tenant2List = workcaseService.list();
|
||
|
||
// 验证数据隔离
|
||
assertNotEquals(tenant1List, tenant2List);
|
||
}
|
||
|
||
private void mockTenant(Long tenantId) {
|
||
PigxUser user = new PigxUser();
|
||
user.setTenantId(tenantId);
|
||
// 设置到安全上下文
|
||
SecurityContextHolder.getContext().setAuthentication(
|
||
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities())
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 8.2 SQL 验证
|
||
```sql
|
||
-- 验证所有表都有租户字段
|
||
SELECT
|
||
TABLE_NAME,
|
||
COLUMN_NAME
|
||
FROM INFORMATION_SCHEMA.COLUMNS
|
||
WHERE
|
||
TABLE_SCHEMA = 'your_database'
|
||
AND COLUMN_NAME = 'tenant_id'
|
||
ORDER BY TABLE_NAME;
|
||
|
||
-- 验证数据分布
|
||
SELECT
|
||
tenant_id,
|
||
COUNT(*) as record_count
|
||
FROM tb_workcase
|
||
GROUP BY tenant_id;
|
||
|
||
-- 验证索引
|
||
SHOW INDEX FROM tb_workcase WHERE Column_name = 'tenant_id';
|
||
```
|
||
|
||
## 9. 注意事项
|
||
|
||
### 9.1 性能考虑
|
||
- 租户ID字段必须建立索引
|
||
- 常用查询条件可以建立复合索引(如:status + tenant_id)
|
||
- 大表可以考虑按租户分区
|
||
|
||
### 9.2 安全考虑
|
||
- 确保租户拦截器正确配置
|
||
- 敏感操作需要记录日志
|
||
- 跨租户操作需要特殊权限
|
||
|
||
### 9.3 开发规范
|
||
- 不要在代码中硬编码租户ID
|
||
- 始终从 SecurityUtils 获取当前租户
|
||
- 使用 MyBatis-Plus 的自动填充功能
|
||
|
||
### 9.4 数据备份
|
||
- 迁移前备份原始数据
|
||
- 记录租户ID映射关系
|
||
- 保留回滚方案
|
||
|
||
## 10. 故障排查
|
||
|
||
### 10.1 常见问题
|
||
|
||
**问题1:查询不到数据**
|
||
- 检查租户ID是否正确
|
||
- 验证租户拦截器是否生效
|
||
- 查看生成的SQL是否包含tenant_id条件
|
||
|
||
**问题2:插入数据失败**
|
||
- 检查tenant_id字段是否为NOT NULL
|
||
- 验证自动填充是否配置
|
||
- 确认当前用户有租户信息
|
||
|
||
**问题3:跨租户数据泄露**
|
||
- 检查是否所有查询都经过租户拦截
|
||
- 验证忽略表配置是否正确
|
||
- 审查自定义SQL是否包含租户条件
|
||
|
||
### 10.2 调试建议
|
||
```yaml
|
||
# application.yml - 开启SQL日志
|
||
logging:
|
||
level:
|
||
com.pig4cloud.pigx.*.mapper: DEBUG
|
||
com.baomidou.mybatisplus: DEBUG
|
||
|
||
# 查看实际执行的SQL
|
||
mybatis-plus:
|
||
configuration:
|
||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||
```
|
||
|
||
## 11. 迁移检查清单
|
||
|
||
- [ ] 所有业务表添加 tenant_id 字段
|
||
- [ ] 所有实体类添加 tenantId 属性
|
||
- [ ] 配置 MyBatis-Plus 自动填充
|
||
- [ ] 配置租户拦截器
|
||
- [ ] 配置忽略表列表
|
||
- [ ] 历史数据设置默认租户ID
|
||
- [ ] 建立租户ID索引
|
||
- [ ] 测试租户隔离功能
|
||
- [ ] 测试跨租户查询权限
|
||
- [ ] 文档更新和团队培训 |