# 租户字段添加指南

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