feat: 实现多租户架构完整能力
## 新增功能 ### 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: 负载均衡器单元测试 - 混合模式集成测试脚本
This commit is contained in:
parent
88291b1d46
commit
10eca3fb35
472
doc/多租户架构实现指南.md
Normal file
472
doc/多租户架构实现指南.md
Normal file
@ -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<Project> 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<Project> listProjects() {
|
||||||
|
// 自动添加租户过滤条件
|
||||||
|
Long tenantId = TenantContextHolder.getTenantId();
|
||||||
|
return projectMapper.selectList(new LambdaQueryWrapper<Project>()
|
||||||
|
.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<Void> 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
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36}
|
||||||
|
[TraceId:%X{traceId}, TenantId:%X{tenantId}] - %msg%n</pattern>
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
```
|
||||||
|
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
|
||||||
|
**维护者**: 架构团队
|
||||||
406
doc/多租户配置示例.md
Normal file
406
doc/多租户配置示例.md
Normal file
@ -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<User> listUsers() {
|
||||||
|
// 自动添加租户过滤条件
|
||||||
|
Long tenantId = TenantContextHolder.getTenantId();
|
||||||
|
return userMapper.selectList(new LambdaQueryWrapper<User>()
|
||||||
|
.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
|
||||||
681
doc/开发问题清单.md
Normal file
681
doc/开发问题清单.md
Normal file
@ -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
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||||
|
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<T>` 包装响应,确认字段命名一致 |
|
||||||
|
| 错误码定义 | 明确各类错误码的含义和处理方式 |
|
||||||
|
|
||||||
|
### 前端路由配置
|
||||||
|
|
||||||
|
| 检查项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 代理路径精确匹配 | 使用 `/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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **备注**: 本文档将根据后续开发过程中的问题持续更新。
|
||||||
@ -50,6 +50,34 @@
|
|||||||
<artifactId>jackson-databind</artifactId>
|
<artifactId>jackson-databind</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- EasyExcel -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.afterturn</groupId>
|
||||||
|
<artifactId>easypoi-spring-boot-starter</artifactId>
|
||||||
|
<version>4.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Cloud LoadBalancer (租户负载均衡) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MyBatis Plus (租户插件) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-extension</artifactId>
|
||||||
|
<version>3.5.5</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Test -->
|
<!-- Test -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@ -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<String> sharedServices = Arrays.asList(
|
||||||
|
"fund-gateway",
|
||||||
|
"fund-report",
|
||||||
|
"fund-file"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 默认租户 ID(当未指定时使用) */
|
||||||
|
private String defaultTenantId = "1";
|
||||||
|
|
||||||
|
/** 租户服务配置映射 */
|
||||||
|
private Map<String, TenantServiceConfig> 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<String> getSharedServices() {
|
||||||
|
return sharedServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSharedServices(List<String> sharedServices) {
|
||||||
|
this.sharedServices = sharedServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDefaultTenantId() {
|
||||||
|
return defaultTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDefaultTenantId(String defaultTenantId) {
|
||||||
|
this.defaultTenantId = defaultTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, TenantServiceConfig> getTenantConfigs() {
|
||||||
|
return tenantConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantConfigs(Map<String, TenantServiceConfig> 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<String, ServiceInstanceConfig> services = new HashMap<>();
|
||||||
|
|
||||||
|
/** 数据库配置(一库一租户模式) */
|
||||||
|
private DatabaseConfig database;
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public String getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTenantId(String tenantId) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, ServiceInstanceConfig> getServices() {
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServices(Map<String, ServiceInstanceConfig> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户感知的负载均衡器
|
||||||
|
*
|
||||||
|
* <p>根据租户 ID 进行服务实例路由,支持:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>租户专属实例优先</li>
|
||||||
|
* <li>共享实例回退</li>
|
||||||
|
* <li>轮询负载均衡</li>
|
||||||
|
* <li>混合模式(VIP 租户专属 + 普通租户共享)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>使用场景</h3>
|
||||||
|
* <pre>
|
||||||
|
* 混合模式部署:
|
||||||
|
* - VIP 客户:独立部署服务实例(带 tenant-group 标签)
|
||||||
|
* - 普通客户:共享服务实例(无 tenant-group 标签)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TenantAwareLoadBalancer.class);
|
||||||
|
|
||||||
|
private final String serviceId;
|
||||||
|
private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;
|
||||||
|
|
||||||
|
public TenantAwareLoadBalancer(String serviceId,
|
||||||
|
ObjectProvider<ServiceInstanceListSupplier> supplierProvider) {
|
||||||
|
this.serviceId = serviceId;
|
||||||
|
this.supplierProvider = supplierProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Response<ServiceInstance>> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据租户组过滤服务实例
|
||||||
|
*
|
||||||
|
* <p>路由策略:</p>
|
||||||
|
* <ol>
|
||||||
|
* <li>优先选择租户专属实例(metadata.tenant-group 匹配)</li>
|
||||||
|
* <li>回退到共享实例(无 tenant-group 标签)</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
List<ServiceInstance> filterByTenantGroup(List<ServiceInstance> instances, String tenantGroup) {
|
||||||
|
if (instances == null || instances.isEmpty()) {
|
||||||
|
return instances;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("[TenantLB] 租户组:{},候选实例数:{}", tenantGroup, instances.size());
|
||||||
|
|
||||||
|
// 优先选择租户专属实例
|
||||||
|
List<ServiceInstance> tenantInstances = instances.stream()
|
||||||
|
.filter(inst -> {
|
||||||
|
Map<String, String> 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<ServiceInstance> sharedInstances = instances.stream()
|
||||||
|
.filter(inst -> {
|
||||||
|
Map<String, String> metadata = inst.getMetadata();
|
||||||
|
return metadata == null || !metadata.containsKey("tenant-group");
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
logger.info("[TenantLB] 未找到租户专属实例,使用 {} 个共享实例", sharedInstances.size());
|
||||||
|
return sharedInstances;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从实例列表中选择一个(轮询)
|
||||||
|
*/
|
||||||
|
private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 租户插件自动配置
|
||||||
|
*
|
||||||
|
* <p>启用条件:配置 mybatis-plus.tenant.enabled=true</p>
|
||||||
|
*
|
||||||
|
* <p>功能:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>自动为 SQL 添加租户过滤条件</li>
|
||||||
|
* <li>支持分页插件</li>
|
||||||
|
* <li>可配置忽略的表</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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 拦截器配置
|
||||||
|
*
|
||||||
|
* <p>包含租户过滤和分页功能</p>
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户忽略工具类
|
||||||
|
*
|
||||||
|
* <p>在需要跳过租户过滤的场景使用,例如:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>管理员查看所有租户数据</li>
|
||||||
|
* <li>数据迁移场景</li>
|
||||||
|
* <li>跨租户查询</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>使用方式:</p>
|
||||||
|
* <pre>
|
||||||
|
* // 方式 1:使用 Lambda
|
||||||
|
* List<User> allUsers = TenantIgnoreHelper.ignore(() -> userMapper.selectList(null));
|
||||||
|
*
|
||||||
|
* // 方式 2:使用代码块
|
||||||
|
* TenantIgnoreHelper.ignore(() -> {
|
||||||
|
* // 这里执行的 SQL 不会添加租户过滤
|
||||||
|
* userMapper.insert(user);
|
||||||
|
* });
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class TenantIgnoreHelper {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TenantIgnoreHelper.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在忽略租户过滤的上下文中执行
|
||||||
|
*
|
||||||
|
* @param supplier 要执行的操作
|
||||||
|
* @param <T> 返回类型
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
public static <T> T ignore(Supplier<T> 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] 恢复租户过滤");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 租户行处理器
|
||||||
|
*
|
||||||
|
* <p>自动为 SQL 语句添加租户过滤条件,实现多租户数据隔离</p>
|
||||||
|
*
|
||||||
|
* <p>使用方式:</p>
|
||||||
|
* <pre>
|
||||||
|
* // 在 MyBatis 配置类中注册
|
||||||
|
* @Bean
|
||||||
|
* public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
|
* MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
|
* interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandlerImpl()));
|
||||||
|
* return interceptor;
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class TenantLineHandlerImpl implements TenantLineHandler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TenantLineHandlerImpl.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 忽略租户过滤的表(系统表、字典表等公共数据)
|
||||||
|
*/
|
||||||
|
private static final Set<String> 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 值
|
||||||
|
*
|
||||||
|
* <p>从 TenantContextHolder 获取当前线程的租户 ID</p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 服务注册元数据配置
|
||||||
|
*
|
||||||
|
* <p>服务启动时自动注册租户标签到 Nacos,支持租户感知的负载均衡</p>
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <T> void exportExcel(List<T> list, String title, String sheetName,
|
||||||
|
Class<T> 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 <T> void exportExcel(List<T> list, String title, String sheetName,
|
||||||
|
Class<T> 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 <T> List<T> importExcel(Object file, Class<T> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ServiceInstance> 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<ServiceInstance> 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<ServiceInstance> tenant2Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_2");
|
||||||
|
assertEquals(1, tenant2Instances.size());
|
||||||
|
|
||||||
|
// 测试未知租户(回退到共享实例)
|
||||||
|
List<ServiceInstance> unknownTenantInstances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_UNKNOWN");
|
||||||
|
assertEquals(1, unknownTenantInstances.size());
|
||||||
|
assertFalse(unknownTenantInstances.get(0).getMetadata().containsKey("tenant-group"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testMixedMode() {
|
||||||
|
// 混合模式:VIP 客户有专属实例,普通客户使用共享实例
|
||||||
|
List<ServiceInstance> 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<ServiceInstance> vip1Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_VIP_001");
|
||||||
|
assertEquals(2, vip1Instances.size());
|
||||||
|
|
||||||
|
// VIP 租户 002 应该路由到专属实例
|
||||||
|
List<ServiceInstance> vip2Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_VIP_002");
|
||||||
|
assertEquals(1, vip2Instances.size());
|
||||||
|
|
||||||
|
// 普通租户应该路由到共享实例
|
||||||
|
List<ServiceInstance> normalInstances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_NORMAL");
|
||||||
|
assertEquals(2, normalInstances.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助方法:创建服务实例
|
||||||
|
private ServiceInstance createInstance(String serviceId, String host, int port, Map<String, String> 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<ServiceInstance> invokeFilterByTenantGroup(TenantAwareLoadBalancer lb, List<ServiceInstance> instances, String tenantGroup) {
|
||||||
|
try {
|
||||||
|
var method = TenantAwareLoadBalancer.class.getDeclaredMethod("filterByTenantGroup", List.class, String.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
return (List<ServiceInstance>) method.invoke(lb, instances, tenantGroup);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ spring:
|
|||||||
server-addr: localhost:8848
|
server-addr: localhost:8848
|
||||||
namespace: fund-platform
|
namespace: fund-platform
|
||||||
group: DEFAULT_GROUP
|
group: DEFAULT_GROUP
|
||||||
|
username: nacos
|
||||||
|
password: nacos
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|||||||
@ -25,7 +25,7 @@ public class FundExpense extends BaseEntity {
|
|||||||
private String currency;
|
private String currency;
|
||||||
|
|
||||||
/** 支出类型(1-日常支出 2-项目支出 3-工资发放 4-其他) */
|
/** 支出类型(1-日常支出 2-项目支出 3-工资发放 4-其他) */
|
||||||
private Integer expenseType;
|
private Long expenseType;
|
||||||
|
|
||||||
/** 收款单位 */
|
/** 收款单位 */
|
||||||
private String payeeName;
|
private String payeeName;
|
||||||
@ -110,11 +110,11 @@ public class FundExpense extends BaseEntity {
|
|||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getExpenseType() {
|
public Long getExpenseType() {
|
||||||
return expenseType;
|
return expenseType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setExpenseType(Integer expenseType) {
|
public void setExpenseType(Long expenseType) {
|
||||||
this.expenseType = expenseType;
|
this.expenseType = expenseType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,11 @@ import org.springframework.util.StringUtils;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
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;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -114,7 +119,7 @@ public class FundExpenseServiceImpl implements FundExpenseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<FundExpenseVO> pageExpenses(int pageNum, int pageSize, String title, Integer expenseType, Integer payStatus, Integer approvalStatus) {
|
public Page<FundExpenseVO> pageExpenses(int pageNum, int pageSize, String title, Long expenseType, Integer payStatus, Integer approvalStatus) {
|
||||||
Page<FundExpense> page = new Page<>(pageNum, pageSize);
|
Page<FundExpense> page = new Page<>(pageNum, pageSize);
|
||||||
LambdaQueryWrapper<FundExpense> wrapper = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<FundExpense> wrapper = new LambdaQueryWrapper<>();
|
||||||
wrapper.eq(FundExpense::getDeleted, 0);
|
wrapper.eq(FundExpense::getDeleted, 0);
|
||||||
@ -353,9 +358,9 @@ public class FundExpenseServiceImpl implements FundExpenseService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getExpenseTypeName(Integer type) {
|
private String getExpenseTypeName(Long type) {
|
||||||
if (type == null) return "";
|
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) {
|
private String getPayStatusName(Integer status) {
|
||||||
@ -378,4 +383,78 @@ public class FundExpenseServiceImpl implements FundExpenseService {
|
|||||||
default -> "";
|
default -> "";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== 统计方法实现 =====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BigDecimal getPendingAmount() {
|
||||||
|
LambdaQueryWrapper<FundExpense> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(FundExpense::getDeleted, 0)
|
||||||
|
.eq(FundExpense::getApprovalStatus, APPROVAL_IN_PROGRESS);
|
||||||
|
|
||||||
|
List<FundExpense> pendingExpenses = expenseDataService.list(wrapper);
|
||||||
|
return pendingExpenses.stream()
|
||||||
|
.map(FundExpense::getAmount)
|
||||||
|
.filter(a -> a != null)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getPendingCount() {
|
||||||
|
LambdaQueryWrapper<FundExpense> 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<FundExpense> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(FundExpense::getDeleted, 0)
|
||||||
|
.eq(FundExpense::getPayStatus, PAY_PAID)
|
||||||
|
.ge(FundExpense::getPayTime, startOfDay)
|
||||||
|
.lt(FundExpense::getPayTime, endOfDay);
|
||||||
|
|
||||||
|
List<FundExpense> todayExpenses = expenseDataService.list(wrapper);
|
||||||
|
return todayExpenses.stream()
|
||||||
|
.map(FundExpense::getAmount)
|
||||||
|
.filter(a -> a != null)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> getTypeDistribution() {
|
||||||
|
LambdaQueryWrapper<FundExpense> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(FundExpense::getDeleted, 0)
|
||||||
|
.eq(FundExpense::getPayStatus, PAY_PAID);
|
||||||
|
|
||||||
|
List<FundExpense> expenses = expenseDataService.list(wrapper);
|
||||||
|
|
||||||
|
Map<Long, BigDecimal> typeAmountMap = new HashMap<>();
|
||||||
|
Map<Long, Integer> 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<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (Long type : typeAmountMap.keySet()) {
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ spring:
|
|||||||
server-addr: localhost:8848
|
server-addr: localhost:8848
|
||||||
namespace: fund-platform
|
namespace: fund-platform
|
||||||
group: DEFAULT_GROUP
|
group: DEFAULT_GROUP
|
||||||
|
username: nacos
|
||||||
|
password: nacos
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|||||||
@ -1,13 +1,42 @@
|
|||||||
server:
|
server:
|
||||||
port: 8170
|
port: 8600
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: fund-file
|
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:
|
cloud:
|
||||||
nacos:
|
nacos:
|
||||||
discovery:
|
discovery:
|
||||||
server-addr: localhost:8848
|
server-addr: localhost:8848
|
||||||
namespace: fund-platform
|
namespace: fund-platform
|
||||||
group: DEFAULT_GROUP
|
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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户信息全局过滤器
|
||||||
|
*
|
||||||
|
* <p>从 JWT Token 中提取租户 ID,并写入请求头,供下游服务使用</p>
|
||||||
|
*/
|
||||||
|
@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<String> WHITE_LIST = Arrays.asList(
|
||||||
|
"/sys/api/v1/auth/login",
|
||||||
|
"/actuator/health"
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> 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 之后
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ spring:
|
|||||||
server-addr: localhost:8848
|
server-addr: localhost:8848
|
||||||
namespace: fund-platform
|
namespace: fund-platform
|
||||||
group: DEFAULT_GROUP
|
group: DEFAULT_GROUP
|
||||||
|
username: nacos
|
||||||
|
password: nacos
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|||||||
@ -11,6 +11,8 @@ spring:
|
|||||||
server-addr: localhost:8848
|
server-addr: localhost:8848
|
||||||
namespace: fund-platform
|
namespace: fund-platform
|
||||||
group: DEFAULT_GROUP
|
group: DEFAULT_GROUP
|
||||||
|
username: nacos
|
||||||
|
password: nacos
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|||||||
@ -1,13 +1,21 @@
|
|||||||
server:
|
server:
|
||||||
port: 8160
|
port: 8700
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: fund-report
|
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:
|
cloud:
|
||||||
nacos:
|
nacos:
|
||||||
discovery:
|
discovery:
|
||||||
server-addr: localhost:8848
|
server-addr: localhost:8848
|
||||||
namespace: fund-platform
|
namespace: fund-platform
|
||||||
group: DEFAULT_GROUP
|
group: DEFAULT_GROUP
|
||||||
|
username: nacos
|
||||||
|
password: nacos
|
||||||
|
|||||||
@ -11,6 +11,8 @@ spring:
|
|||||||
server-addr: localhost:8848
|
server-addr: localhost:8848
|
||||||
namespace: fund-platform
|
namespace: fund-platform
|
||||||
group: DEFAULT_GROUP
|
group: DEFAULT_GROUP
|
||||||
|
username: nacos
|
||||||
|
password: nacos
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|||||||
@ -11,6 +11,12 @@ spring:
|
|||||||
server-addr: localhost:8848
|
server-addr: localhost:8848
|
||||||
namespace: fund-platform
|
namespace: fund-platform
|
||||||
group: DEFAULT_GROUP
|
group: DEFAULT_GROUP
|
||||||
|
username: nacos
|
||||||
|
password: nacos
|
||||||
|
# 租户元数据(一库一租户模式时配置)
|
||||||
|
metadata:
|
||||||
|
tenant-id: ${TENANT_ID:1}
|
||||||
|
tenant-group: TENANT_${TENANT_ID:DEFAULT}
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@ -60,6 +66,9 @@ mybatis-plus:
|
|||||||
logic-delete-field: deleted
|
logic-delete-field: deleted
|
||||||
logic-delete-value: 1
|
logic-delete-value: 1
|
||||||
logic-not-delete-value: 0
|
logic-not-delete-value: 0
|
||||||
|
# 租户插件配置(一库多租户模式启用)
|
||||||
|
tenant:
|
||||||
|
enabled: false # 启用后自动为 SQL 添加 tenant_id 条件
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
182
scripts/test-tenant-mixed-mode.sh
Executable file
182
scripts/test-tenant-mixed-mode.sh
Executable file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user