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

15 KiB
Raw Blame History

租户字段添加指南

概述

pigx 是一个多租户 SaaS 平台所有业务数据需要通过租户IDtenant_id进行隔离。本指南详细说明如何为所有业务表添加租户字段并实现租户隔离。

1. 租户字段规范

1.1 字段定义

`tenant_id` bigint NOT NULL DEFAULT 1 COMMENT '租户ID'

1.2 字段特性

  • 类型BIGINT
  • 非空NOT NULL
  • 默认值1默认租户
  • 索引:建议添加索引以提高查询性能
  • 注释'租户ID'

2. 为现有表添加租户字段

2.1 通用 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 批量添加脚本

-- 工单模块
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 实体类模板

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 自动填充配置

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 租户拦截器配置

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 查询时自动添加租户条件

@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

@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 跨租户查询(管理员功能)

@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 定时任务中的租户处理

@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 异步任务中的租户传递

@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

-- 为历史数据设置默认租户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 根据部门映射租户

-- 如果有部门与租户的映射关系
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 租户隔离测试

@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 验证

-- 验证所有表都有租户字段
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 调试建议

# 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索引
  • 测试租户隔离功能
  • 测试跨租户查询权限
  • 文档更新和团队培训