Files
urbanLifeline/.kiro/specs/urbanlifeline-to-pigx-migration/tenant-isolation-guide.md

505 lines
15 KiB
Markdown
Raw Normal View History

2026-01-14 15:42:26 +08:00
# 租户字段添加指南
## 概述
pigx 是一个多租户 SaaS 平台所有业务数据需要通过租户IDtenant_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索引
- [ ] 测试租户隔离功能
- [ ] 测试跨租户查询权限
- [ ] 文档更新和团队培训