You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring MVC Java CRM系统多数据库登录实现方案求助

Spring Boot + Spring Security 多租户(分公司独立数据库)登录实现方案

核心思路

通过租户元数据库存储所有公司的数据库连接信息,用户登录时先从元数据库获取对应公司的数据源配置,动态切换数据源后再验证用户身份,实现每个公司独立数据库的登录逻辑。


1. 定义租户数据源实体与元数据库

首先需要一个单独的元数据库,用来存储所有公司的数据库连接信息:

元数据库表结构

CREATE TABLE tenant_data_sources (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    company_id VARCHAR(50) UNIQUE NOT NULL COMMENT '公司唯一标识',
    db_url VARCHAR(255) NOT NULL COMMENT '数据库连接地址',
    db_username VARCHAR(50) NOT NULL COMMENT '数据库用户名',
    db_password VARCHAR(255) NOT NULL COMMENT '数据库密码',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
);

租户数据源实体类

@Entity
@Table(name = "tenant_data_sources")
public class TenantDataSource {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "company_id", unique = true, nullable = false)
    private String companyId;
    @Column(name = "db_url", nullable = false)
    private String dbUrl;
    @Column(name = "db_username", nullable = false)
    private String dbUsername;
    @Column(name = "db_password", nullable = false)
    private String dbPassword;
    // 省略getter、setter
}

元数据库Repository

public interface TenantDataSourceRepository extends JpaRepository<TenantDataSource, Long> {
    TenantDataSource findByCompanyId(String companyId);
}

2. 实现动态数据源切换

继承Spring的AbstractRoutingDataSource,通过ThreadLocal存储当前租户标识,实现动态路由到对应公司的数据库:

public class DynamicDataSource extends AbstractRoutingDataSource {
    // 存储已加载的租户数据源,避免重复创建
    private final Map<Object, Object> targetDataSources = new ConcurrentHashMap<>();
    // 当前租户标识线程变量
    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static void setCurrentTenant(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static void clearCurrentTenant() {
        CURRENT_TENANT.remove();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return CURRENT_TENANT.get();
    }

    // 动态添加租户数据源
    public void addTargetDataSource(String tenantId, DataSource dataSource) {
        targetDataSources.put(tenantId, dataSource);
        super.setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();
    }
}

动态数据源配置类

@Configuration
public class DataSourceConfig {
    // 元数据库配置(application.yml中配置)
    @Value("${spring.datasource.meta.url}")
    private String metaDbUrl;
    @Value("${spring.datasource.meta.username}")
    private String metaDbUsername;
    @Value("${spring.datasource.meta.password}")
    private String metaDbPassword;

    // 元数据库数据源
    @Bean
    public DataSource metaDataSource() {
        return DataSourceBuilder.create()
                .url(metaDbUrl)
                .username(metaDbUsername)
                .password(metaDbPassword)
                .driverClassName("com.mysql.cj.jdbc.Driver")
                .build();
    }

    // 动态数据源(作为默认数据源)
    @Bean
    public DynamicDataSource dynamicDataSource() {
        DynamicDataSource dynamicDS = new DynamicDataSource();
        // 设置默认数据源为元数据库(可选,避免无租户时报错)
        dynamicDS.setDefaultTargetDataSource(metaDataSource());
        return dynamicDS;
    }

    // 配置JdbcTemplate使用动态数据源
    @Bean
    public JdbcTemplate jdbcTemplate() {
        return new JdbcTemplate(dynamicDataSource());
    }
}

3. 自定义UserDetailsService实现多租户登录验证

重写UserDetailsService,在用户登录时解析租户标识,切换到对应数据库验证用户:

@Service
public class TenantUserDetailsService implements UserDetailsService {
    @Autowired
    private TenantDataSourceRepository tenantRepo;
    @Autowired
    private DynamicDataSource dynamicDataSource;
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String loginIdentifier) throws UsernameNotFoundException {
        // 假设登录标识格式为「companyId:username」,也可通过请求参数单独传递companyId
        String[] parts = loginIdentifier.split(":");
        if (parts.length != 2) {
            throw new UsernameNotFoundException("登录格式错误,请输入「公司ID:用户名」");
        }
        String companyId = parts[0];
        String username = parts[1];

        // 从元数据库获取公司数据源配置
        TenantDataSource tenantDS = tenantRepo.findByCompanyId(companyId);
        if (tenantDS == null) {
            throw new UsernameNotFoundException("公司不存在");
        }

        // 复用已加载的数据源,不存在则创建
        if (!dynamicDataSource.getTargetDataSources().containsKey(companyId)) {
            DataSource companyDS = DataSourceBuilder.create()
                    .url(tenantDS.getDbUrl())
                    .username(tenantDS.getDbUsername())
                    .password(tenantDS.getDbPassword())
                    .driverClassName("com.mysql.cj.jdbc.Driver")
                    .build();
            dynamicDataSource.addTargetDataSource(companyId, companyDS);
        }

        // 切换到当前租户的数据源
        DynamicDataSource.setCurrentTenant(companyId);

        // 从公司数据库查询用户信息(这里用JdbcTemplate,也可替换为JPA)
        JdbcTemplate jdbcTemplate = new JdbcTemplate((DataSource) dynamicDataSource.getTargetDataSources().get(companyId));
        String userSql = "SELECT username, password, enabled, role FROM users WHERE username = ?";
        try {
            return jdbcTemplate.queryForObject(userSql, new Object[]{username}, (rs, rowNum) ->
                    User.withUsername(rs.getString("username"))
                            .password(rs.getString("password"))
                            .disabled(!rs.getBoolean("enabled"))
                            .authorities(rs.getString("role"))
                            .passwordEncoder(passwordEncoder::matches)
                            .build()
            );
        } catch (EmptyResultDataAccessException e) {
            throw new UsernameNotFoundException("用户不存在");
        }
    }
}

4. Spring Security配置调整

配置自定义的UserDetailsService,并处理登录/登出逻辑:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private TenantUserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/login").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .usernameParameter("loginIdentifier") // 对应前端登录输入框的name属性
                        .defaultSuccessUrl("/dashboard")
                        .failureUrl("/login?error")
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/login")
                        .addLogoutHandler((request, response, authentication) -> {
                            // 登出时清除租户标识
                            DynamicDataSource.clearCurrentTenant();
                        })
                )
                .userDetailsService(userDetailsService);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

5. 请求结束后清除租户标识

添加过滤器,在请求完成后清除ThreadLocal中的租户标识,避免内存泄漏:

@Component
public class TenantClearFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } finally {
            DynamicDataSource.clearCurrentTenant();
        }
    }
}

关键注意事项

  • 数据源缓存:DynamicDataSource中维护的targetDataSources会缓存已加载的数据源,避免每次登录重复创建连接池。
  • ThreadLocal清理:必须在请求结束后清除租户标识,否则会导致线程复用时代码逻辑错误。
  • 异常处理:需针对租户不存在、数据库连接失败、用户不存在等场景添加友好的错误提示。
  • JPA适配:如果使用JPA,需要配置动态EntityManagerFactory,或者为每个租户单独创建EntityManager,相比JdbcTemplate复杂度更高,建议登录验证阶段先用JdbcTemplate实现。

内容的提问来源于stack exchange,提问作者user20432214

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.13 16:01:11