Files
urbanLifeline/.kiro/specs/urbanlifeline-to-pigx-migration/tenant-isolation-guide.md
2026-01-14 15:42:26 +08:00

505 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 租户字段添加指南
## 概述
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索引
- [ ] 测试租户隔离功能
- [ ] 测试跨租户查询权限
- [ ] 文档更新和团队培训