多租户改造实施方案文档（AIDriveEEForAudio）
1. 背景与目标
当前现状
技术栈：Spring Boot 3.5 + MyBatis-Plus + MyBatis XML + MySQL。
ORM：@TableName + BaseMapper 为主，XML 主要用于 resultMap 与列列表。
现有系统未引入任何 租户字段/多租户插件/租户上下文，为 单租户系统。
改造目标
在不大幅重构现有业务的前提下，引入稳定可扩展的 多租户能力，实现不同租户数据的逻辑隔离；
对现有 CRUD 影响尽量小，大部分业务代码可复用；
为后续扩展租户级限流、权限、配置等能力奠定基础。
2. 多租户模式与设计原则
2.1 多租户模式选择
本项目推荐采用：
模式：单库单 schema + 每张业务表增加 tenant_id 字段 + MyBatis-Plus 多租户插件自动拼接租户条件。
理由：
不需要为每个租户单独建库或动态数据源，改造成本可控；
利用 MP 官方 TenantLineInnerInterceptor，绝大多数查询/更新可自动加上 tenant_id 条件；
便于后续做运营侧的跨租户统计（可选择性绕过多租户拦截器）。
2.2 租户边界与业务含义
推荐按 企业 / 经销商 / 机构 维度作为租户边界，例如：
一个 4S 店/经销商/医院/公司 = 一个租户；
一个租户下包含多个用户、销售、设备、项目等。
租户维度对象：
用户（user）归属一个租户；
门店/项目/客户/音频记录等业务实体，均挂在某个 tenant_id 下。
3. 数据库层改造方案
3.1 新增租户表
新表：tenant
字段建议：
id：主键（BIGINT 或 VARCHAR(36)，与 Java 实现保持一致）
tenant_code：租户编码（如公司英文缩写、唯一）
tenant_name：租户名称
status：状态（启用/禁用）
create_time / update_time：创建与更新时间
其他扩展字段：联系人、电话、过期时间

CREATE TABLE `tenant` (
    `id`           VARCHAR(36)   NOT NULL COMMENT '租户主键ID',
    `tenant_code`  VARCHAR(64)   NOT NULL COMMENT '租户编码，唯一',
    `tenant_name`  VARCHAR(128)  NOT NULL COMMENT '租户名称',
    `status`       TINYINT       NOT NULL DEFAULT 1 COMMENT '状态：1-启用，0-禁用',
    `contact_name` VARCHAR(64)            DEFAULT NULL COMMENT '联系人姓名',
    `contact_phone`VARCHAR(32)            DEFAULT NULL COMMENT '联系人电话',
    `expire_time`  DATETIME               DEFAULT NULL COMMENT '租户过期时间',
    `create_time`  DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time`  DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_tenant_code` (`tenant_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表';



3.2 业务表增加 tenant_id 字段
需挂租户的表（示例，实际以 SQL 目录为准扩展）：
音频类：
audio_management、audio_segment、audio_management_statistics、audio_text_analysis_furniture、audio_text_analysis_sop、video_image_analysis、video_management、video_synthesis_log 等。
客户与销售类：
customer_management、customer_profile_analysis、communication_record、sales_management 等。
门店与项目类：
dealership、project_management。
日志与其他：
yhy_audio_upload_log、heartbeat_log、tts_request_log、face_detect_log 等业务相关日志。
用户与权限：
user、user_role、role 等（至少 user 需有 tenant_id 字段）。
字段规范建议：
字段名统一：tenant_id；
字段类型：与 tenant.id 保持一致（推荐 BIGINT 或 VARCHAR(36)）；
约束：
第一阶段：允许 NULL（兼容旧数据）；
数据迁移完成后：设置为 NOT NULL；
索引：
大表添加复合索引：
如：customer_management 增加 INDEX idx_cm_tenant_dealership (tenant_id, dealership_id)
如：audio_management 增加 INDEX idx_am_tenant_ctime (tenant_id, create_time)

-- ======================
-- 音频类
-- ======================
ALTER TABLE `audio_management`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_am_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `audio_segment`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_as_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `audio_management_statistics`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_ams_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `audio_text_analysis_furniture`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_ataf_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `audio_text_analysis_sop`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_atas_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `video_image_analysis`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_via_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `video_management`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_vm_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `video_synthesis_log`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_vsl_tenant_ctime` (`tenant_id`, `create_time`);


-- ======================
-- 客户与销售类
-- ======================
ALTER TABLE `customer_management`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_cm_tenant_dealership` (`tenant_id`, `dealership_id`);

ALTER TABLE `customer_profile_analysis`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_cpa_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `communication_record`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_cr_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `sales_management`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_sm_tenant_sales` (`tenant_id`, `sales_id`);

ALTER TABLE `device_management`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_dm_tenant_device_management` (`tenant_id`, `id`);



-- ======================
-- 门店与项目类
-- ======================
ALTER TABLE `dealership`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_dealer_tenant` (`tenant_id`);

ALTER TABLE `project_management`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_pm_tenant_ctime` (`tenant_id`, `create_time`);


-- ======================
-- 日志与其他
-- ======================
ALTER TABLE `yhy_audio_upload_log`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_yhy_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `heartbeat_log`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_hb_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `tts_request_log`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_tts_tenant_ctime` (`tenant_id`, `create_time`);

ALTER TABLE `face_detect_log`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_fdl_tenant_ctime` (`tenant_id`, `create_time`);


-- ======================
-- 用户与权限
-- ======================
ALTER TABLE `user`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_user_tenant` (`tenant_id`);

ALTER TABLE `user_role`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_ur_tenant_role` (`tenant_id`, `role_id`);

ALTER TABLE `role`
    ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
    ADD INDEX `idx_role_tenant` (`tenant_id`);



3.3 历史数据迁移策略
插入一个默认租户，如：DEFAULT_TENANT：
INSERT INTO tenant (id, tenant_code, tenant_name, status, ...) VALUES (...);
对所有新增了 tenant_id 的表执行数据补齐：
UPDATE 表名 SET tenant_id = 'DEFAULT_TENANT_ID' WHERE tenant_id IS NULL;
需要细分历史数据进入多个租户时，可后续再做拆分迁移脚本。
-- 1. 插入默认租户（示例）
INSERT INTO `tenant` (
    `id`,
    `tenant_code`,
    `tenant_name`,
    `status`,
    `contact_name`,
    `contact_phone`,
    `expire_time`,
    `create_time`,
    `update_time`
) VALUES (
    'TENANT_ID_CST_2026',           -- 建议改成真实的 UUID 或雪花ID
    '北京移多旺科技有限公司',
    '默认租户',
    1,
    NULL,
    NULL,
    NULL,
    NOW(),
    NOW()
);


-- 2. 为所有新增了 tenant_id 的表补齐历史数据
-- 音频类
UPDATE `audio_management`            SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `audio_segment`               SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `audio_management_statistics` SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `audio_text_analysis_furniture` SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `audio_text_analysis_sop`     SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `video_image_analysis`        SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `video_management`            SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `video_synthesis_log`         SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;

-- 客户与销售类
UPDATE `customer_management`         SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `customer_profile_analysis`   SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `communication_record`        SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `sales_management`            SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;

-- 门店与项目类
UPDATE `dealership`                  SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `project_management`          SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `device_management`          SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;

-- 日志与其他
UPDATE `yhy_audio_upload_log`        SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `heartbeat_log`               SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `tts_request_log`             SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `face_detect_log`             SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;

-- 用户与权限
UPDATE `user`                        SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `user_role`                   SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;
UPDATE `role`                        SET `tenant_id` = 'TENANT_ID_CST_2026' WHERE `tenant_id` IS NULL;





4. 应用层多租户实现方案
4.1 实体类增加 tenantId 字段
以 CustomerManagement 为例：
在 com.rj.entity.CustomerManagement 中增加：
@Schema(description = "租户ID")
@TableField("tenant_id")
private String tenantId;
@Schema(description = "租户ID")@TableField("tenant_id")private String tenantId;
对所有挂租户的实体（如 AudioManagement、Dealership、ProjectManagement、SalesManagement 等）增加同名字段，映射 tenant_id。
> 原则：和数据库字段一一对应，命名统一为 tenantId + @TableField("tenant_id")，方便在 MyBatis-Plus 插件中统一处理。


4.2 租户上下文设计（TenantContextHolder）
在 com.rj.common 或新包 com.rj.tenant 下新增 TenantContextHolder：
功能：
存放当前请求的租户 ID（基于 ThreadLocal）；
提供 set/get/clear 三个静态方法；
供：
TenantLineHandler 从中读取租户；
部分业务中获取当前租户信息。

public final class TenantContextHolder {

    private static final ThreadLocal<String> TENANT_ID_HOLDER = new ThreadLocal<>();

    private TenantContextHolder() {
        // 工具类，禁止实例化
    }

    /**
     * 设置当前线程的租户ID。
     *
     * @param tenantId 租户ID，允许为 null（表示无租户上下文）
     */
    public static void setTenantId(String tenantId) {
        TENANT_ID_HOLDER.set(tenantId);
    }

    /**
     * 获取当前线程的租户ID。
     *
     * @return 当前租户ID，可能为 null
     */
    public static String getTenantId() {
        return TENANT_ID_HOLDER.get();
    }

    /**
     * 清理当前线程保存的租户ID，防止线程复用导致的租户串用。
     */
    public static void clear() {
        TENANT_ID_HOLDER.remove();
    }
}


4.3 请求到租户的映射方式
推荐方案：HTTP Header 传递租户 ID。
前端在每次请求头中带上：X-Tenant-Id: <当前租户ID>；
该租户 ID 一般来自：
登录时从用户表查出的 tenant_id；
登录成功后前端缓存（或由 token 中解析得到）。
4.4 请求过滤器 / 拦截器
在 com.rj.config 新增 TenantFilter（或使用 Spring MVC HandlerInterceptor）：
获取请求头 X-Tenant-Id；
校验租户 ID 是否存在、是否合法（可选校验是否为启用状态）；
成功则 TenantContextHolder.setTenantId(tenantId)；
在 finally 阶段调用 TenantContextHolder.clear() 防止线程复用污染。
> 要求：任何 Mapper/Service 层执行 SQL 前，当前线程中必须已经设置好了租户 ID。

@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class TenantFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(TenantFilter.class);

    /**
     * Header 名称常量，便于前后端约定与维护
     */
    public static final String TENANT_HEADER = "X-Tenant-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String tenantId = request.getHeader(TENANT_HEADER);

        try {
            if (tenantId != null && !tenantId.isEmpty()) {
                TenantContextHolder.setTenantId(tenantId);
            } else {
                // 允许为 null 的情况：例如某些公共接口或尚未登录
                TenantContextHolder.setTenantId(null);
            }

            filterChain.doFilter(request, response);
        } finally {
            // 无论请求是否成功，都要清理线程变量
            TenantContextHolder.clear();
        }
    }
}




5. MyBatis-Plus 多租户插件集成
5.1 当前配置情况
com.rj.config.MybatisPlusConfig 中已有：
PaginationInnerInterceptor（分页插件）
OptimisticLockerInnerInterceptor（乐观锁）
暂未配置多租户拦截器。
5.2 引入 TenantLineInnerInterceptor
思路：在 MybatisPlusInterceptor 中加入一个 TenantLineInnerInterceptor，并实现 TenantLineHandler 接口，定义：
当前租户 ID 的获取方式；
租户字段名（统一为 tenant_id）；
哪些表不需要租户隔离。
配置要点：
添加顺序：一般先添加多租户拦截器，再添加分页拦截器；
在 ignoreTable 中配置哪些表不参与多租户，例如：
tenant（租户表自身）
公共字典表/行业标签表等（如认为是全局共享）。
> 效果：所有走 MyBatis-Plus 的查询/更新语句，在生成 SQL 时自动追加 tenant_id = 当前租户 条件，实现物理数据隔离。

    /**
     * 多租户处理器：定义如何获取租户ID、租户字段名以及忽略的表
     */
    @Bean
    public TenantLineHandler tenantLineHandler() {
        // 需要忽略多租户的表（不自动拼接 tenant_id）
        Set<String> ignoreTables = new HashSet<>();
        ignoreTables.add("tenant");          // 租户表本身
        ignoreTables.add("industry_tags");   // 行业标签（示例：如认为是公共字典）

        return new TenantLineHandler() {

            @Override
            public Expression getTenantId() {
                String tenantId = TenantContextHolder.getTenantId();
                // 没有租户ID时，返回 null 由 MyBatis-Plus 决定处理策略，通常为不过滤或抛错（视版本而定）
                return tenantId == null ? null : new StringValue(tenantId);
            }

            @Override
            public String getTenantIdColumn() {
                return "tenant_id";
            }

            @Override
            public boolean ignoreTable(String tableName) {
                // 忽略名单内的表不做租户隔离
                return ignoreTables.contains(tableName);
            }
        };
    }


5.3 XML / 自定义 SQL 兼容性说明
当前项目 XML 多为：
resultMap；
Base_Column_List 等列定义。
这些 XML 并不影响多租户插件的生效，插件对最终 SQL 生效。
需关注的场景：
若存在大量手写复杂 SQL（join、多表统计等）：
插件会尝试对所有包含 tenant_id 字段的表自动加条件；
对于需要跨租户查询的统计/后台管理接口，可通过：
使用 MyBatis-Plus 提供的注解（如 @InterceptorIgnore(tenantLine = "true") 或对应版本配置）在 Mapper 方法上跳过租户拦截；
或单独定义不走多租户的 Mapper/方法，仅用于运营后台。






6. 数据写入与审计机制
6.1 新增数据的 tenant_id 自动填充
新增数据（INSERT）时需明确写入当前 tenant_id。可选方案：
在各业务 Service 中，创建实体时手动设置：
entity.setTenantId(TenantContextHolder.getTenantId());
使用 MyBatis-Plus MetaObjectHandler：
在 insertFill 中统一为 tenantId 字段填充当前租户，避免业务层遗漏。
推荐方案：使用 MetaObjectHandler + 在特殊场景手动覆盖。




6.2 更新数据的安全性
一般不允许修改 tenant_id 字段；
更新语句只在 WHERE 条件中带上 tenant_id = 当前租户，确保：
不会更新到其他租户的数据；
逻辑删除/批量更新等操作同样受租户约束。
/**
 * MyBatis-Plus 公共字段自动填充处理器
 *
 * <p>
 * 仅负责在插入时为带有 tenantId 字段的实体自动填充当前租户ID，<br/>
 * 更新时不修改 tenantId，确保租户ID一旦写入不可被业务层随意修改。
 * </p>
 */
@Component
public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {

    private static final String TENANT_FIELD = "tenantId";

    @Override
    public void insertFill(MetaObject metaObject) {
        // 仅在实体中存在 tenantId 字段且当前值为空时进行填充
        if (metaObject.hasSetter(TENANT_FIELD)) {
            Object currentValue = getFieldValByName(TENANT_FIELD, metaObject);
            if (currentValue == null) {
                String tenantId = TenantContextHolder.getTenantId();
                if (tenantId != null) {
                    this.strictInsertFill(metaObject, TENANT_FIELD, String.class, tenantId);
                }
            }
        }
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        // 不自动更新 tenantId，保持租户ID稳定
    }
}



6.3 审计字段补充（可选）
在已有 create_time / update_time 基础上扩展：
create_by / update_by（记录用户 ID）；
与租户联动后，便于定位“哪个租户、哪个用户、何时”操作了数据。




7. 登录认证与租户绑定
7.1 用户表改造
在 user 表和对应实体中新增 tenant_id 字段；
模型设计：
简单模式：一个用户只属于一个租户（适合大多数 B 端场景）；
复杂模式：一个账号可归属多个租户，则需 user_tenant 关联表（如后期有此需求可扩展）。


7.2 登录流程中的租户处理
登录接口逻辑：
用户名密码校验通过后，查询用户的 tenant_id；
在返回的 token（JWT 或自定义）中写入 tenantId 声明；
前端在发起业务请求时：
将 token 放入 Authorization；
将 tenantId 放入 X-Tenant-Id（可到期从 token 中解析填充）。
服务端过滤器逻辑：
从 token 与 Header 中解析并对比 tenantId，保持一致性；
决定本次请求的租户上下文（设置 TenantContextHolder）。
// TODO 
        当前已经按你文档里的登录多租户方案，把后端改造完成：
        登录返回携带租户 ID：在 LoginResponse 中新增 tenantId 字段，并在 UserServiceImpl.login 
        里从 user.getTenantId() 赋值，前端拿到后即可将其放入 X-Tenant-Id。
        通过 token 绑定租户：TenantFilter 现在会从 Authorization 头中解析 token（支持 Bearer xxx 或直接 token），
        调用 IUserService.getUserByToken 查出 User，得到 tokenTenantId。
        Header 与 token 的一致性与上下文设置：
        同时有 token 和 X-Tenant-Id 且不一致时会打出告警日志；
        优先使用 token 中的租户 ID，其次才用 X-Tenant-Id，然后调用 
        TenantContextHolder.setTenantId(finalTenantId)，在整个请求周期内为 MyBatis 多租户拦截器提供正确租户上下文。






8. 外部依赖与多租户适配（可选/后续）
项目中已使用：
Redis（会话、缓存、RAG 等）
MinIO（音频/图片等文件存储）
建议逐步考虑多租户隔离策略：
8.1 Redis Key 命名规范
为避免不同租户共享同一 Key 带来数据串扰：
统一增加租户前缀：<tenantId>:<业务Key>；
封装统一的 Redis 工具类，使调用侧只传业务 Key，由工具类自动添加租户前缀；
对于确实需要跨租户的缓存（全局配置等），使用特殊前缀如 global:。
8.2 MinIO 存储路径策略
建议在对象路径或 bucket 中加入租户维度：
对象前缀方式：tenant-<id>/audio/...；
或 bucket 方式：audio-tenant-<id>；
将路径构造封装在 MinIOUtil 或相关 service 中，避免业务代码直接拼接。
9. 实施步骤（落地路线）
9.1 设计与评审阶段
明确业务侧租户定义（按公司/门店/项目等）；
确认需要隔离的表列表及需共享的公共表；
设计 tenant 表结构以及 tenant_id 字段规范（类型、索引策略）。
9.2 数据库改造
编写 SQL 脚本：
创建 tenant 表；
为业务表增加 tenant_id 字段及索引；
在测试环境执行、验证 SQL；
插入默认租户，并为所有历史数据补齐 tenant_id。
9.3 应用代码第一阶段改造
为所有挂租户实体类添加 tenantId 字段及 @TableField("tenant_id")；
新增 TenantContextHolder；
编写 TenantFilter/HandlerInterceptor，从请求头或 token 中解析租户，并设置/清理上下文；
在 MybatisPlusConfig 中注册 TenantLineInnerInterceptor 与 TenantLineHandler，配置忽略表列表；
（可选）实现 MetaObjectHandler 自动填充 tenantId。
9.4 应用代码第二阶段改造与测试
排查：
是否有直接使用 JdbcTemplate 或原生 SQL 的地方，手动加 tenant_id 条件；
需跨租户查询的运营接口，为 Mapper 方法配置忽略多租户拦截。
改造登录模块：
用户实体和数据库增加 tenant_id；
登录成功后，将 tenantId 写入 token，并在前端请求中携带。
编写联调/集成测试：
准备两个租户数据，验证：
A 租户的用户只看到 A 的数据；
A、B 租户数据在分页、统计接口中互不干扰。
9.5 外部服务与性能调优（可并行/后续）
优化 Redis / MinIO 的多租户隔离（Key/路径规范）；
对热点业务表基于 tenant_id 索引进行慢 SQL 分析与优化；
监控系统表现，如单库压力过高，后续可考虑按租户/时间进行分库分表。
10. 总结
本方案以 “租户字段 + 请求租户上下文 + MyBatis-Plus 多租户插件” 为核心，对现有项目做 最小侵入 的多租户改造；
改造重点集中在：
数据库新增 tenant_id 字段及索引；
实体类字段同步；
引入租户上下文、过滤器以及 MP 多租户插件；
登录与用户表中引入租户绑定。
在此基础上，后续可以平滑扩展：
租户级配置中心；
租户级限流、配额；
租户级报表与运营管理。
如果你需要，我可以基于本方案，进一步输出一份 具体到文件名/类名/方法名的“改造清单 + 示例代码片段”，方便你直接在 IDE 中按步骤实施。