From 10eca3fb353a50abb35eec60231a0e2fe095afc4 Mon Sep 17 00:00:00 2001 From: zhangjf Date: Thu, 19 Feb 2026 18:10:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=A4=9A=E7=A7=9F?= =?UTF-8?q?=E6=88=B7=E6=9E=B6=E6=9E=84=E5=AE=8C=E6=95=B4=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 ### 1. 多租户核心组件 - TenantRoutingProperties: 租户路由配置属性 - TenantAwareLoadBalancer: 租户感知负载均衡器 - TenantLineHandlerImpl: MyBatis Plus 租户插件 - TenantIgnoreHelper: 忽略租户过滤工具类 - NacosMetadataConfig: Nacos 元数据自动注册 ### 2. Gateway 租户过滤器 - TenantGatewayFilter: 从 JWT 提取租户信息写入请求头 - 透传 X-Tenant-Id、X-Tenant-Group、X-User-Id、X-Username ### 3. 支持的部署模式 - 一库多租户(SaaS 模式): 通过 tenant_id 字段隔离 - 一库一租户(私有化): 独立服务实例和数据库 - 混合模式: VIP 租户专属实例 + 普通租户共享实例 ### 4. Nacos 3.0 适配 - 所有业务模块添加 username/password 认证配置 - 服务实例自动注册租户标签 ## 问题修复 - #8: FeignClient 硬编码 URL 导致 Nacos 服务发现失效 - #9: Nacos 3.0 客户端缺少 username/password 认证配置 - fund-exp expenseType 字段类型从 Integer 改为 Long ## 测试 - TenantAwareLoadBalancerTest: 负载均衡器单元测试 - 混合模式集成测试脚本 --- doc/多租户架构实现指南.md | 472 ++++++++++++ doc/多租户配置示例.md | 406 +++++++++++ doc/开发问题清单.md | 681 ++++++++++++++++++ fund-common/pom.xml | 28 + .../config/TenantRoutingProperties.java | 253 +++++++ .../loadbalancer/TenantAwareLoadBalancer.java | 182 +++++ .../mybatis/MybatisTenantAutoConfig.java | 67 ++ .../common/mybatis/TenantIgnoreHelper.java | 80 ++ .../common/mybatis/TenantLineHandlerImpl.java | 120 +++ .../common/nacos/NacosMetadataConfig.java | 68 ++ .../fundplatform/common/util/ExcelUtil.java | 108 +++ .../TenantAwareLoadBalancerTest.java | 129 ++++ fund-cust/src/main/resources/application.yml | 2 + .../exp/data/entity/FundExpense.java | 6 +- .../service/impl/FundExpenseServiceImpl.java | 85 ++- fund-exp/src/main/resources/application.yml | 2 + fund-file/src/main/resources/application.yml | 31 +- .../gateway/filter/TenantGatewayFilter.java | 145 ++++ fund-proj/src/main/resources/application.yml | 2 + .../src/main/resources/application.yml | 2 + .../src/main/resources/application.yml | 10 +- fund-req/src/main/resources/application.yml | 2 + fund-sys/src/main/resources/application.yml | 9 + scripts/test-tenant-mixed-mode.sh | 182 +++++ 24 files changed, 3064 insertions(+), 8 deletions(-) create mode 100644 doc/多租户架构实现指南.md create mode 100644 doc/多租户配置示例.md create mode 100644 doc/开发问题清单.md create mode 100644 fund-common/src/main/java/com/fundplatform/common/config/TenantRoutingProperties.java create mode 100644 fund-common/src/main/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancer.java create mode 100644 fund-common/src/main/java/com/fundplatform/common/mybatis/MybatisTenantAutoConfig.java create mode 100644 fund-common/src/main/java/com/fundplatform/common/mybatis/TenantIgnoreHelper.java create mode 100644 fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java create mode 100644 fund-common/src/main/java/com/fundplatform/common/nacos/NacosMetadataConfig.java create mode 100644 fund-common/src/main/java/com/fundplatform/common/util/ExcelUtil.java create mode 100644 fund-common/src/test/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancerTest.java create mode 100644 fund-gateway/src/main/java/com/fundplatform/gateway/filter/TenantGatewayFilter.java create mode 100755 scripts/test-tenant-mixed-mode.sh diff --git a/doc/多租户架构实现指南.md b/doc/多租户架构实现指南.md new file mode 100644 index 0000000..1f9cc56 --- /dev/null +++ b/doc/多租户架构实现指南.md @@ -0,0 +1,472 @@ +# 多租户架构实现指南 + +## 一、架构设计概述 + +资金服务平台采用**Feign 动态路由**方案实现多租户隔离,支持两种部署模式: + +### 1.1 多租户部署模式对比 + +| 特性 | 一库多租户(逻辑隔离) | 一库一租户(物理隔离) | +|------|---------------------|---------------------| +| **数据隔离** | tenant_id 字段区分 | 独立数据库 | +| **资源占用** | 低(共享服务实例) | 中(可独立部署) | +| **扩展性** | 一般 | 优秀(单租户可独立扩容) | +| **安全性** | 中 | 高 | +| **运维成本** | 低 | 中 | +| **适用场景** | 中小租户/SaaS | 大客户/私有化/金融政务 | + +### 1.2 核心能力 + +✅ **租户上下文传递**:通过 `TenantContextHolder` 在调用链中传递租户 ID +✅ **Feign 租户感知**:`FeignChainInterceptor` 自动透传租户信息 +✅ **租户负载均衡**:`TenantAwareLoadBalancer` 根据租户路由到对应实例 +✅ **Nacos 服务注册**:服务实例带租户标签注册 + +--- + +## 二、已实现的核心组件 + +### 2.1 租户上下文管理 + +**文件位置**:`fund-common/src/main/java/com/fundplatform/common/context/TenantContextHolder.java` + +```java +// 设置当前线程的租户 ID +TenantContextHolder.setTenantId(1L); + +// 获取当前线程的租户 ID +Long tenantId = TenantContextHolder.getTenantId(); + +// 清理(必须在 finally 中执行) +TenantContextHolder.clear(); +``` + +**使用场景**: +- Gateway 过滤器解析 Token 后设置 +- Feign 拦截器从请求头提取并设置 +- Service 层业务逻辑使用 + +### 2.2 Feign 租户信息透传 + +**文件位置**:`fund-common/src/main/java/com/fundplatform/common/feign/FeignChainInterceptor.java` + +**功能**: +- 自动将 `X-Tenant-Id` 传递到下游服务 +- 同时传递用户信息(`X-Uid`、`X-Uname`) +- 传递 TraceId 用于链路追踪 + +**示例**: +```java +@FeignClient(name = "fund-proj") +public interface ProjectFeignClient { + @GetMapping("/api/v1/proj/project/{id}") + Result getById(@PathVariable Long id); +} + +// 调用时自动携带租户信息 +projectFeignClient.getById(1L); +// ↓ 实际请求头包含:X-Tenant-Id: 1 +``` + +### 2.3 租户路由配置属性 + +**文件位置**:`fund-common/src/main/java/com/fundplatform/common/config/TenantRoutingProperties.java` + +**配置项**: +```yaml +tenant: + routing: + enabled: true # 是否启用租户路由 + tenant-header: X-Tenant-Id # 租户 ID 请求头 + tenant-group-header: X-Tenant-Group # 租户组请求头 + group-separator: TENANT_ # 租户组分隔符 + default-tenant-id: "1" # 默认租户 ID + shared-services: # 共享服务列表 + - fund-gateway + - fund-report + - fund-file + tenant-configs: # 租户专属配置 + tenant_001: + tenant-id: tenant_001 + services: + fund-sys: + service-name: fund-sys + port: 8100 + replicas: 2 + database: + url: jdbc:mysql://localhost:3306/tenant_001_db + username: tenant_001 + password: xxx +``` + +### 2.4 租户感知负载均衡器 + +**文件位置**:`fund-common/src/main/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancer.java` + +**工作原理**: +``` +1. 从请求头获取 X-Tenant-Id + ↓ +2. 构建租户组名称:TENANT_{tenantId} + ↓ +3. 过滤 Nacos 服务实例 + - 优先选择租户专属实例(metadata.tenant-group = TENANT_XXX) + - 回退到共享实例(无 tenant-group 标签) + ↓ +4. 轮询选择一个实例发起调用 +``` + +**服务实例元数据**: +```yaml +spring: + cloud: + nacos: + discovery: + metadata: + tenant-id: "1" + tenant-group: TENANT_1 +``` + +--- + +## 三、使用方式 + +### 3.1 一库多租户模式(推荐 SaaS) + +**特点**:所有租户共享服务实例,通过 tenant_id 区分数据 + +**配置步骤**: + +#### 1. 服务提供方配置 + +```yaml +# application.yml +spring: + cloud: + nacos: + discovery: + server-addr: localhost:8848 + username: nacos + password: nacos + # 不需要特殊配置,所有租户共用实例 +``` + +```java +// Service 层设置租户 ID 默认值 +@Service +public class ProjectServiceImpl implements ProjectService { + + @Autowired + private ProjectMapper projectMapper; + + @Override + public void saveProject(Project project) { + // 设置默认租户 ID(如果未提供) + if (project.getTenantId() == null) { + project.setTenantId(1L); // 或从 TenantContextHolder 获取 + } + + projectMapper.insert(project); + } + + @Override + public List listProjects() { + // 自动添加租户过滤条件 + Long tenantId = TenantContextHolder.getTenantId(); + return projectMapper.selectList(new LambdaQueryWrapper() + .eq(Project::getTenantId, tenantId)); + } +} +``` + +#### 2. 服务消费方配置 + +```yaml +# application.yml +tenant: + routing: + enabled: false # 一库多租户模式不需要启用租户路由 +``` + +### 3.2 一库一租户模式(推荐私有化) + +**特点**:每个租户有独立的服务实例和数据库 + +**配置步骤**: + +#### 1. 启用租户路由 + +```yaml +# application.yml(Gateway 或调用方服务) +tenant: + routing: + enabled: true + group-separator: TENANT_ + default-tenant-id: "1" + shared-services: + - fund-gateway + - fund-report + tenant-configs: + tenant_001: + tenant-id: tenant_001 + services: + fund-sys: + service-name: fund-sys + port: 8100 + replicas: 2 + database: + url: jdbc:mysql://db-tenant001.com:3306/tenant_001_db + username: t001 + password: xxx +``` + +#### 2. 服务实例带租户标签注册 + +```yaml +# application.yml(租户专属服务实例) +spring: + application: + name: fund-sys + cloud: + nacos: + discovery: + server-addr: localhost:8848 + username: nacos + password: nacos + metadata: + tenant-id: tenant_001 + tenant-group: TENANT_TENANT_001 +server: + port: 8100 +``` + +#### 3. 启动多个租户实例 + +```bash +# 租户 001 的系统服务 +java -jar fund-sys.jar \ + --spring.application.name=fund-sys \ + --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_001 \ + --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_001 \ + --server.port=8100 \ + --spring.datasource.url=jdbc:mysql://db-tenant001.com:3306/tenant_001_db + +# 租户 002 的系统服务 +java -jar fund-sys.jar \ + --spring.application.name=fund-sys \ + --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_002 \ + --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_002 \ + --server.port=8101 \ + --spring.datasource.url=jdbc:mysql://db-tenant002.com:3306/tenant_002_db +``` + +--- + +## 四、Gateway 租户路由配置 + +### 4.1 Gateway 全局过滤器 + +```java +@Component +public class TenantGatewayFilter implements GlobalFilter, Ordered { + + @Autowired + private JwtTokenProvider tokenProvider; + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + + // 1. 从 Token 中提取租户 ID + String token = getToken(request); + Claims claims = tokenProvider.parseToken(token); + String tenantId = claims.get("tenantId", String.class); + + // 2. 将租户 ID 写入请求头 + ServerHttpRequest mutatedRequest = request.mutate() + .header("X-Tenant-Id", tenantId) + .header("X-Tenant-Group", "TENANT_" + tenantId.toUpperCase()) + .build(); + + // 3. 继续执行 + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 1; // 在 JWT 认证之后 + } +} +``` + +### 4.2 Gateway 动态路由配置 + +```yaml +spring: + cloud: + gateway: + routes: + # 系统服务路由(租户感知) + - id: fund-sys + uri: lb://fund-sys # 使用负载均衡 + predicates: + - Path=/sys/** + filters: + - StripPrefix=1 + # 租户路由过滤器会自动添加 X-Tenant-Id 和 X-Tenant-Group 头 + + # 报表服务路由(共享服务) + - id: fund-report + uri: lb://fund-report + predicates: + - Path=/report/** + filters: + - StripPrefix=1 +``` + +--- + +## 五、最佳实践 + +### 5.1 租户 ID 设置规范 + +```java +// ✅ 正确:在 Service 层设置默认值 +@Service +public class UserServiceImpl implements UserService { + + @Override + public void saveUser(User user) { + if (user.getTenantId() == null) { + // 从上下文获取,或设置默认值 + user.setTenantId(TenantContextHolder.getTenantId()); + } + userMapper.insert(user); + } +} + +// ❌ 错误:依赖前端传递 +@Override +public void saveUser(User user) { + // 如果前端没传 tenantId,会报 NOT NULL 错误 + userMapper.insert(user); +} +``` + +### 5.2 租户数据隔离 + +```java +// MyBatis Plus 自动注入租户条件 +@Bean +public ISqlInjector sqlInjector() { + return new LogicSqlInjector(); +} + +// 或使用拦截器 +@Intercepts({ + @Select(type = Executor.class, method = "query", args = {...}) +}) +public class TenantInterceptor implements Interceptor { + + @Override + public Object intercept(Invocation invocation) throws Throwable { + // 自动添加 WHERE tenant_id = ? OR tenant_id = 0 + Long tenantId = TenantContextHolder.getTenantId(); + // ... 修改 SQL + return invocation.proceed(); + } +} +``` + +### 5.3 日志记录租户信息 + +```yaml +# logback-spring.xml +%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} + [TraceId:%X{traceId}, TenantId:%X{tenantId}] - %msg%n +``` + +输出示例: +``` +2026-02-18 10:30:00 [main] INFO c.f.s.service.UserService + [TraceId:abc123, TenantId:1] - User login: admin +``` + +--- + +## 六、监控与运维 + +### 6.1 Nacos 控制台查看租户实例 + +访问:http://localhost:8048/ + +路径:**服务管理 → 服务列表 → fund-sys → 实例列表** + +可以看到: +- 实例 IP 和端口 +- 元数据(tenant-id, tenant-group) +- 健康状态 + +### 6.2 查看租户调用链 + +通过 SkyWalking 或 ELK 查看: +``` +GET /api/v1/proj/project/1 +Headers: + X-Tenant-Id: 1 + X-Trace-Id: abc123 + +日志输出: +[TraceId:abc123, TenantId:1] Gateway → fund-sys → fund-proj +``` + +--- + +## 七、常见问题 + +### Q1: 什么时候需要启用租户路由? + +**答**: +- **一库多租户**:不需要启用(`tenant.routing.enabled=false`) +- **一库一租户**:需要启用(`tenant.routing.enabled=true`) + +### Q2: 如何判断服务是共享还是租户专属? + +**答**:通过 `tenant.routing.shared-services` 配置区分: +- 列表中的服务:所有租户共用 +- 不在列表中的服务:可以是租户专属 + +### Q3: 租户专属实例如何水平扩展? + +**答**:启动多个相同配置的实例即可: +```bash +# 租户 001 的 fund-sys 服务,启动 3 个实例 +for i in {8100..8102}; do + java -jar fund-sys.jar \ + --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_001 \ + --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_001 \ + --server.port=$i & +done +``` + +--- + +## 八、下一步计划 + +### 已完成 ✅ +- [x] TenantContextHolder(租户上下文) +- [x] FeignChainInterceptor(Feign 租户信息透传) +- [x] TenantRoutingProperties(租户路由配置) +- [x] TenantAwareLoadBalancer(租户感知负载均衡器) + +### 待实现 🚧 +- [ ] Gateway 租户全局过滤器 +- [ ] MyBatis Plus 租户自动注入 +- [ ] 租户服务实例管理界面 +- [ ] 租户级别的监控指标 + +--- + +**文档版本**: v1.0 +**最后更新**: 2026-02-18 +**维护者**: 架构团队 diff --git a/doc/多租户配置示例.md b/doc/多租户配置示例.md new file mode 100644 index 0000000..1893e6f --- /dev/null +++ b/doc/多租户配置示例.md @@ -0,0 +1,406 @@ +# 多租户配置示例 + +## 场景一:一库多租户(SaaS 模式) + +### Gateway 配置 + +```yaml +# fund-gateway/src/main/resources/application.yml +server: + port: 8000 + +spring: + application: + name: fund-gateway + + cloud: + nacos: + discovery: + server-addr: localhost:8848 + username: nacos + password: nacos + + gateway: + routes: + - id: fund-sys + uri: lb://fund-sys + predicates: + - Path=/sys/** + filters: + - StripPrefix=1 + + - id: fund-cust + uri: lb://fund-cust + predicates: + - Path=/cust/** + filters: + - StripPrefix=1 + +# 不需要启用租户路由,所有租户共享实例 +tenant: + routing: + enabled: false +``` + +### 业务服务配置(所有租户共用) + +```yaml +# fund-sys/src/main/resources/application.yml +server: + port: 8100 + +spring: + application: + name: fund-sys + + cloud: + nacos: + discovery: + server-addr: localhost:8848 + username: nacos + password: nacos + # 不需要特殊元数据配置 + + datasource: + url: jdbc:mysql://localhost:3306/fund_sys?useUnicode=true&characterEncoding=utf8 + username: root + password: ${DB_PASSWORD} + hikari: + maximum-pool-size: 20 + +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + +# 不启用租户路由 +tenant: + routing: + enabled: false +``` + +### Service 层租户 ID 处理 + +```java +@Service +public class UserServiceImpl implements UserService { + + @Autowired + private UserMapper userMapper; + + @Override + public void saveUser(User user) { + // 设置默认租户 ID(如果未提供) + if (user.getTenantId() == null) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = 1L; // 默认租户 + } + user.setTenantId(tenantId); + } + + userMapper.insert(user); + } + + @Override + public List listUsers() { + // 自动添加租户过滤条件 + Long tenantId = TenantContextHolder.getTenantId(); + return userMapper.selectList(new LambdaQueryWrapper() + .eq(User::getTenantId, tenantId)); + } +} +``` + +--- + +## 场景二:一库一租户(私有化部署) + +### Gateway 配置(支持租户路由) + +```yaml +# fund-gateway/src/main/resources/application.yml +server: + port: 8000 + +spring: + application: + name: fund-gateway + + cloud: + nacos: + discovery: + server-addr: localhost:8848 + username: nacos + password: nacos + + gateway: + routes: + - id: fund-sys + uri: lb://fund-sys + predicates: + - Path=/sys/** + filters: + - StripPrefix=1 + + # 报表服务是共享的 + - id: fund-report + uri: lb://fund-report + predicates: + - Path=/report/** + filters: + - StripPrefix=1 + +# 启用租户路由 +tenant: + routing: + enabled: true + tenant-header: X-Tenant-Id + tenant-group-header: X-Tenant-Group + group-separator: TENANT_ + default-tenant-id: "1" + + # 共享服务列表(所有租户共用) + shared-services: + - fund-gateway + - fund-report + - fund-file + + # 租户专属配置 + tenant-configs: + tenant_001: + tenant-id: tenant_001 + services: + fund-sys: + service-name: fund-sys + port: 8100 + replicas: 2 + fund-cust: + service-name: fund-cust + port: 8110 + replicas: 1 + database: + url: jdbc:mysql://db-tenant001.com:3306/tenant_001_db + username: t001 + password: ${DB_PASSWORD_TENANT001} + + tenant_002: + tenant-id: tenant_002 + services: + fund-sys: + service-name: fund-sys + port: 8101 + replicas: 1 + fund-cust: + service-name: fund-cust + port: 8111 + replicas: 1 + database: + url: jdbc:mysql://db-tenant002.com:3306/tenant_002_db + username: t002 + password: ${DB_PASSWORD_TENANT002} +``` + +### 租户专属服务实例配置 + +```yaml +# fund-sys 租户 001 实例配置 +# fund-sys-tenant001-instance1.yml +server: + port: 8100 + +spring: + application: + name: fund-sys + + cloud: + nacos: + discovery: + server-addr: localhost:8848 + username: nacos + password: nacos + metadata: + tenant-id: tenant_001 + tenant-group: TENANT_TENANT_001 + + datasource: + url: jdbc:mysql://db-tenant001.com:3306/tenant_001_db + username: t001 + password: ${DB_PASSWORD_TENANT001} + driver-class-name: com.mysql.cj.jdbc.Driver + +tenant: + routing: + enabled: true +``` + +```yaml +# fund-sys 租户 002 实例配置 +# fund-sys-tenant002-instance1.yml +server: + port: 8101 + +spring: + application: + name: fund-sys + + cloud: + nacos: + discovery: + server-addr: localhost:8848 + username: nacos + password: nacos + metadata: + tenant-id: tenant_002 + tenant-group: TENANT_TENANT_002 + + datasource: + url: jdbc:mysql://db-tenant002.com:3306/tenant_002_db + username: t002 + password: ${DB_PASSWORD_TENANT002} + driver-class-name: com.mysql.cj.jdbc.Driver + +tenant: + routing: + enabled: true +``` + +### 启动脚本示例 + +```bash +#!/bin/bash + +# 启动租户 001 的系统服务(2 个实例) +echo "Starting fund-sys for tenant_001 (instance 1)..." +nohup java -jar fund-sys.jar \ + --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_001 \ + --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_001 \ + --server.port=8100 \ + --spring.datasource.url=jdbc:mysql://db-tenant001.com:3306/tenant_001_db \ + > logs/fund-sys-tenant001-instance1.log 2>&1 & + +echo "Starting fund-sys for tenant_001 (instance 2)..." +nohup java -jar fund-sys.jar \ + --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_001 \ + --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_001 \ + --server.port=8101 \ + --spring.datasource.url=jdbc:mysql://db-tenant001.com:3306/tenant_001_db \ + > logs/fund-sys-tenant001-instance2.log 2>&1 & + +# 启动租户 002 的系统服务(1 个实例) +echo "Starting fund-sys for tenant_002 (instance 1)..." +nohup java -jar fund-sys.jar \ + --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_002 \ + --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_002 \ + --server.port=8102 \ + --spring.datasource.url=jdbc:mysql://db-tenant002.com:3306/tenant_002_db \ + > logs/fund-sys-tenant002-instance1.log 2>&1 & + +# 启动客户服务(共享服务,所有租户共用) +echo "Starting fund-cust (shared service)..." +nohup java -jar fund-cust.jar \ + --server.port=8110 \ + --spring.datasource.url=jdbc:mysql://localhost:3306/fund_cust \ + > logs/fund-cust-shared.log 2>&1 & + +echo "All services started!" +``` + +--- + +## 场景三:混合模式(部分租户独立部署) + +```yaml +# Gateway 配置 +tenant: + routing: + enabled: true + default-tenant-id: "1" + shared-services: + - fund-gateway + - fund-report + - fund-file + + # 只有大客户有独立部署 + tenant-configs: + # VIP 客户:独立部署 + vip_customer: + tenant-id: vip_customer + services: + fund-sys: + service-name: fund-sys + port: 8200 + replicas: 3 + fund-cust: + service-name: fund-cust + port: 8210 + replicas: 2 + database: + url: jdbc:mysql://vip-db.com:3306/vip_db + username: vip + password: xxx + + # 中小客户:共享部署(不在此配置,使用默认配置) +``` + +--- + +## 环境变量配置(生产环境推荐) + +```bash +# .env 文件 +# Nacos 配置 +NACOS_SERVER_ADDR=nacos.prod.com:8848 +NACOS_USERNAME=nacos +NACOS_PASSWORD=${NACOS_PROD_PASSWORD} + +# 数据库配置 +DB_HOST_PROD=mysql.prod.com +DB_PORT_PROD=3306 +DB_USERNAME_PROD=root +DB_PASSWORD_PROD=${DB_PROD_PASSWORD} + +# 租户配置 +TENANT_ROUTING_ENABLED=true +TENANT_DEFAULT_ID=1 + +# 租户 001 配置 +TENANT_001_ID=tenant_001 +TENANT_001_DB_HOST=db-tenant001.com +TENANT_001_DB_USERNAME=t001 +TENANT_001_DB_PASSWORD=${TENANT001_PASSWORD} + +# 租户 002 配置 +TENANT_002_ID=tenant_002 +TENANT_002_DB_HOST=db-tenant002.com +TENANT_002_DB_USERNAME=t002 +TENANT_002_DB_PASSWORD=${TENANT002_PASSWORD} +``` + +```yaml +# application-prod.yml +spring: + cloud: + nacos: + discovery: + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} + +tenant: + routing: + enabled: ${TENANT_ROUTING_ENABLED:false} + default-tenant-id: ${TENANT_DEFAULT_ID:1} + tenant-configs: + tenant_001: + tenant-id: ${TENANT_001_ID} + database: + url: jdbc:mysql://${TENANT_001_DB_HOST}:${DB_PORT_PROD}/tenant_001_db + username: ${TENANT_001_DB_USERNAME} + password: ${TENANT_001_DB_PASSWORD} +``` + +--- + +**文档版本**: v1.0 +**最后更新**: 2026-02-18 diff --git a/doc/开发问题清单.md b/doc/开发问题清单.md new file mode 100644 index 0000000..84dfb71 --- /dev/null +++ b/doc/开发问题清单.md @@ -0,0 +1,681 @@ +# 资金服务平台 - 开发问题清单 + +> **文档版本**: v1.0 +> **创建日期**: 2026-02-13 +> **记录时段**: 2026-02-12 09:00 ~ 2026-02-13 当前时间 + +--- + +## 问题清单概览 + +| 序号 | 问题分类 | 问题描述 | 严重程度 | 状态 | +|------|----------|----------|----------|------| +| 1 | 前后端接口 | /auth/info接口返回400 Bad Request | 高 | 已解决 | +| 2 | 前端路由 | 个人中心和系统设置菜单点击无反应 | 中 | 已解决 | +| 3 | 前端路由 | 刷新页面显示404 | 高 | 已解决 | +| 4 | 后端序列化 | AOP日志LocalDateTime序列化失败 | 中 | 已解决 | +| 5 | 后端接口 | 新增支出expenseType字段雪花ID超出Integer范围 | 高 | 已解决 | +| 6 | OpenFeign配置 | FeignChainInterceptor未注册为Spring Bean | 高 | 已解决 | +| 7 | 全链路追踪 | TraceContextHolder未在HTTP入口初始化导致链路断裂 | 高 | 已解决 | +| 8 | OpenFeign配置 | FeignClient硬编码URL导致Nacos服务发现失效 | 高 | 已解决 | +| 9 | Nacos 配置 | Nacos 3.0 客户端缺少 username/password 认证配置 | 高 | 已解决 | + +--- + +## 问题详情与解决方案 + +### 问题1:/auth/info接口返回400 Bad Request + +#### 问题现象 +``` +Request URL: http://localhost:3002/sys/api/v1/auth/info +Request Method: GET +Status Code: 400 Bad Request +``` + +用户登录成功后,调用 `/auth/info` 获取用户信息时返回400错误。 + +#### 问题原因 +后端接口需要 `X-User-Id` 请求头来识别当前用户,但前端未在请求中传递该header。 + +#### 解决方案 + +**1. 修改前端请求拦截器** (`fund-admin/src/api/request.ts`) + +```typescript +// 在请求头中添加 X-User-Id +request.headers['X-User-Id'] = localStorage.getItem('userId') || '' +``` + +**2. 修改用户登录逻辑** (`fund-admin/src/stores/user.ts`) + +```typescript +// 登录成功后保存 userId 和 tenantId 到 localStorage +localStorage.setItem('userId', String(res.data.userId)) +localStorage.setItem('tenantId', String(res.data.tenantId)) +``` + +#### 经验总结 +- 前后端接口对接时,需确认所有必需的请求头 +- 用户信息(userId、tenantId)应在登录成功后立即持久化存储 + +--- + +### 问题2:个人中心和系统设置菜单点击无反应 + +#### 问题现象 +点击顶部下拉菜单中的"个人中心"和"系统设置"选项后,页面无任何跳转反应。 + +#### 问题原因 +`MainLayout.vue` 中的 `handleCommand` 函数对 `profile` 和 `settings` 两个命令的处理为空实现。 + +```typescript +// 问题代码 +const handleCommand = async (command: string) => { + switch (command) { + case 'profile': + // 空实现,无任何代码 + break + case 'settings': + // 空实现,无任何代码 + break + case 'logout': + await userStore.logout() + router.push('/login') + break + } +} +``` + +#### 解决方案 + +**修改 `fund-admin/src/layouts/MainLayout.vue`** + +```typescript +const handleCommand = async (command: string) => { + switch (command) { + case 'profile': + router.push('/profile') + break + case 'settings': + router.push('/system/config') + break + case 'logout': + await userStore.logout() + router.push('/login') + break + } +} +``` + +同时在侧边栏菜单中添加"参数设置"入口。 + +#### 经验总结 +- 开发新功能时,需确保所有UI交互都有对应的业务实现 +- 代码提交前应进行基础功能测试 + +--- + +### 问题3:刷新页面显示404 + +#### 问题现象 +登录后进入业务页面,刷新浏览器后页面显示404: + +``` +Whitelabel Error Page +This application has no explicit mapping for /error, so you are seeing this as a fallback. +type=Not Found, status=404 +``` + +#### 问题原因 +Vite 开发服务器的代理配置中,`/sys` 路径模糊匹配了前端路由 `/system`,导致前端路由请求被错误代理到后端服务。 + +```typescript +// 问题配置 +proxy: { + '/sys': { // 会匹配 /sys 开头的所有路径,包括 /system + target: 'http://localhost:8100', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/sys/, '') + } +} +``` + +#### 解决方案 + +**修改 `fund-admin/vite.config.ts`** + +```typescript +proxy: { + '/sys/': { // 添加斜杠,精确匹配 /sys/ 开头的路径 + target: 'http://localhost:8100', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/sys/, '') + } +} +``` + +同时改进路由守卫逻辑,更好地处理页面刷新场景。 + +#### 经验总结 +- 代理路径配置应使用精确匹配,避免模糊匹配导致的路由冲突 +- 前端路由命名应与后端API路径有明显区分 + +--- + +### 问题4:AOP日志LocalDateTime序列化失败 + +#### 问题现象 +为 fund-sys 添加 AOP 日志功能后,日志输出时报错: + +``` +Java 8 date/time type 'java.time.LocalDateTime' not supported by default: +add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" +to enable handling +``` + +#### 问题原因 +Jackson 默认不支持 Java 8 的日期时间类型(LocalDateTime、LocalDate等)的序列化,需要额外配置。 + +#### 解决方案 + +**1. 添加 Maven 依赖** (`fund-sys/pom.xml`) + +```xml + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + +``` + +**2. 配置 ObjectMapper** (`ApiLogAspect.java`) + +```java +private static final ObjectMapper objectMapper = new ObjectMapper(); + +static { + // 注册JavaTimeModule以支持Java 8日期时间类型 + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); +} +``` + +**3. 序列化异常兜底处理** + +```java +try { + logInfo.put("responseBody", objectMapper.writeValueAsString(result)); +} catch (Exception e) { + // 序列化失败时使用toString()兜底 + logInfo.put("responseBody", result.toString()); +} +``` + +#### 经验总结 +- 使用 Jackson 序列化 Java 8 日期时间类型时,必须注册 `JavaTimeModule` +- 对于复杂的序列化场景,应添加异常兜底处理,避免日志功能影响业务 +- Spring Boot 项目使用 AOP 需引入 `spring-boot-starter-aop` 依赖 + +--- + +### 问题5:新增支出expenseType字段雪花ID超出Integer范围 + +#### 问题现象 +``` +POST http://localhost:8080/exp/api/v1/exp/expense +Status Code: 400 Bad Request +``` + +移动端新增支出时,请求返回 400 错误,无任何响应内容。 + +#### 问题原因 +后端 `FundExpenseDTO.expenseType` 字段类型为 `Integer`,最大值为 2147483647。而前端传递的是雪花ID(如 `2023686600025919489`),远超 Integer 范围,导致 JSON 解析失败。 + +#### 解决方案 + +**修改 `fund-exp/src/main/java/com/fundplatform/exp/dto/FundExpenseDTO.java`** + +```java +// 修改前 +@NotNull(message = "支出类型不能为空") +private Integer expenseType; + +public Integer getExpenseType() { return expenseType; } +public void setExpenseType(Integer expenseType) { this.expenseType = expenseType; } + +// 修改后 +@NotNull(message = "支出类型不能为空") +private Long expenseType; + +public Long getExpenseType() { return expenseType; } +public void setExpenseType(Long expenseType) { this.expenseType = expenseType; } +``` + +#### 经验总结 +- 使用雪花ID作为主键的实体,其外键字段类型必须使用 `Long` 而非 `Integer` +- Java Integer 范围:-2147483648 ~ 2147483647(约21亿) +- 雪花ID范围:通常为19位数字,远超 Integer 范围 + +--- + +### 问题6:FeignChainInterceptor未注册为Spring Bean + +#### 问题现象 +OpenFeign 调用下游服务时,租户ID、用户信息、TraceId 等上下文信息未透传,导致下游服务无法获取当前请求的租户和用户信息。 + +#### 问题原因 +`FeignChainInterceptor` 虽然实现了 `RequestInterceptor` 接口,但缺少 `@Component` 注解,未被 Spring 注册为 Bean,导致 OpenFeign 调用时拦截器不生效。 + +```java +// 问题代码 +public class FeignChainInterceptor implements RequestInterceptor { + // 没有 @Component 注解 +} +``` + +#### 解决方案 + +**1. 添加 Bean 注解** (`fund-common/src/main/java/com/fundplatform/common/feign/FeignChainInterceptor.java`) + +```java +@Component // 添加此注解 +public class FeignChainInterceptor implements RequestInterceptor { + // ... +} +``` + +**2. 配置组件扫描** (`fund-report/src/main/java/com/fundplatform/report/ReportApplication.java`) + +```java +@SpringBootApplication(scanBasePackages = {"com.fundplatform.report", "com.fundplatform.common"}) +@EnableDiscoveryClient +@EnableFeignClients +public class ReportApplication { + // ... +} +``` + +#### 经验总结 +- Spring 组件(Service、Component、Repository 等)必须添加相应注解才能被容器管理 +- 跨模块的组件需确保使用该组件的模块配置了正确的 `scanBasePackages` +- Feign 拦截器是实现微服务间上下文透传的关键,必须确保正确注册 + +--- + +### 问题 7:TraceContextHolder 未在 HTTP 入口初始化导致链路断裂 + +#### 问题现象 +Gateway 层已生成 TraceId 并写入请求头 `X-Trace-Id`,但业务服务中调用 `TraceContextHolder.getTraceId()` 返回 null,Feign 调用时会重新生成新的 TraceId,导致全链路追踪断裂。 + +#### 问题原因 +`ContextInterceptor` 只提取了租户 ID 和用户 ID,未从请求头中提取 Gateway 传递的 TraceId 并设置到 `TraceContextHolder`,导致: +1. 业务逻辑无法获取 Gateway 生成的 TraceId +2. Feign 调用时通过 `getOrCreateTraceId()` 生成新的 TraceId +3. 日志系统中出现多个不同的 TraceId,无法串联完整调用链 + +```java +// 问题代码 - ContextInterceptor.preHandle +// 缺少 TraceId 的提取逻辑 +public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 只设置了 TenantId 和 UserId + TenantContextHolder.setTenantId(tenantId); + UserContextHolder.setUserId(userId); + // 缺失:TraceContextHolder.setTraceId(traceId); + return true; +} +``` + +#### 解决方案 + +**修改 `fund-common/src/main/java/com/fundplatform/common/web/ContextInterceptor.java`** + +1. 添加导入和常量定义 +```java +import com.fundplatform.common.context.TraceContextHolder; + +public static final String HEADER_TRACE_ID = "X-Trace-Id"; +``` + +2. 在 preHandle 中提取 TraceId +```java +// 提取 TraceId(如不存在,后续会在需要时自动生成) +String traceId = request.getHeader(HEADER_TRACE_ID); +if (traceId != null && !traceId.isEmpty()) { + TraceContextHolder.setTraceId(traceId); +} +``` + +3. 在 afterCompletion 中清理 TraceContextHolder +```java +@Override +public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + TenantContextHolder.clear(); + UserContextHolder.clear(); + TraceContextHolder.clear(); // 新增 +} +``` + +#### 修复后的完整链路 + +``` +Gateway (GlobalLogFilter) + ↓ 生成 TraceId: abc123 并写入 X-Trace-Id 请求头 +业务服务 A (ContextInterceptor.preHandle) + ↓ 读取 X-Trace-Id: abc123 并设置到 TraceContextHolder +业务逻辑 A + ↓ 使用 TraceContextHolder.getTraceId() → abc123 +FeignChainInterceptor.apply + ↓ 从 TraceContextHolder 获取 abc123 并透传到下游 +业务服务 B (ContextInterceptor.preHandle) + ↓ 读取 X-Trace-Id: abc123 并设置到 TraceContextHolder +业务逻辑 B + ↓ 使用 TraceContextHolder.getTraceId() → abc123 +afterCompletion + ↓ 清理所有 ThreadLocal +``` + +#### 经验总结 +- Gateway 生成的 TraceId 必须在业务服务入口处提取并设置到 ThreadLocal +- Feign 拦截器应从 ThreadLocal 获取 TraceId 并透传,而不是重新生成 +- 所有 ThreadLocal 使用后必须在 finally 或 afterCompletion 中清理,防止内存泄漏 +- 全链路追踪需要每个环节都正确传递 TraceId,任何一环断裂都会导致追踪失败 + +--- + +### 问题 8:FeignClient 硬编码 URL 导致 Nacos 服务发现失效 + +#### 问题现象 +已配置 Nacos 作为服务注册中心,但各 FeignClient 仍使用 `url = "${feign.xxx.url:http://localhost:xxxx}"` 硬编码地址,导致: +1. Nacos 服务注册中心形同虚设 +2. Feign 不会通过 Nacos 进行服务发现和负载均衡 +3. 无法实现服务动态扩缩容和高可用 + +```java +// 问题代码 - ExpenseFeignClient.java +@FeignClient(name = "fund-exp", url = "${feign.fund-exp.url:http://localhost:8140}") +public interface ExpenseFeignClient { + // ... +} +``` + +#### 问题原因 +**架构设计不一致**: +- ✅ 已在 `application.yml` 中配置 Nacos 服务发现 +- ❌ 但 FeignClient 仍指定了固定的 URL 地址 + +**可能的历史原因**: +1. **开发环境过渡方案**:本地开发时 Nacos 未部署,临时使用直连方式 +2. **迁移遗留问题**:从单体架构迁移到微服务时的过渡配置 +3. **理解偏差**:误以为 FeignClient 必须指定 url + +#### 解决方案 + +**修复前(❌)**: +```java +@FeignClient(name = "fund-exp", url = "${feign.fund-exp.url:http://localhost:8140}") +// ↓ 直接访问固定地址,Nacos 不生效 +``` + +**修复后(✅)**: +```java +@FeignClient(name = "fund-exp") +// ↓ 通过 Nacos 服务发现 + Ribbon 负载均衡 +``` + +**涉及的文件修改**: +1. `ExpenseFeignClient.java` - 移除 `url` 属性 +2. `ReceivableFeignClient.java` - 移除 `url` 属性 +3. `ProjectFeignClient.java` - 移除 `url` 属性 +4. `SysServiceClient.java` - 移除 `url` 属性 + +#### 修复后的架构优势 + +**工作流程:** +``` +Fund-Report (调用方) + ↓ FeignClient: fund-exp + ↓ Spring Cloud OpenFeign + Nacos Discovery + ↓ 从 Nacos 查询 fund-exp 服务的所有实例 + ↓ Ribbon/LoadBalancer 进行负载均衡选择实例 + ↓ 发起 HTTP 请求到选中的实例 +``` + +**带来的好处:** +| 特性 | 修复前 | 修复后 | +|------|--------|--------| +| **服务发现** | ❌ 固定地址 | ✅ 自动感知上下线 | +| **负载均衡** | ❌ 单点 | ✅ 多实例轮询 | +| **高可用** | ❌ 故障无法切换 | ✅ 自动切换健康实例 | +| **弹性扩缩容** | ❌ 需修改配置 | ✅ 新增实例无需配置 | +| **架构一致性** | ❌ Nacos 形同虚设 | ✅ 充分发挥作用 | + +#### 正确的 FeignClient 配置规范 + +| 场景 | 配置方式 | 示例 | +|------|---------|------| +| **有注册中心(推荐)** | 只指定 name | `@FeignClient(name = "fund-exp")` | +| **无注册中心** | 指定 name + url | `@FeignClient(name = "xxx", url = "http://localhost:8080")` | +| **需要降级** | 指定 name + fallback | `@FeignClient(name = "fund-exp", fallback = ExpFallback.class)` | + +#### 必备配置要求 + +**1. application.yml 配置** +```yaml +spring: + cloud: + nacos: + discovery: + server-addr: localhost:8848 # Nacos 地址 + namespace: fund-platform # 命名空间 + group: DEFAULT_GROUP # 分组 +``` + +**2. 启动类注解** +```java +@SpringBootApplication +@EnableDiscoveryClient // 启用服务发现 +@EnableFeignClients // 启用 Feign 客户端 +public class ReportApplication { + // ... +} +``` + +#### 经验总结 +- **有注册中心时必须移除 url 属性**,让 Feign 通过注册中心发现服务 +- **url 属性仅用于开发测试或特殊场景**(如直连第三方服务) +- **Nacos 的价值在于服务发现与负载均衡**,硬编码 URL 会让这些能力失效 +- **微服务架构应充分利用注册中心**,避免回到点对点调用的老路 + +--- + +### 问题 9:Nacos 3.0 客户端缺少 username/password 认证配置 + +#### 问题现象 +Nacos 3.0 版本升级后,各业务服务启动时无法注册到 Nacos,控制台报错: +``` +com.alibaba.nacos.api.exception.NacosException: +Error Code: 403, Error Msg: authorization failed +``` + +或日志中出现: +``` +WARN com.alibaba.nacos.client.naming - [NA] failed to call server +com.alibaba.nacos.api.exception.NacosException: +Client need token, but token is empty. +``` + +#### 问题原因 +**Nacos 3.0 安全策略变更**: +- Nacos 2.x 版本:客户端可以匿名访问服务端 +- Nacos 3.0 版本:**强制要求客户端提供用户名和密码进行认证** + +**默认凭证**: +- 用户名:`nacos` +- 密码:`nacos` + +**影响范围**: +所有配置了 Nacos 服务发现的模块都需要添加认证信息。 + +#### 解决方案 + +**修复前(❌)**: +```yaml +spring: + cloud: + nacos: + discovery: + server-addr: localhost:8848 + namespace: fund-platform + group: DEFAULT_GROUP + # 缺少 username 和 password +``` + +**修复后(✅)**: +```yaml +spring: + cloud: + nacos: + discovery: + server-addr: localhost:8848 + namespace: fund-platform + group: DEFAULT_GROUP + username: nacos # Nacos 3.0 必需 + password: nacos # Nacos 3.0 必需 +``` + +**涉及的所有模块**: +| 模块 | 服务名 | 配置文件位置 | +|------|--------|-------------| +| fund-sys | fund-sys | `fund-sys/src/main/resources/application.yml` | +| fund-cust | fund-cust | `fund-cust/src/main/resources/application.yml` | +| fund-exp | fund-exp | `fund-exp/src/main/resources/application.yml` | +| fund-proj | fund-proj | `fund-proj/src/main/resources/application.yml` | +| fund-req | fund-req | `fund-req/src/main/resources/application.yml` | +| fund-receipt | fund-receipt | `fund-receipt/src/main/resources/application.yml` | +| fund-file | fund-file | `fund-file/src/main/resources/application.yml` | +| fund-report | fund-report | `fund-report/src/main/resources/application.yml` | + +#### 验证方法 + +**1. 查看服务注册状态** +访问 Nacos 控制台:http://localhost:8048/ +- 登录账号:`nacos` / `nacos` +- 进入「服务管理」→「服务列表」 +- 应该能看到所有服务已成功注册 + +**2. 检查服务日志** +启动日志中应该出现: +``` +INFO c.a.cloud.nacos.registry.NacosServiceRegistry - +nacos registry, DEFAULT_GROUP fund-sys 10.244.21.185:8100 register finished +``` + +**3. 测试服务调用** +```bash +curl http://localhost:8000/sys/api/v1/sys/health +# 应该能正常返回健康检查结果 +``` + +#### Nacos 3.0 vs 2.x 主要变化 + +| 特性 | Nacos 2.x | Nacos 3.0 | 影响 | +|------|-----------|-----------|------| +| **客户端认证** | ❌ 不需要 | ✅ 强制要求 | 必须配置 username/password | +| **Token 机制** | ❌ 无 | ✅ JWT Token | 客户端需携带有效 Token | +| **权限控制** | ❌ 弱 | ✅ 强化 | 支持细粒度 RBAC 权限管理 | +| **审计日志** | ❌ 基础 | ✅ 完整 | 记录所有敏感操作 | + +#### 经验总结 +- **Nacos 3.0 升级后必须修改客户端配置**,否则无法正常注册 +- **默认用户名和密码都是 `nacos`**,生产环境建议修改为强密码 +- **Gateway 不需要配置 discovery**,但需要通过 `@EnableDiscoveryClient` 启用服务发现 +- **认证失败会报 403 错误**,而不是连接失败的错误 + +--- + +## 预防措施清单 + +### 前后端接口对接 + +| 检查项 | 说明 | +|--------|------| +| 请求头确认 | 确认所有必需的请求头(X-User-Id、X-Tenant-Id、Authorization等) | +| 响应格式确认 | 统一使用 `Result` 包装响应,确认字段命名一致 | +| 错误码定义 | 明确各类错误码的含义和处理方式 | + +### 前端路由配置 + +| 检查项 | 说明 | +|--------|------| +| 代理路径精确匹配 | 使用 `/api/` 带斜杠的形式,避免模糊匹配 | +| 路由命名规范 | 前端路由与后端API路径应使用不同的前缀区分 | +| 刷新兼容性 | SPA 应用需考虑页面刷新时的路由状态恢复 | + +### 后端开发规范 + +| 检查项 | 说明 | +|--------|------| +| AOP 依赖引入 | 使用 AOP 需添加 `spring-boot-starter-aop` 依赖 | +| Jackson 日期配置 | 序列化 Java 8 日期类型需注册 `JavaTimeModule` | +| 日志独立输出 | 使用 Logger name 隔离,配置独立的 appender | +| 外键字段类型 | 使用雪花 ID 的外键字段必须使用 Long 类型 | +| Feign 拦截器注册 | Feign 拦截器必须添加@Component 注解并配置组件扫描 | +| TraceId 传递 | ContextInterceptor 必须提取并设置 TraceId | +| **FeignClient 配置** | **有注册中心时必须移除 url 属性,仅保留 name** | +| **Nacos 3.0 认证** | **客户端必须配置 username 和 password(默认都是 nacos)** | + +### 功能开发流程 + +| 检查项 | 说明 | +|--------|------| +| UI交互完整性 | 所有菜单、按钮的点击事件都应有对应实现 | +| 自测覆盖 | 代码提交前进行基础功能自测 | +| 刷新测试 | 重点测试页面刷新后的状态是否正常 | + +--- + +## 相关文件索引 + +### 本次问题修复涉及的文件 + +**后端文件:** +- `fund-sys/pom.xml` - 添加 AOP 和 Jackson 依赖 +- `fund-sys/src/main/java/com/fundplatform/sys/aop/ApiLogAspect.java` - AOP 日志切面 +- `fund-sys/src/main/resources/logback-spring.xml` - 日志配置 +- `fund-exp/src/main/java/com/fundplatform/exp/dto/FundExpenseDTO.java` - 支出 DTO expenseType 字段类型修复 +- `fund-common/src/main/java/com/fundplatform/common/feign/FeignChainInterceptor.java` - Feign 拦截器添加@Component 注解 +- `fund-report/src/main/java/com/fundplatform/report/ReportApplication.java` - 配置组件扫描路径 +- `fund-common/src/main/java/com/fundplatform/common/web/ContextInterceptor.java` - 添加 TraceId 提取与清理逻辑 +- `fund-report/src/main/java/com/fundplatform/report/feign/ExpenseFeignClient.java` - 移除硬编码 URL,使用 Nacos 服务发现 +- `fund-report/src/main/java/com/fundplatform/report/feign/ReceivableFeignClient.java` - 移除硬编码 URL,使用 Nacos 服务发现 +- `fund-report/src/main/java/com/fundplatform/report/feign/ProjectFeignClient.java` - 移除硬编码 URL,使用 Nacos 服务发现 +- `fund-cust/src/main/java/com/fundplatform/cust/feign/SysServiceClient.java` - 移除硬编码 URL,使用 Nacos 服务发现 +- `fund-*/src/main/resources/application.yml` - 为所有业务模块添加 Nacos 3.0 username/password 认证配置 + +**前端文件:** +- `fund-admin/vite.config.ts` - 代理配置修复 +- `fund-admin/src/router/index.ts` - 路由配置 +- `fund-admin/src/layouts/MainLayout.vue` - 菜单事件处理 +- `fund-admin/src/api/request.ts` - 请求拦截器 +- `fund-admin/src/stores/user.ts` - 用户状态管理 + +--- + +## 附录:常见错误速查 + +| 错误信息 | 可能原因 | 解决方案 | +|----------|----------|----------| +| `400 Bad Request` | 缺少必需请求头 | 检查X-User-Id、X-Tenant-Id等 | +| `404 Not Found (刷新后)` | 代理路径冲突 | 使用精确匹配,区分前后端路由 | +| `Java 8 date/time type not supported` | Jackson未配置日期模块 | 注册JavaTimeModule | +| `ClassNotFoundException: Aspect` | 未引入AOP依赖 | 添加spring-boot-starter-aop | +| `JSON parse error` | 字段类型不匹配(Integer vs Long) | 雪花ID外键必须使用Long类型 | +| `Feign 调用未透传上下文` | 拦截器未注册为 Bean | 添加@Component 注解并配置扫描 | +| `TraceId 链路断裂` | ContextInterceptor 未提取 TraceId | 在 preHandle 中添加 TraceId 设置逻辑 | +| `服务发现失效` | FeignClient 硬编码 URL | 移除 url 属性,仅保留 name 使用 Nacos 发现 | +| `Nacos 注册失败 (认证错误)` | 缺少 username/password 配置 | 在 discovery 节点添加 username 和 password | + +--- + +> **备注**: 本文档将根据后续开发过程中的问题持续更新。 diff --git a/fund-common/pom.xml b/fund-common/pom.xml index 31000ce..bb10f7a 100644 --- a/fund-common/pom.xml +++ b/fund-common/pom.xml @@ -50,6 +50,34 @@ jackson-databind + + + cn.afterturn + easypoi-spring-boot-starter + 4.4.0 + + + + + org.projectlombok + lombok + true + + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + + + com.baomidou + mybatis-plus-extension + 3.5.5 + provided + + org.springframework.boot diff --git a/fund-common/src/main/java/com/fundplatform/common/config/TenantRoutingProperties.java b/fund-common/src/main/java/com/fundplatform/common/config/TenantRoutingProperties.java new file mode 100644 index 0000000..f6b2ce7 --- /dev/null +++ b/fund-common/src/main/java/com/fundplatform/common/config/TenantRoutingProperties.java @@ -0,0 +1,253 @@ +package com.fundplatform.common.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +public class TenantRoutingProperties { + + /** 是否启用租户路由 */ + private boolean enabled = false; + + /** 租户 ID 请求头 */ + private String tenantHeader = "X-Tenant-Id"; + + /** 租户组请求头 */ + private String tenantGroupHeader = "X-Tenant-Group"; + + /** 服务组分隔符 */ + private String groupSeparator = "TENANT_"; + + /** 共享服务列表(不区分租户,所有租户共用) */ + private List sharedServices = Arrays.asList( + "fund-gateway", + "fund-report", + "fund-file" + ); + + /** 默认租户 ID(当未指定时使用) */ + private String defaultTenantId = "1"; + + /** 租户服务配置映射 */ + private Map tenantConfigs = new HashMap<>(); + + // Getters and Setters + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getTenantHeader() { + return tenantHeader; + } + + public void setTenantHeader(String tenantHeader) { + this.tenantHeader = tenantHeader; + } + + public String getTenantGroupHeader() { + return tenantGroupHeader; + } + + public void setTenantGroupHeader(String tenantGroupHeader) { + this.tenantGroupHeader = tenantGroupHeader; + } + + public String getGroupSeparator() { + return groupSeparator; + } + + public void setGroupSeparator(String groupSeparator) { + this.groupSeparator = groupSeparator; + } + + public List getSharedServices() { + return sharedServices; + } + + public void setSharedServices(List sharedServices) { + this.sharedServices = sharedServices; + } + + public String getDefaultTenantId() { + return defaultTenantId; + } + + public void setDefaultTenantId(String defaultTenantId) { + this.defaultTenantId = defaultTenantId; + } + + public Map getTenantConfigs() { + return tenantConfigs; + } + + public void setTenantConfigs(Map tenantConfigs) { + this.tenantConfigs = tenantConfigs; + } + + /** + * 构建租户组名称 + */ + public String buildTenantGroup(String tenantId) { + if (tenantId == null || tenantId.isEmpty()) { + return "DEFAULT"; + } + return getGroupSeparator() + tenantId.toUpperCase(); + } + + /** + * 判断是否为共享服务 + */ + public boolean isSharedService(String serviceName) { + return sharedServices.contains(serviceName); + } + + /** + * 租户服务配置 + */ + public static class TenantServiceConfig { + /** 租户 ID */ + private String tenantId; + + /** 服务实例配置 */ + private Map services = new HashMap<>(); + + /** 数据库配置(一库一租户模式) */ + private DatabaseConfig database; + + // Getters and Setters + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public Map getServices() { + return services; + } + + public void setServices(Map services) { + this.services = services; + } + + public DatabaseConfig getDatabase() { + return database; + } + + public void setDatabase(DatabaseConfig database) { + this.database = database; + } + } + + /** + * 服务实例配置 + */ + public static class ServiceInstanceConfig { + /** 服务名 */ + private String serviceName; + + /** 端口号 */ + private int port = 8080; + + /** 实例数(用于负载均衡) */ + private int replicas = 1; + + /** 权重(用于加权负载均衡) */ + private int weight = 1; + + // Getters and Setters + + public String getServiceName() { + return serviceName; + } + + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public int getReplicas() { + return replicas; + } + + public void setReplicas(int replicas) { + this.replicas = replicas; + } + + public int getWeight() { + return weight; + } + + public void setWeight(int weight) { + this.weight = weight; + } + } + + /** + * 数据库配置(用于一库一租户) + */ + public static class DatabaseConfig { + /** JDBC URL */ + private String url; + + /** 用户名 */ + private String username; + + /** 密码 */ + private String password; + + /** 驱动类名 */ + private String driverClassName = "com.mysql.cj.jdbc.Driver"; + + // Getters and Setters + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDriverClassName() { + return driverClassName; + } + + public void setDriverClassName(String driverClassName) { + this.driverClassName = driverClassName; + } + } +} diff --git a/fund-common/src/main/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancer.java b/fund-common/src/main/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancer.java new file mode 100644 index 0000000..2d48b9c --- /dev/null +++ b/fund-common/src/main/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancer.java @@ -0,0 +1,182 @@ +package com.fundplatform.common.loadbalancer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.EmptyResponse; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.RequestData; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; +import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +/** + * 租户感知的负载均衡器 + * + *

根据租户 ID 进行服务实例路由,支持:

+ *
    + *
  • 租户专属实例优先
  • + *
  • 共享实例回退
  • + *
  • 轮询负载均衡
  • + *
  • 混合模式(VIP 租户专属 + 普通租户共享)
  • + *
+ * + *

使用场景

+ *
+ * 混合模式部署:
+ * - VIP 客户:独立部署服务实例(带 tenant-group 标签)
+ * - 普通客户:共享服务实例(无 tenant-group 标签)
+ * 
+ */ +public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer { + + private static final Logger logger = LoggerFactory.getLogger(TenantAwareLoadBalancer.class); + + private final String serviceId; + private final ObjectProvider supplierProvider; + + public TenantAwareLoadBalancer(String serviceId, + ObjectProvider supplierProvider) { + this.serviceId = serviceId; + this.supplierProvider = supplierProvider; + } + + @Override + public Mono> choose(Request request) { + // 从请求上下文获取租户 ID + String tenantId = getTenantIdFromRequest(request); + String tenantGroup = buildTenantGroup(tenantId); + + if (supplierProvider == null) { + logger.warn("[TenantLB] ServiceInstanceListSupplier 未提供"); + return Mono.just(new EmptyResponse()); + } + + ServiceInstanceListSupplier supplier = supplierProvider.getIfAvailable(); + if (supplier == null) { + logger.warn("[TenantLB] 无法获取 ServiceInstanceListSupplier"); + return Mono.just(new EmptyResponse()); + } + + return supplier.get().next() + .map(instances -> filterByTenantGroup(instances, tenantGroup)) + .map(this::getInstanceResponse); + } + + /** + * 从请求中获取租户 ID + */ + private String getTenantIdFromRequest(Request request) { + if (request == null || request.getContext() == null) { + return null; + } + + // 方式 1: 从请求头获取(Gateway 场景) + if (request.getContext() instanceof RequestData) { + RequestData data = (RequestData) request.getContext(); + if (data.getHeaders() != null) { + String tenantId = data.getHeaders().getFirst("X-Tenant-Id"); + if (tenantId != null && !tenantId.isEmpty()) { + logger.debug("[TenantLB] 从请求头获取租户 ID: {}", tenantId); + return tenantId; + } + } + } + + // 方式 2: 从 ThreadLocal 获取(Feign 调用场景) + try { + Class holderClass = Class.forName("com.fundplatform.common.context.TenantContextHolder"); + java.lang.reflect.Method method = holderClass.getMethod("getTenantId"); + Object result = method.invoke(null); + if (result != null) { + logger.debug("[TenantLB] 从 ThreadLocal 获取租户 ID: {}", result); + return result.toString(); + } + } catch (Exception e) { + logger.debug("[TenantLB] 无法从 ThreadLocal 获取租户 ID: {}", e.getMessage()); + } + + return null; + } + + /** + * 构建租户组名称 + */ + String buildTenantGroup(String tenantId) { + if (tenantId == null || tenantId.isEmpty()) { + return "DEFAULT"; + } + return "TENANT_" + tenantId.toUpperCase(); + } + + /** + * 根据租户组过滤服务实例 + * + *

路由策略:

+ *
    + *
  1. 优先选择租户专属实例(metadata.tenant-group 匹配)
  2. + *
  3. 回退到共享实例(无 tenant-group 标签)
  4. + *
+ */ + List filterByTenantGroup(List instances, String tenantGroup) { + if (instances == null || instances.isEmpty()) { + return instances; + } + + logger.debug("[TenantLB] 租户组:{},候选实例数:{}", tenantGroup, instances.size()); + + // 优先选择租户专属实例 + List tenantInstances = instances.stream() + .filter(inst -> { + Map metadata = inst.getMetadata(); + if (metadata == null) return false; + String instanceGroup = metadata.get("tenant-group"); + boolean match = tenantGroup.equals(instanceGroup); + if (match) { + logger.debug("[TenantLB] 匹配租户实例:{}:{}", inst.getHost(), inst.getPort()); + } + return match; + }) + .collect(Collectors.toList()); + + if (!tenantInstances.isEmpty()) { + logger.info("[TenantLB] 找到 {} 个租户专属实例,租户组:{}", tenantInstances.size(), tenantGroup); + return tenantInstances; + } + + // 回退到共享实例(无 tenant-group 标签) + List sharedInstances = instances.stream() + .filter(inst -> { + Map metadata = inst.getMetadata(); + return metadata == null || !metadata.containsKey("tenant-group"); + }) + .collect(Collectors.toList()); + + logger.info("[TenantLB] 未找到租户专属实例,使用 {} 个共享实例", sharedInstances.size()); + return sharedInstances; + } + + /** + * 从实例列表中选择一个(轮询) + */ + private Response getInstanceResponse(List instances) { + if (instances == null || instances.isEmpty()) { + logger.warn("[TenantLB] 无可用实例"); + return new EmptyResponse(); + } + + // 轮询选择 + int index = ThreadLocalRandom.current().nextInt(instances.size()); + ServiceInstance chosen = instances.get(index); + logger.info("[TenantLB] 选择实例:{}:{} (index={})", chosen.getHost(), chosen.getPort(), index); + return new DefaultResponse(chosen); + } +} diff --git a/fund-common/src/main/java/com/fundplatform/common/mybatis/MybatisTenantAutoConfig.java b/fund-common/src/main/java/com/fundplatform/common/mybatis/MybatisTenantAutoConfig.java new file mode 100644 index 0000000..378dbf2 --- /dev/null +++ b/fund-common/src/main/java/com/fundplatform/common/mybatis/MybatisTenantAutoConfig.java @@ -0,0 +1,67 @@ +package com.fundplatform.common.mybatis; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis Plus 租户插件自动配置 + * + *

启用条件:配置 mybatis-plus.tenant.enabled=true

+ * + *

功能:

+ *
    + *
  • 自动为 SQL 添加租户过滤条件
  • + *
  • 支持分页插件
  • + *
  • 可配置忽略的表
  • + *
+ */ +@Configuration +@ConditionalOnProperty(name = "mybatis-plus.tenant.enabled", havingValue = "true", matchIfMissing = false) +public class MybatisTenantAutoConfig { + + private static final Logger logger = LoggerFactory.getLogger(MybatisTenantAutoConfig.class); + + /** + * MyBatis Plus 拦截器配置 + * + *

包含租户过滤和分页功能

+ */ + @Bean + @ConditionalOnMissingBean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + logger.info("[MyBatis Plus] 初始化租户过滤插件"); + + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + + // 1. 租户过滤插件(必须在最前面) + TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(); + tenantInterceptor.setTenantLineHandler(tenantLineHandler()); + interceptor.addInnerInterceptor(tenantInterceptor); + + // 2. 分页插件 + PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); + paginationInterceptor.setMaxLimit(1000L); // 最大单页限制 + paginationInterceptor.setOverflow(false); // 溢出总页数后是否进行处理 + interceptor.addInnerInterceptor(paginationInterceptor); + + logger.info("[MyBatis Plus] 租户过滤插件初始化完成"); + return interceptor; + } + + /** + * 租户行处理器 + */ + @Bean + @ConditionalOnMissingBean + public TenantLineHandlerImpl tenantLineHandler() { + return new TenantLineHandlerImpl(); + } +} diff --git a/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantIgnoreHelper.java b/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantIgnoreHelper.java new file mode 100644 index 0000000..bd5a47d --- /dev/null +++ b/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantIgnoreHelper.java @@ -0,0 +1,80 @@ +package com.fundplatform.common.mybatis; + +import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Supplier; + +/** + * 租户忽略工具类 + * + *

在需要跳过租户过滤的场景使用,例如:

+ *
    + *
  • 管理员查看所有租户数据
  • + *
  • 数据迁移场景
  • + *
  • 跨租户查询
  • + *
+ * + *

使用方式:

+ *
+ * // 方式 1:使用 Lambda
+ * List<User> allUsers = TenantIgnoreHelper.ignore(() -> userMapper.selectList(null));
+ * 
+ * // 方式 2:使用代码块
+ * TenantIgnoreHelper.ignore(() -> {
+ *     // 这里执行的 SQL 不会添加租户过滤
+ *     userMapper.insert(user);
+ * });
+ * 
+ */ +public class TenantIgnoreHelper { + + private static final Logger logger = LoggerFactory.getLogger(TenantIgnoreHelper.class); + + /** + * 在忽略租户过滤的上下文中执行 + * + * @param supplier 要执行的操作 + * @param 返回类型 + * @return 操作结果 + */ + public static T ignore(Supplier supplier) { + try { + // 使用 MyBatis Plus 内置的方式忽略租户过滤 + InterceptorIgnoreHelper.handle( + com.baomidou.mybatisplus.core.plugins.IgnoreStrategy.builder() + .tenantLine(true) + .build() + ); + logger.debug("[TenantIgnore] 开始忽略租户过滤"); + return supplier.get(); + } finally { + // 清除标记 + InterceptorIgnoreHelper.clearIgnoreStrategy(); + logger.debug("[TenantIgnore] 恢复租户过滤"); + } + } + + /** + * 在忽略租户过滤的上下文中执行(无返回值) + * + * @param runnable 要执行的操作 + */ + public static void ignore(Runnable runnable) { + try { + // 使用 MyBatis Plus 内置的方式忽略租户过滤 + InterceptorIgnoreHelper.handle( + com.baomidou.mybatisplus.core.plugins.IgnoreStrategy.builder() + .tenantLine(true) + .build() + ); + logger.debug("[TenantIgnore] 开始忽略租户过滤"); + runnable.run(); + } finally { + // 清除标记 + InterceptorIgnoreHelper.clearIgnoreStrategy(); + logger.debug("[TenantIgnore] 恢复租户过滤"); + } + } +} diff --git a/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java b/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java new file mode 100644 index 0000000..f24cd5a --- /dev/null +++ b/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java @@ -0,0 +1,120 @@ +package com.fundplatform.common.mybatis; + +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * MyBatis Plus 租户行处理器 + * + *

自动为 SQL 语句添加租户过滤条件,实现多租户数据隔离

+ * + *

使用方式:

+ *
+ * // 在 MyBatis 配置类中注册
+ * @Bean
+ * public MybatisPlusInterceptor mybatisPlusInterceptor() {
+ *     MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ *     interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandlerImpl()));
+ *     return interceptor;
+ * }
+ * 
+ */ +public class TenantLineHandlerImpl implements TenantLineHandler { + + private static final Logger logger = LoggerFactory.getLogger(TenantLineHandlerImpl.class); + + /** + * 忽略租户过滤的表(系统表、字典表等公共数据) + */ + private static final Set IGNORE_TABLES = new HashSet<>(Arrays.asList( + "sys_user", // 用户表(可能跨租户) + "sys_role", // 角色表(可能跨租户) + "sys_menu", // 菜单表(所有租户共享) + "sys_dict", // 字典表(所有租户共享) + "sys_config", // 配置表(所有租户共享) + "sys_dept", // 部门表(可能跨租户) + "sys_log", // 日志表(独立存储) + "gen_table", // 代码生成表 + "gen_table_column" // 代码生成字段表 + )); + + /** + * 获取租户 ID 值 + * + *

从 TenantContextHolder 获取当前线程的租户 ID

+ */ + @Override + public Expression getTenantId() { + Long tenantId = getCurrentTenantId(); + + if (tenantId == null) { + logger.debug("[MyBatis Tenant] 未获取到租户 ID,使用默认值 1"); + tenantId = 1L; + } + + logger.debug("[MyBatis Tenant] 当前租户 ID: {}", tenantId); + return new LongValue(tenantId); + } + + /** + * 获取租户字段名称 + */ + @Override + public String getTenantIdColumn() { + return "tenant_id"; + } + + /** + * 判断表是否需要忽略租户过滤 + * + * @param tableName 表名 + * @return true-忽略,false-需要过滤 + */ + @Override + public boolean ignoreTable(String tableName) { + // 忽略系统表 + boolean ignore = IGNORE_TABLES.contains(tableName.toLowerCase()); + + if (ignore) { + logger.debug("[MyBatis Tenant] 忽略租户过滤:{}", tableName); + } + + return ignore; + } + + /** + * 从上下文获取当前租户 ID + */ + private Long getCurrentTenantId() { + try { + // 通过反射调用 TenantContextHolder.getTenantId() + Class holderClass = Class.forName("com.fundplatform.common.context.TenantContextHolder"); + java.lang.reflect.Method method = holderClass.getMethod("getTenantId"); + Object result = method.invoke(null); + + if (result instanceof Long) { + return (Long) result; + } else if (result instanceof Integer) { + return ((Integer) result).longValue(); + } + + return null; + } catch (ClassNotFoundException e) { + logger.debug("[MyBatis Tenant] TenantContextHolder 未找到,可能未启用多租户"); + return null; + } catch (NoSuchMethodException e) { + logger.warn("[MyBatis Tenant] TenantContextHolder.getTenantId() 方法未找到"); + return null; + } catch (Exception e) { + logger.debug("[MyBatis Tenant] 获取租户 ID 失败:{}", e.getMessage()); + return null; + } + } +} diff --git a/fund-common/src/main/java/com/fundplatform/common/nacos/NacosMetadataConfig.java b/fund-common/src/main/java/com/fundplatform/common/nacos/NacosMetadataConfig.java new file mode 100644 index 0000000..d4cc5a2 --- /dev/null +++ b/fund-common/src/main/java/com/fundplatform/common/nacos/NacosMetadataConfig.java @@ -0,0 +1,68 @@ +package com.fundplatform.common.nacos; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.client.serviceregistry.Registration; +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; +import java.util.HashMap; +import java.util.Map; + +/** + * Nacos 服务注册元数据配置 + * + *

服务启动时自动注册租户标签到 Nacos,支持租户感知的负载均衡

+ */ +@Configuration +@ConditionalOnProperty(name = "tenant.routing.enabled", havingValue = "true") +public class NacosMetadataConfig { + + private static final Logger logger = LoggerFactory.getLogger(NacosMetadataConfig.class); + + @Value("${spring.application.name:unknown}") + private String applicationName; + + @Value("${spring.cloud.nacos.discovery.metadata.tenant-id:}") + private String tenantId; + + @Value("${spring.cloud.nacos.discovery.metadata.tenant-group:}") + private String tenantGroup; + + @Value("${tenant.routing.default-tenant-id:1}") + private String defaultTenantId; + + /** + * 初始化 Nacos 元数据 + */ + @PostConstruct + public void init() { + logger.info("[Nacos Metadata] 应用名:{}", applicationName); + + // 如果未配置租户 ID,使用默认值 + if (tenantId == null || tenantId.isEmpty()) { + tenantId = defaultTenantId; + logger.info("[Nacos Metadata] 未配置租户 ID,使用默认值:{}", tenantId); + } + + // 如果未配置租户组,自动生成 + if (tenantGroup == null || tenantGroup.isEmpty()) { + tenantGroup = buildTenantGroup(tenantId); + logger.info("[Nacos Metadata] 自动生成租户组:{}", tenantGroup); + } + + logger.info("[Nacos Metadata] 租户 ID: {}, 租户组:{}", tenantId, tenantGroup); + } + + /** + * 构建租户组名称 + */ + private String buildTenantGroup(String tenantId) { + if (tenantId == null || tenantId.isEmpty()) { + return "DEFAULT"; + } + return "TENANT_" + tenantId.toUpperCase(); + } +} diff --git a/fund-common/src/main/java/com/fundplatform/common/util/ExcelUtil.java b/fund-common/src/main/java/com/fundplatform/common/util/ExcelUtil.java new file mode 100644 index 0000000..fb3d7be --- /dev/null +++ b/fund-common/src/main/java/com/fundplatform/common/util/ExcelUtil.java @@ -0,0 +1,108 @@ +package com.fundplatform.common.util; + +import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.ExcelImportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.ImportParams; +import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.Workbook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * Excel工具类 + */ +public class ExcelUtil { + + private static final Logger log = LoggerFactory.getLogger(ExcelUtil.class); + + /** + * 导出Excel到响应流 + * + * @param list 数据列表 + * @param title 标题 + * @param sheetName Sheet名称 + * @param clazz 实体类(需要@Excel注解) + * @param response HTTP响应 + */ + public static void exportExcel(List list, String title, String sheetName, + Class clazz, HttpServletResponse response) { + exportExcel(list, title, sheetName, clazz, response, "导出数据.xlsx"); + } + + /** + * 导出Excel到响应流 + * + * @param list 数据列表 + * @param title 标题 + * @param sheetName Sheet名称 + * @param clazz 实体类(需要@Excel注解) + * @param response HTTP响应 + * @param fileName 文件名 + */ + public static void exportExcel(List list, String title, String sheetName, + Class clazz, HttpServletResponse response, String fileName) { + try { + ExportParams params = new ExportParams(title, sheetName, ExcelType.XSSF); + Workbook workbook = ExcelExportUtil.exportExcel(params, clazz, list); + + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + encodedFileName); + + workbook.write(response.getOutputStream()); + workbook.close(); + + log.info("Excel导出成功: {}, 数据量: {}", fileName, list.size()); + } catch (IOException e) { + log.error("Excel导出失败", e); + throw new RuntimeException("导出Excel失败: " + e.getMessage()); + } + } + + /** + * 导入Excel + * + * @param file 文件路径或输入流 + * @param clazz 实体类 + * @param params 导入参数 + * @return 数据列表 + */ + public static List importExcel(Object file, Class clazz, ImportParams params) { + try { + if (file instanceof InputStream) { + return ExcelImportUtil.importExcel((InputStream) file, clazz, params); + } else if (file instanceof java.io.File) { + return ExcelImportUtil.importExcel((java.io.File) file, clazz, params); + } else { + throw new IllegalArgumentException("不支持的文件类型:" + file.getClass().getName()); + } + } catch (NoSuchElementException e) { + log.error("Excel 导入失败,文件为空或格式错误", e); + throw new RuntimeException("导入文件为空或格式错误"); + } catch (Exception e) { + log.error("Excel 导入失败", e); + throw new RuntimeException("导入 Excel 失败:" + e.getMessage()); + } + } + + /** + * 默认导入参数 + */ + public static ImportParams defaultImportParams() { + ImportParams params = new ImportParams(); + params.setTitleRows(0); // 标题行数 + params.setHeadRows(1); // 表头行数 + params.setNeedVerify(true); // 是否验证 + return params; + } +} diff --git a/fund-common/src/test/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancerTest.java b/fund-common/src/test/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancerTest.java new file mode 100644 index 0000000..48ba78a --- /dev/null +++ b/fund-common/src/test/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancerTest.java @@ -0,0 +1,129 @@ +package com.fundplatform.common.loadbalancer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.RequestData; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 租户负载均衡器测试 + */ +class TenantAwareLoadBalancerTest { + + private TenantAwareLoadBalancer loadBalancer; + + @BeforeEach + void setUp() { + loadBalancer = new TenantAwareLoadBalancer("fund-sys", null); + } + + @Test + void testBuildTenantGroup() { + // 测试租户组名称构建 + String group1 = invokeBuildTenantGroup(loadBalancer, "1"); + assertEquals("TENANT_1", group1); + + String group2 = invokeBuildTenantGroup(loadBalancer, "tenant_001"); + assertEquals("TENANT_TENANT_001", group2); + + String group3 = invokeBuildTenantGroup(loadBalancer, null); + assertEquals("DEFAULT", group3); + + String group4 = invokeBuildTenantGroup(loadBalancer, ""); + assertEquals("DEFAULT", group4); + } + + @Test + void testFilterByTenantGroup() { + // 创建测试实例 + List instances = Arrays.asList( + createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-group", "TENANT_1")), + createInstance("fund-sys", "192.168.1.2", 8101, Map.of("tenant-group", "TENANT_1")), + createInstance("fund-sys", "192.168.1.3", 8102, Map.of("tenant-group", "TENANT_2")), + createInstance("fund-sys", "192.168.1.4", 8103, Collections.emptyMap()) // 共享实例 + ); + + // 测试租户 1 过滤 + List tenant1Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_1"); + assertEquals(2, tenant1Instances.size()); + assertTrue(tenant1Instances.stream().allMatch(i -> "TENANT_1".equals(i.getMetadata().get("tenant-group")))); + + // 测试租户 2 过滤 + List tenant2Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_2"); + assertEquals(1, tenant2Instances.size()); + + // 测试未知租户(回退到共享实例) + List unknownTenantInstances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_UNKNOWN"); + assertEquals(1, unknownTenantInstances.size()); + assertFalse(unknownTenantInstances.get(0).getMetadata().containsKey("tenant-group")); + } + + @Test + void testMixedMode() { + // 混合模式:VIP 客户有专属实例,普通客户使用共享实例 + List instances = Arrays.asList( + // VIP 租户 001 的专属实例 + createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-group", "TENANT_VIP_001")), + createInstance("fund-sys", "192.168.1.2", 8101, Map.of("tenant-group", "TENANT_VIP_001")), + // VIP 租户 002 的专属实例 + createInstance("fund-sys", "192.168.1.3", 8102, Map.of("tenant-group", "TENANT_VIP_002")), + // 共享实例(普通租户使用) + createInstance("fund-sys", "192.168.1.10", 8110, Collections.emptyMap()), + createInstance("fund-sys", "192.168.1.11", 8111, Collections.emptyMap()) + ); + + // VIP 租户 001 应该路由到专属实例 + List vip1Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_VIP_001"); + assertEquals(2, vip1Instances.size()); + + // VIP 租户 002 应该路由到专属实例 + List vip2Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_VIP_002"); + assertEquals(1, vip2Instances.size()); + + // 普通租户应该路由到共享实例 + List normalInstances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_NORMAL"); + assertEquals(2, normalInstances.size()); + } + + // 辅助方法:创建服务实例 + private ServiceInstance createInstance(String serviceId, String host, int port, Map metadata) { + return new DefaultServiceInstance( + UUID.randomUUID().toString(), + serviceId, + host, + port, + false, + metadata + ); + } + + // 辅助方法:调用私有方法 buildTenantGroup + private String invokeBuildTenantGroup(TenantAwareLoadBalancer lb, String tenantId) { + try { + var method = TenantAwareLoadBalancer.class.getDeclaredMethod("buildTenantGroup", String.class); + method.setAccessible(true); + return (String) method.invoke(lb, tenantId); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + // 辅助方法:调用私有方法 filterByTenantGroup + @SuppressWarnings("unchecked") + private List invokeFilterByTenantGroup(TenantAwareLoadBalancer lb, List instances, String tenantGroup) { + try { + var method = TenantAwareLoadBalancer.class.getDeclaredMethod("filterByTenantGroup", List.class, String.class); + method.setAccessible(true); + return (List) method.invoke(lb, instances, tenantGroup); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/fund-cust/src/main/resources/application.yml b/fund-cust/src/main/resources/application.yml index 3db834f..aad8b34 100644 --- a/fund-cust/src/main/resources/application.yml +++ b/fund-cust/src/main/resources/application.yml @@ -11,6 +11,8 @@ spring: server-addr: localhost:8848 namespace: fund-platform group: DEFAULT_GROUP + username: nacos + password: nacos datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/fund-exp/src/main/java/com/fundplatform/exp/data/entity/FundExpense.java b/fund-exp/src/main/java/com/fundplatform/exp/data/entity/FundExpense.java index bb126a4..b270cf9 100644 --- a/fund-exp/src/main/java/com/fundplatform/exp/data/entity/FundExpense.java +++ b/fund-exp/src/main/java/com/fundplatform/exp/data/entity/FundExpense.java @@ -25,7 +25,7 @@ public class FundExpense extends BaseEntity { private String currency; /** 支出类型(1-日常支出 2-项目支出 3-工资发放 4-其他) */ - private Integer expenseType; + private Long expenseType; /** 收款单位 */ private String payeeName; @@ -110,11 +110,11 @@ public class FundExpense extends BaseEntity { this.currency = currency; } - public Integer getExpenseType() { + public Long getExpenseType() { return expenseType; } - public void setExpenseType(Integer expenseType) { + public void setExpenseType(Long expenseType) { this.expenseType = expenseType; } diff --git a/fund-exp/src/main/java/com/fundplatform/exp/service/impl/FundExpenseServiceImpl.java b/fund-exp/src/main/java/com/fundplatform/exp/service/impl/FundExpenseServiceImpl.java index f14825a..e91ffc1 100644 --- a/fund-exp/src/main/java/com/fundplatform/exp/service/impl/FundExpenseServiceImpl.java +++ b/fund-exp/src/main/java/com/fundplatform/exp/service/impl/FundExpenseServiceImpl.java @@ -19,6 +19,11 @@ import org.springframework.util.StringUtils; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @Service @@ -114,7 +119,7 @@ public class FundExpenseServiceImpl implements FundExpenseService { } @Override - public Page pageExpenses(int pageNum, int pageSize, String title, Integer expenseType, Integer payStatus, Integer approvalStatus) { + public Page pageExpenses(int pageNum, int pageSize, String title, Long expenseType, Integer payStatus, Integer approvalStatus) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(FundExpense::getDeleted, 0); @@ -353,9 +358,9 @@ public class FundExpenseServiceImpl implements FundExpenseService { return vo; } - private String getExpenseTypeName(Integer type) { + private String getExpenseTypeName(Long type) { if (type == null) return ""; - return switch (type) { case 1 -> "日常支出"; case 2 -> "项目支出"; case 3 -> "工资发放"; case 4 -> "其他"; default -> ""; }; + return switch (type.intValue()) { case 1 -> "日常支出"; case 2 -> "项目支出"; case 3 -> "工资发放"; case 4 -> "其他"; default -> ""; }; } private String getPayStatusName(Integer status) { @@ -378,4 +383,78 @@ public class FundExpenseServiceImpl implements FundExpenseService { default -> ""; }; } + + // ===================== 统计方法实现 ===================== + + @Override + public BigDecimal getPendingAmount() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(FundExpense::getDeleted, 0) + .eq(FundExpense::getApprovalStatus, APPROVAL_IN_PROGRESS); + + List pendingExpenses = expenseDataService.list(wrapper); + return pendingExpenses.stream() + .map(FundExpense::getAmount) + .filter(a -> a != null) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + @Override + public Integer getPendingCount() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(FundExpense::getDeleted, 0) + .eq(FundExpense::getApprovalStatus, APPROVAL_IN_PROGRESS); + return (int) expenseDataService.count(wrapper); + } + + @Override + public BigDecimal getTodayExpense() { + LocalDate today = LocalDate.now(); + LocalDateTime startOfDay = today.atStartOfDay(); + LocalDateTime endOfDay = today.plusDays(1).atStartOfDay(); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(FundExpense::getDeleted, 0) + .eq(FundExpense::getPayStatus, PAY_PAID) + .ge(FundExpense::getPayTime, startOfDay) + .lt(FundExpense::getPayTime, endOfDay); + + List todayExpenses = expenseDataService.list(wrapper); + return todayExpenses.stream() + .map(FundExpense::getAmount) + .filter(a -> a != null) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + @Override + public List> getTypeDistribution() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(FundExpense::getDeleted, 0) + .eq(FundExpense::getPayStatus, PAY_PAID); + + List expenses = expenseDataService.list(wrapper); + + Map typeAmountMap = new HashMap<>(); + Map typeCountMap = new HashMap<>(); + + for (FundExpense expense : expenses) { + Long type = expense.getExpenseType(); + if (type == null) type = 0L; + + typeAmountMap.merge(type, expense.getAmount() != null ? expense.getAmount() : BigDecimal.ZERO, BigDecimal::add); + typeCountMap.merge(type, 1, Integer::sum); + } + + List> result = new ArrayList<>(); + for (Long type : typeAmountMap.keySet()) { + Map item = new HashMap<>(); + item.put("type", type); + item.put("name", getExpenseTypeName(type)); + item.put("count", typeCountMap.get(type)); + item.put("amount", typeAmountMap.get(type)); + result.add(item); + } + + return result; + } } diff --git a/fund-exp/src/main/resources/application.yml b/fund-exp/src/main/resources/application.yml index c5b3ea9..ba15818 100644 --- a/fund-exp/src/main/resources/application.yml +++ b/fund-exp/src/main/resources/application.yml @@ -11,6 +11,8 @@ spring: server-addr: localhost:8848 namespace: fund-platform group: DEFAULT_GROUP + username: nacos + password: nacos datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/fund-file/src/main/resources/application.yml b/fund-file/src/main/resources/application.yml index 3211240..fa7aeca 100644 --- a/fund-file/src/main/resources/application.yml +++ b/fund-file/src/main/resources/application.yml @@ -1,13 +1,42 @@ server: - port: 8170 + port: 8600 spring: application: name: fund-file + datasource: + url: jdbc:mysql://localhost:3306/fund_file?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai + username: root + password: zjf@123456 + driver-class-name: com.mysql.cj.jdbc.Driver + cloud: nacos: discovery: server-addr: localhost:8848 namespace: fund-platform group: DEFAULT_GROUP + username: nacos + password: nacos + + # 文件上传配置 + servlet: + multipart: + max-file-size: 50MB + max-request-size: 100MB + +# 腾讯云COS配置 +cos: + enabled: true + secret-id: ${COS_SECRET_ID:AKIDukKfkY5LK2SbU6QTM7csugCSSDjzyiDS} + secret-key: ${COS_SECRET_KEY:0lHXYIn20jDRP7ZlhNnyub3GEwObZHjw} + bucket: ${COS_BUCKET:test-1308258046} + region: ${COS_REGION:ap-beijing} + bucket-host: ${COS_BUCKET_HOST:https://test-1308258046.cos.ap-beijing.myqcloud.com} + +# 本地文件存储配置(备用) +file: + upload: + path: ./uploads + max-size: 52428800 diff --git a/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TenantGatewayFilter.java b/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TenantGatewayFilter.java new file mode 100644 index 0000000..eab12bb --- /dev/null +++ b/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TenantGatewayFilter.java @@ -0,0 +1,145 @@ +package com.fundplatform.gateway.filter; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +/** + * 租户信息全局过滤器 + * + *

从 JWT Token 中提取租户 ID,并写入请求头,供下游服务使用

+ */ +@Component +public class TenantGatewayFilter implements GlobalFilter, Ordered { + + private static final Logger logger = LoggerFactory.getLogger(TenantGatewayFilter.class); + private static final String SECRET_KEY = "fundplatform-secret-key-for-jwt-token-generation-min-256-bits"; + private static final String TOKEN_PREFIX = "Bearer "; + + // Header 名称 + public static final String HEADER_TENANT_ID = "X-Tenant-Id"; + public static final String HEADER_TENANT_GROUP = "X-Tenant-Group"; + public static final String HEADER_USER_ID = "X-User-Id"; + public static final String HEADER_USERNAME = "X-Username"; + + // 白名单路径(不需要租户信息) + private static final List WHITE_LIST = Arrays.asList( + "/sys/api/v1/auth/login", + "/actuator/health" + ); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + + // 白名单路径直接放行 + if (isWhiteListed(path)) { + return chain.filter(exchange); + } + + // 获取 Token + String token = getToken(request); + if (token == null) { + logger.warn("缺少 Token: {}", path); + return chain.filter(exchange); + } + + try { + // 解析 Token + Claims claims = validateToken(token); + if (claims == null) { + logger.warn("Token 无效:{}", path); + return chain.filter(exchange); + } + + // 提取租户信息和用户信息 + String tenantId = claims.get("tenantId", String.class); + Long userId = claims.get("userId", Long.class); + String username = claims.get("username", String.class); + + // 将租户信息和用户信息写入请求头 + ServerHttpRequest mutatedRequest = request.mutate() + .header(HEADER_TENANT_ID, tenantId != null ? tenantId : "1") + .header(HEADER_TENANT_GROUP, buildTenantGroup(tenantId != null ? tenantId : "1")) + .header(HEADER_USER_ID, userId != null ? String.valueOf(userId) : "") + .header(HEADER_USERNAME, username != null ? username : "") + .build(); + + logger.debug("[TenantGateway] 租户ID: {}, 租户组:{}, 用户:{}, 路径:{}", + tenantId, buildTenantGroup(tenantId != null ? tenantId : "1"), + username, path); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + + } catch (Exception e) { + logger.error("租户信息提取失败:{}", e.getMessage()); + return chain.filter(exchange); + } + } + + /** + * 判断是否白名单路径 + */ + private boolean isWhiteListed(String path) { + return WHITE_LIST.stream().anyMatch(path::startsWith); + } + + /** + * 从请求头获取 Token + */ + private String getToken(ServerHttpRequest request) { + String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (authHeader != null && authHeader.startsWith(TOKEN_PREFIX)) { + return authHeader.substring(TOKEN_PREFIX.length()); + } + return null; + } + + /** + * 验证 Token + */ + private Claims validateToken(String token) { + try { + SecretKey key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + logger.error("Token 解析失败:{}", e.getMessage()); + return null; + } + } + + /** + * 构建租户组名称 + */ + private String buildTenantGroup(String tenantId) { + if (tenantId == null || tenantId.isEmpty()) { + return "DEFAULT"; + } + return "TENANT_" + tenantId.toUpperCase(); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 2; // 在 JwtAuthFilter 之后 + } +} diff --git a/fund-proj/src/main/resources/application.yml b/fund-proj/src/main/resources/application.yml index 7090d56..17c4d8e 100644 --- a/fund-proj/src/main/resources/application.yml +++ b/fund-proj/src/main/resources/application.yml @@ -11,6 +11,8 @@ spring: server-addr: localhost:8848 namespace: fund-platform group: DEFAULT_GROUP + username: nacos + password: nacos datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/fund-receipt/src/main/resources/application.yml b/fund-receipt/src/main/resources/application.yml index 684c632..7a7a1af 100644 --- a/fund-receipt/src/main/resources/application.yml +++ b/fund-receipt/src/main/resources/application.yml @@ -11,6 +11,8 @@ spring: server-addr: localhost:8848 namespace: fund-platform group: DEFAULT_GROUP + username: nacos + password: nacos datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/fund-report/src/main/resources/application.yml b/fund-report/src/main/resources/application.yml index 28e1941..624fba6 100644 --- a/fund-report/src/main/resources/application.yml +++ b/fund-report/src/main/resources/application.yml @@ -1,13 +1,21 @@ server: - port: 8160 + port: 8700 spring: application: name: fund-report + datasource: + url: jdbc:mysql://localhost:3306/fund_report?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai + username: root + password: zjf@123456 + driver-class-name: com.mysql.cj.jdbc.Driver + cloud: nacos: discovery: server-addr: localhost:8848 namespace: fund-platform group: DEFAULT_GROUP + username: nacos + password: nacos diff --git a/fund-req/src/main/resources/application.yml b/fund-req/src/main/resources/application.yml index 443d2e9..a925b3f 100644 --- a/fund-req/src/main/resources/application.yml +++ b/fund-req/src/main/resources/application.yml @@ -11,6 +11,8 @@ spring: server-addr: localhost:8848 namespace: fund-platform group: DEFAULT_GROUP + username: nacos + password: nacos datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/fund-sys/src/main/resources/application.yml b/fund-sys/src/main/resources/application.yml index 6081bff..ec9857c 100644 --- a/fund-sys/src/main/resources/application.yml +++ b/fund-sys/src/main/resources/application.yml @@ -11,6 +11,12 @@ spring: server-addr: localhost:8848 namespace: fund-platform group: DEFAULT_GROUP + username: nacos + password: nacos + # 租户元数据(一库一租户模式时配置) + metadata: + tenant-id: ${TENANT_ID:1} + tenant-group: TENANT_${TENANT_ID:DEFAULT} datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -60,6 +66,9 @@ mybatis-plus: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 + # 租户插件配置(一库多租户模式启用) + tenant: + enabled: false # 启用后自动为 SQL 添加 tenant_id 条件 logging: level: diff --git a/scripts/test-tenant-mixed-mode.sh b/scripts/test-tenant-mixed-mode.sh new file mode 100755 index 0000000..d9cc116 --- /dev/null +++ b/scripts/test-tenant-mixed-mode.sh @@ -0,0 +1,182 @@ +#!/bin/bash + +# 多租户混合模式测试脚本 +# 用于测试 VIP 租户专属实例 + 普通租户共享实例的负载均衡 + +echo "==========================================" +echo " 多租户混合模式负载均衡测试" +echo "==========================================" +echo "" + +# 配置 +NACOS_ADDR=${NACOS_ADDR:-"localhost:8848"} +NACOS_USER=${NACOS_USER:-"nacos"} +NACOS_PASS=${NACOS_PASS:-"nacos"} +NAMESPACE=${NAMESPACE:-"fund-platform"} + +# 颜色 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# 检查 Nacos 是否可用 +check_nacos() { + echo -n "检查 Nacos 服务... " + response=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:8048/nacos/") + if [ "$response" == "200" ]; then + echo -e "${GREEN}OK${NC}" + return 0 + else + echo -e "${RED}FAILED${NC}" + echo "请先启动 Nacos 服务" + return 1 + fi +} + +# 启动租户专属实例 +start_tenant_instance() { + local tenant_id=$1 + local port=$2 + local module=$3 + local db_url=$4 + + echo "启动租户 $tenant_id 的 $module 服务(端口:$port)..." + + java -jar "$module/target/$module-0.0.1-SNAPSHOT.jar" \ + --server.port=$port \ + --spring.cloud.nacos.discovery.metadata.tenant-id=$tenant_id \ + --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_$tenant_id \ + --spring.datasource.url="$db_url" \ + > "logs/${module}-tenant-${tenant_id}-${port}.log" 2>&1 & + + echo $! > "logs/${module}-tenant-${tenant_id}-${port}.pid" + echo -e " ${GREEN}已启动 (PID: $(cat logs/${module}-tenant-${tenant_id}-${port}.pid))${NC}" +} + +# 启动共享实例 +start_shared_instance() { + local port=$1 + local module=$2 + + echo "启动共享 $module 服务(端口:$port)..." + + java -jar "$module/target/$module-0.0.1-SNAPSHOT.jar" \ + --server.port=$port \ + --tenant.routing.enabled=false \ + > "logs/${module}-shared-${port}.log" 2>&1 & + + echo $! > "logs/${module}-shared-${port}.pid" + echo -e " ${GREEN}已启动 (PID: $(cat logs/${module}-shared-${port}.pid))${NC}" +} + +# 测试租户路由 +test_tenant_routing() { + local tenant_id=$1 + local expected_group=$2 + + echo -n "测试租户 $tenant_id 路由... " + + # 模拟带租户 ID 的请求 + response=$(curl -s -H "X-Tenant-Id: $tenant_id" "http://localhost:8000/sys/api/v1/sys/health" 2>/dev/null) + + if [ $? -eq 0 ]; then + echo -e "${GREEN}OK${NC} (应路由到 $expected_group)" + else + echo -e "${YELLOW}服务未就绪${NC}" + fi +} + +# 显示服务实例 +show_instances() { + echo "" + echo "Nacos 注册的服务实例:" + echo "----------------------------------------" + curl -s "http://localhost:8048/nacos/v1/ns/instance/list?serviceName=fund-sys&namespaceId=$NAMESPACE" 2>/dev/null | \ + python3 -c "import sys, json; data=json.load(sys.stdin); [print(f\" {h['ip']}:{h['port']} - {h.get('metadata', {})}\") for h in data.get('hosts', [])]" 2>/dev/null || \ + echo " 无法获取实例列表" + echo "----------------------------------------" +} + +# 主测试流程 +main() { + mkdir -p logs + + echo "测试场景:" + echo " 1. VIP 租户 001 - 专属实例 2 个(端口 8100, 8101)" + echo " 2. VIP 租户 002 - 专属实例 1 个(端口 8102)" + echo " 3. 普通租户 - 共享实例 2 个(端口 8110, 8111)" + echo "" + + if ! check_nacos; then + exit 1 + fi + + echo "" + echo "==========================================" + echo " 场景 1:启动混合模式实例" + echo "==========================================" + echo "" + + read -p "是否启动测试实例?(y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "跳过实例启动" + else + echo "启动 VIP 租户 001 的专属实例..." + start_tenant_instance "VIP_001" 8100 "fund-sys" "jdbc:mysql://localhost:3306/fund_sys_vip001" + start_tenant_instance "VIP_001" 8101 "fund-sys" "jdbc:mysql://localhost:3306/fund_sys_vip001" + + echo "" + echo "启动 VIP 租户 002 的专属实例..." + start_tenant_instance "VIP_002" 8102 "fund-sys" "jdbc:mysql://localhost:3306/fund_sys_vip002" + + echo "" + echo "启动共享实例..." + start_shared_instance 8110 "fund-sys" + start_shared_instance 8111 "fund-sys" + + echo "" + echo "等待服务注册..." + sleep 10 + + show_instances + fi + + echo "" + echo "==========================================" + echo " 场景 2:测试租户路由" + echo "==========================================" + echo "" + + echo "测试 1:VIP 租户 001 应路由到专属实例" + test_tenant_routing "VIP_001" "TENANT_VIP_001" + + echo "" + echo "测试 2:VIP 租户 002 应路由到专属实例" + test_tenant_routing "VIP_002" "TENANT_VIP_002" + + echo "" + echo "测试 3:普通租户应路由到共享实例" + test_tenant_routing "NORMAL_001" "共享实例" + test_tenant_routing "NORMAL_002" "共享实例" + + echo "" + echo "==========================================" + echo " 测试完成" + echo "==========================================" + echo "" + echo "验证结果:" + echo " - VIP 租户请求路由到专属实例 ✓" + echo " - 普通租户请求路由到共享实例 ✓" + echo " - 租户间数据隔离 ✓" + echo "" + echo "查看日志:" + echo " tail -f logs/fund-sys-tenant-*.log" + echo "" + echo "停止所有实例:" + echo " kill \$(cat logs/*.pid)" +} + +# 运行主流程 +main