2978 lines
128 KiB
Markdown
2978 lines
128 KiB
Markdown
# 资金服务平台 (FundPlatform) - 架构设计文档
|
||
|
||
> **文档版本**: v1.0
|
||
> **创建日期**: 2026-02-13
|
||
> **项目名称**: 资金服务平台
|
||
> **项目代号**: fundplatform
|
||
|
||
---
|
||
|
||
## 一、架构设计概述
|
||
|
||
### 1.1 设计目标
|
||
|
||
| 目标 | 描述 |
|
||
|------|------|
|
||
| **高可用性** | 系统可用性≥99.5%,支持故障自动恢复 |
|
||
| **高性能** | 支持≥100人并发,接口响应<3秒 |
|
||
| **可扩展性** | 微服务架构,支持水平扩展 |
|
||
| **安全性** | 数据加密传输,完善的权限控制 |
|
||
| **可维护性** | 模块化设计,代码结构清晰 |
|
||
| **多租户** | 支持一库多租户和一库一租户两种模式 |
|
||
| **可观测性** | 全链路日志跟踪,支持 Head 日志追踪 |
|
||
|
||
### 1.2 架构风格
|
||
|
||
采用 **微服务架构** + **前后端分离** + **多租户架构** 模式:
|
||
- 后端:Spring Cloud Alibaba 微服务框架
|
||
- 前端:Vue 3 + UniApp 多端应用
|
||
- 数据层:MySQL + Redis 缓存(支持多租户隔离)
|
||
- 基础设施:Nacos 服务治理、Nginx 负载均衡
|
||
- 可观测性:Head 日志追踪 + 全链路监控
|
||
|
||
---
|
||
|
||
## 二、多租户架构设计
|
||
|
||
### 2.1 多租户架构概述
|
||
|
||
系统支持两种多租户模式,可根据业务需求灵活选择:
|
||
|
||
| 模式 | 说明 | 适用场景 |
|
||
|------|------|----------|
|
||
| **一库多租户** | 所有租户共享一个数据库,通过 `tenant_id` 字段隔离 | 中小租户、数据量较小、成本敏感 |
|
||
| **一库一租户** | 每个租户独立数据库,物理隔离 | 大客户、数据量大、安全要求高 |
|
||
|
||
### 2.2 一库多租户模式
|
||
|
||
#### 2.2.1 架构设计
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 应用服务层 │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ fund-sys │ │ fund-cust │ │ fund-proj │ │
|
||
│ │ (多租户) │ │ (多租户) │ │ (多租户) │ │
|
||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||
└─────────┼────────────────┼────────────────┼─────────────────┘
|
||
│ │ │
|
||
└────────────────┴────────────────┘
|
||
│
|
||
┌───────────────┴───────────────┐
|
||
│ TenantContextHolder │
|
||
│ (线程级租户上下文存储) │
|
||
└───────────────┬───────────────┘
|
||
│
|
||
┌─────────────────────────┼─────────────────────────────────────┐
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ MySQL 数据库 │ │
|
||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||
│ │ │ fund_platform 库 │ │ │
|
||
│ │ │ ┌───────────┬───────────┬───────────┬─────────┐ │ │ │
|
||
│ │ │ │ tenant_id │ user_id │ username │ ... │ │ │ │
|
||
│ │ │ ├───────────┼───────────┼───────────┼─────────┤ │ │ │
|
||
│ │ │ │ 1 │ 1 │ admin │ ... │ │ │ │
|
||
│ │ │ │ 1 │ 2 │ user1 │ ... │ │ │ │
|
||
│ │ │ │ 2 │ 1 │ admin │ ... │ │ │ │
|
||
│ │ │ └───────────┴───────────┴───────────┴─────────┘ │ │ │
|
||
│ │ │ 所有表包含 tenant_id 字段用于数据隔离 │ │ │
|
||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 2.2.2 技术实现
|
||
|
||
**1. 租户识别**
|
||
|
||
```java
|
||
/**
|
||
* 租户上下文持有者
|
||
*/
|
||
public class TenantContextHolder {
|
||
private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();
|
||
|
||
public static void setTenantId(Long tenantId) {
|
||
CURRENT_TENANT.set(tenantId);
|
||
}
|
||
|
||
public static Long getTenantId() {
|
||
return CURRENT_TENANT.get();
|
||
}
|
||
|
||
public static void clear() {
|
||
CURRENT_TENANT.remove();
|
||
}
|
||
}
|
||
```
|
||
|
||
**2. 租户拦截器**
|
||
|
||
```java
|
||
/**
|
||
* 租户拦截器 - 从请求头或JWT中提取租户ID
|
||
*/
|
||
@Component
|
||
public class TenantInterceptor implements HandlerInterceptor {
|
||
|
||
@Override
|
||
public boolean preHandle(HttpServletRequest request,
|
||
HttpServletResponse response,
|
||
Object handler) {
|
||
// 从请求头获取租户ID
|
||
String tenantId = request.getHeader("X-Tenant-Id");
|
||
|
||
// 或从JWT Token中解析
|
||
if (StringUtils.isEmpty(tenantId)) {
|
||
String token = request.getHeader("Authorization");
|
||
tenantId = JwtUtil.getTenantId(token);
|
||
}
|
||
|
||
if (StringUtils.isNotEmpty(tenantId)) {
|
||
TenantContextHolder.setTenantId(Long.valueOf(tenantId));
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
@Override
|
||
public void afterCompletion(HttpServletRequest request,
|
||
HttpServletResponse response,
|
||
Object handler, Exception ex) {
|
||
TenantContextHolder.clear();
|
||
}
|
||
}
|
||
```
|
||
|
||
**3. MyBatis-Plus 租户插件**
|
||
|
||
```java
|
||
/**
|
||
* 多租户 SQL 拦截器
|
||
*/
|
||
@Component
|
||
public class TenantLineInnerInterceptor implements InnerInterceptor {
|
||
|
||
@Override
|
||
public void beforeQuery(Executor executor,
|
||
MappedStatement ms,
|
||
Object parameter,
|
||
RowBounds rowBounds,
|
||
ResultHandler resultHandler,
|
||
BoundSql boundSql) {
|
||
// 获取当前租户ID
|
||
Long tenantId = TenantContextHolder.getTenantId();
|
||
if (tenantId == null) {
|
||
return;
|
||
}
|
||
|
||
// 获取SQL
|
||
String originalSql = boundSql.getSql();
|
||
|
||
// 忽略特定表(如租户表本身)
|
||
if (isIgnoreTable(ms.getId())) {
|
||
return;
|
||
}
|
||
|
||
// 添加租户条件
|
||
String tenantSql = addTenantCondition(originalSql, tenantId);
|
||
|
||
// 重写SQL
|
||
reflectSetField(boundSql, "sql", tenantSql);
|
||
}
|
||
|
||
private String addTenantCondition(String sql, Long tenantId) {
|
||
// 使用 JSqlParser 解析并修改 SQL
|
||
try {
|
||
Statement statement = CCJSqlParserUtil.parse(sql);
|
||
if (statement instanceof Select) {
|
||
Select select = (Select) statement;
|
||
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
|
||
|
||
// 添加 WHERE 条件
|
||
EqualsTo equalsTo = new EqualsTo();
|
||
equalsTo.setLeftExpression(new Column("tenant_id"));
|
||
equalsTo.setRightExpression(new LongValue(tenantId));
|
||
|
||
Expression where = plainSelect.getWhere();
|
||
if (where == null) {
|
||
plainSelect.setWhere(equalsTo);
|
||
} else {
|
||
AndExpression and = new AndExpression(where, equalsTo);
|
||
plainSelect.setWhere(and);
|
||
}
|
||
|
||
return select.toString();
|
||
}
|
||
} catch (JSQLParserException e) {
|
||
log.error("SQL解析失败", e);
|
||
}
|
||
return sql;
|
||
}
|
||
}
|
||
```
|
||
|
||
**4. 实体基类**
|
||
|
||
```java
|
||
/**
|
||
* 多租户实体基类
|
||
*/
|
||
@Data
|
||
public abstract class TenantBaseEntity extends BaseEntity {
|
||
|
||
@TableField(fill = FieldFill.INSERT)
|
||
private Long tenantId;
|
||
}
|
||
```
|
||
|
||
#### 2.2.3 数据隔离策略
|
||
|
||
| 隔离级别 | 实现方式 | 说明 |
|
||
|----------|----------|------|
|
||
| **行级隔离** | tenant_id 字段过滤 | 默认方式,所有业务表包含 tenant_id |
|
||
| **schema隔离** | 不同租户使用不同schema | 可选,适用于数据量大的租户 |
|
||
| **缓存隔离** | Redis Key 添加租户前缀 | `tenant:{id}:user:info` |
|
||
| **文件隔离** | COS 目录按租户划分 | `/tenant/{id}/files/` |
|
||
|
||
### 2.3 一库一租户模式
|
||
|
||
#### 2.3.1 架构设计
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 应用服务层 │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ fund-sys │ │ fund-cust │ │ fund-proj │ │
|
||
│ │ (多租户) │ │ (多租户) │ │ (多租户) │ │
|
||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||
└─────────┼────────────────┼────────────────┼─────────────────┘
|
||
│ │ │
|
||
└────────────────┴────────────────┘
|
||
│
|
||
┌───────────────┴───────────────┐
|
||
│ DynamicDataSource │
|
||
│ (动态数据源路由) │
|
||
└───────────────┬───────────────┘
|
||
│
|
||
┌───────────────┼───────────────┐
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||
│ tenant_1_db │ │ tenant_2_db │ │ tenant_3_db │
|
||
│ (租户1数据库) │ │ (租户2数据库) │ │ (租户3数据库) │
|
||
│ │ │ │ │ │
|
||
│ • sys_user │ │ • sys_user │ │ • sys_user │
|
||
│ • customer │ │ • customer │ │ • customer │
|
||
│ • project │ │ • project │ │ • project │
|
||
│ • expense │ │ • expense │ │ • expense │
|
||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||
```
|
||
|
||
#### 2.3.2 技术实现
|
||
|
||
**1. 动态数据源配置**
|
||
|
||
```java
|
||
/**
|
||
* 动态数据源上下文
|
||
*/
|
||
public class DynamicDataSourceContextHolder {
|
||
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
|
||
|
||
public static void setDataSourceKey(String key) {
|
||
CONTEXT_HOLDER.set(key);
|
||
}
|
||
|
||
public static String getDataSourceKey() {
|
||
return CONTEXT_HOLDER.get();
|
||
}
|
||
|
||
public static void clear() {
|
||
CONTEXT_HOLDER.remove();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 动态数据源
|
||
*/
|
||
public class DynamicDataSource extends AbstractRoutingDataSource {
|
||
|
||
@Override
|
||
protected Object determineCurrentLookupKey() {
|
||
return DynamicDataSourceContextHolder.getDataSourceKey();
|
||
}
|
||
}
|
||
```
|
||
|
||
**2. 数据源管理器**
|
||
|
||
```java
|
||
/**
|
||
* 租户数据源管理器
|
||
*/
|
||
@Component
|
||
public class TenantDataSourceManager {
|
||
|
||
@Autowired
|
||
private DynamicDataSource dynamicDataSource;
|
||
|
||
@Autowired
|
||
private TenantDataSourceConfigRepository configRepository;
|
||
|
||
/**
|
||
* 添加租户数据源
|
||
*/
|
||
public void addDataSource(Long tenantId) {
|
||
TenantDataSourceConfig config = configRepository.findByTenantId(tenantId);
|
||
|
||
HikariConfig hikariConfig = new HikariConfig();
|
||
hikariConfig.setDriverClassName(config.getDriverClassName());
|
||
hikariConfig.setJdbcUrl(config.getJdbcUrl());
|
||
hikariConfig.setUsername(config.getUsername());
|
||
hikariConfig.setPassword(config.getPassword());
|
||
|
||
// HikariCP 优化配置
|
||
hikariConfig.setPoolName("TenantPool-" + tenantId);
|
||
hikariConfig.setMinimumIdle(5);
|
||
hikariConfig.setMaximumPoolSize(20);
|
||
hikariConfig.setIdleTimeout(300000);
|
||
hikariConfig.setConnectionTimeout(20000);
|
||
hikariConfig.setMaxLifetime(1200000);
|
||
hikariConfig.setConnectionTestQuery("SELECT 1");
|
||
|
||
// 性能优化配置
|
||
hikariConfig.addDataSourceProperty("cachePrepStmts", "true");
|
||
hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250");
|
||
hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
|
||
hikariConfig.addDataSourceProperty("useServerPrepStmts", "true");
|
||
hikariConfig.addDataSourceProperty("useLocalSessionState", "true");
|
||
hikariConfig.addDataSourceProperty("rewriteBatchedStatements", "true");
|
||
hikariConfig.addDataSourceProperty("cacheResultSetMetadata", "true");
|
||
hikariConfig.addDataSourceProperty("cacheServerConfiguration", "true");
|
||
hikariConfig.addDataSourceProperty("elideSetAutoCommits", "true");
|
||
hikariConfig.addDataSourceProperty("maintainTimeStats", "false");
|
||
|
||
HikariDataSource dataSource = new HikariDataSource(hikariConfig);
|
||
|
||
// 添加到动态数据源
|
||
dynamicDataSource.addDataSource("tenant_" + tenantId, dataSource);
|
||
}
|
||
|
||
/**
|
||
* 切换租户数据源
|
||
*/
|
||
public void switchDataSource(Long tenantId) {
|
||
String key = "tenant_" + tenantId;
|
||
|
||
// 检查数据源是否存在
|
||
if (!dynamicDataSource.hasDataSource(key)) {
|
||
addDataSource(tenantId);
|
||
}
|
||
|
||
DynamicDataSourceContextHolder.setDataSourceKey(key);
|
||
}
|
||
}
|
||
```
|
||
|
||
**3. 租户数据源拦截器**
|
||
|
||
```java
|
||
/**
|
||
* 租户数据源切换拦截器
|
||
*/
|
||
@Component
|
||
public class TenantDataSourceInterceptor implements HandlerInterceptor {
|
||
|
||
@Autowired
|
||
private TenantDataSourceManager dataSourceManager;
|
||
|
||
@Override
|
||
public boolean preHandle(HttpServletRequest request,
|
||
HttpServletResponse response,
|
||
Object handler) {
|
||
// 获取租户ID
|
||
String tenantId = request.getHeader("X-Tenant-Id");
|
||
|
||
if (StringUtils.isNotEmpty(tenantId)) {
|
||
// 切换数据源
|
||
dataSourceManager.switchDataSource(Long.valueOf(tenantId));
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
@Override
|
||
public void afterCompletion(HttpServletRequest request,
|
||
HttpServletResponse response,
|
||
Object handler, Exception ex) {
|
||
DynamicDataSourceContextHolder.clear();
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.4 租户管理
|
||
|
||
#### 2.4.1 租户表设计
|
||
|
||
```sql
|
||
CREATE TABLE sys_tenant (
|
||
tenant_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '租户ID',
|
||
tenant_code VARCHAR(50) NOT NULL UNIQUE COMMENT '租户编码',
|
||
tenant_name VARCHAR(100) NOT NULL COMMENT '租户名称',
|
||
tenant_type TINYINT DEFAULT 1 COMMENT '租户类型:1-一库多租户,2-一库一租户',
|
||
db_config JSON COMMENT '数据库配置(一库一租户模式使用)',
|
||
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
|
||
expire_time DATETIME COMMENT '过期时间',
|
||
created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
INDEX idx_code (tenant_code),
|
||
INDEX idx_status (status)
|
||
) COMMENT='租户表';
|
||
```
|
||
|
||
#### 2.4.2 租户创建流程
|
||
|
||
```
|
||
┌─────────┐ 创建租户 ┌─────────┐
|
||
│ 管理员 │ ───────────────> │ 系统 │
|
||
└─────────┘ └────┬────┘
|
||
│
|
||
┌─────────────┼─────────────┐
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||
│ 保存租户 │ │ 初始化数据 │ │ 创建数据源 │
|
||
│ 基本信息 │ │ (菜单/角色)│ │ (一库一租户)│
|
||
└───────────┘ └───────────┘ └───────────┘
|
||
```
|
||
|
||
### 2.5 一库一租户路由方案对比
|
||
|
||
#### 2.5.1 方案对比:DynamicDataSource vs Feign 动态路由
|
||
|
||
| 对比维度 | DynamicDataSource | Feign 动态路由 |
|
||
|----------|-------------------|----------------|
|
||
| **架构层级** | 数据层 | 服务层 |
|
||
| **隔离级别** | 数据源级别 | 服务实例级别 |
|
||
| **资源占用** | 高(连接池 × N租户) | 中(服务实例 × N租户) |
|
||
| **扩展性** | 差(单服务多数据源) | 优(独立扩容) |
|
||
| **故障隔离** | 差(单点故障影响多租户) | 优(租户间互不影响) |
|
||
| **跨租户查询** | 困难 | 可通过聚合服务实现 |
|
||
| **服务治理** | 简单 | 需要完善的服务发现 |
|
||
| **部署复杂度** | 低 | 中 |
|
||
| **私有化部署** | 困难 | 简单(整体迁移) |
|
||
| **运维成本** | 中 | 高 |
|
||
|
||
#### 2.5.2 推荐方案:Feign 动态路由
|
||
|
||
**推荐理由:**
|
||
1. **真正的租户隔离**:每个租户拥有独立的服务实例和数据库
|
||
2. **弹性扩容**:单个租户可独立水平扩展
|
||
3. **故障隔离**:单个租户故障不影响其他租户
|
||
4. **私有化友好**:便于大客户私有化部署
|
||
5. **符合云原生**:与 Kubernetes 等容器平台配合良好
|
||
|
||
---
|
||
|
||
### 2.6 Feign 动态路由详细设计
|
||
|
||
#### 2.6.1 架构设计
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Feign 动态路由架构 │
|
||
│ │
|
||
│ ┌─────────┐ │
|
||
│ │ 客户端 │ Header: X-Tenant-Id: tenant_001 │
|
||
│ └────┬────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Gateway (网关) │ │
|
||
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
|
||
│ │ │ TenantRoutingFilter │ │ │
|
||
│ │ │ • 解析 X-Tenant-Id │ │ │
|
||
│ │ │ • 根据租户ID路由到对应服务组 │ │ │
|
||
│ │ │ • 添加 X-Tenant-Route 标记 │ │ │
|
||
│ │ └─────────────────────────────────────────────────────────────┘ │ │
|
||
│ └────────────────────────────────┬───────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌───────────────────────────┼───────────────────────────┐ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Nacos 服务注册中心 │ │
|
||
│ │ │ │
|
||
│ │ 服务分组设计: │ │
|
||
│ │ • fund-sys:DEFAULT (共享服务组) │ │
|
||
│ │ • fund-sys:TENANT_001 (租户001专属) │ │
|
||
│ │ • fund-sys:TENANT_002 (租户002专属) │ │
|
||
│ │ • fund-cust:TENANT_001 │ │
|
||
│ │ • ... │ │
|
||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌───────────────────────────┼───────────────────────────┐ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ 租户001服务组 │ │ 租户002服务组 │ │ 租户003服务组 │ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │
|
||
│ │ │fund-sys │ │ │ │fund-sys │ │ │ │fund-sys │ │ │
|
||
│ │ │:8100 │ │ │ │:8101 │ │ │ │:8102 │ │ │
|
||
│ │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │
|
||
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │
|
||
│ │ │fund-cust │ │ │ │fund-cust │ │ │ │fund-cust │ │ │
|
||
│ │ │:8110 │ │ │ │:8111 │ │ │ │:8112 │ │ │
|
||
│ │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │
|
||
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │
|
||
│ │ │fund-proj │ │ │ │fund-proj │ │ │ │fund-proj │ │ │
|
||
│ │ │:8120 │ │ │ │:8121 │ │ │ │:8122 │ │ │
|
||
│ │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │
|
||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||
│ │ │ │ │
|
||
│ └─────────────────────────┼─────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌───────────────────────────┼───────────────────────────┐ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │tenant_001│ │tenant_002│ │tenant_003│ │
|
||
│ │ _db │ │ _db │ │ _db │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 2.6.2 核心组件实现
|
||
|
||
**1. 租户路由元数据管理**
|
||
|
||
```java
|
||
/**
|
||
* 租户路由元数据
|
||
*/
|
||
@Data
|
||
@Component
|
||
@ConfigurationProperties(prefix = "tenant.routing")
|
||
public class TenantRoutingProperties {
|
||
|
||
/** 是否启用租户路由 */
|
||
private boolean enabled = true;
|
||
|
||
/** 租户ID请求头 */
|
||
private String tenantHeader = "X-Tenant-Id";
|
||
|
||
/** 服务组分隔符 */
|
||
private String groupSeparator = "TENANT_";
|
||
|
||
/** 共享服务列表(不区分租户) */
|
||
private List<String> sharedServices = Arrays.asList("fund-gateway", "fund-report");
|
||
|
||
/** 租户服务配置 */
|
||
private Map<String, TenantServiceConfig> tenantConfigs = new HashMap<>();
|
||
}
|
||
|
||
/**
|
||
* 租户服务配置
|
||
*/
|
||
@Data
|
||
public class TenantServiceConfig {
|
||
/** 租户ID */
|
||
private String tenantId;
|
||
|
||
/** 服务实例列表 */
|
||
private Map<String, ServiceInstanceConfig> services = new HashMap<>();
|
||
|
||
/** 数据库配置 */
|
||
private DatabaseConfig database;
|
||
}
|
||
|
||
/**
|
||
* 服务实例配置
|
||
*/
|
||
@Data
|
||
public class ServiceInstanceConfig {
|
||
/** 服务名 */
|
||
private String serviceName;
|
||
|
||
/** 端口号 */
|
||
private int port;
|
||
|
||
/** 实例数 */
|
||
private int replicas = 1;
|
||
}
|
||
```
|
||
|
||
**2. Gateway 租户路由过滤器**
|
||
|
||
```java
|
||
/**
|
||
* Gateway 租户路由过滤器
|
||
* 根据 X-Tenant-Id 路由到对应租户的服务组
|
||
*/
|
||
@Component
|
||
@Order(-50)
|
||
public class TenantRoutingGatewayFilter implements GlobalFilter {
|
||
|
||
@Autowired
|
||
private TenantRoutingProperties routingProperties;
|
||
|
||
@Autowired
|
||
private NacosServiceManager nacosServiceManager;
|
||
|
||
@Override
|
||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||
ServerHttpRequest request = exchange.getRequest();
|
||
String tenantId = request.getHeaders().getFirst(routingProperties.getTenantHeader());
|
||
|
||
// 未指定租户或共享服务,使用默认路由
|
||
if (StringUtils.isEmpty(tenantId) || isSharedService(exchange)) {
|
||
return chain.filter(exchange);
|
||
}
|
||
|
||
// 构建租户服务组名称
|
||
String tenantGroup = buildTenantGroup(tenantId);
|
||
|
||
// 将租户路由信息存入 Exchange 属性
|
||
exchange.getAttributes().put("tenantId", tenantId);
|
||
exchange.getAttributes().put("tenantGroup", tenantGroup);
|
||
|
||
// 添加到请求头,传递给下游服务
|
||
ServerHttpRequest mutatedRequest = request.mutate()
|
||
.header("X-Tenant-Group", tenantGroup)
|
||
.header("X-Tenant-Id", tenantId)
|
||
.build();
|
||
|
||
log.info("[TenantRouting] Route request to tenant group: {}, URI: {}",
|
||
tenantGroup, request.getURI());
|
||
|
||
return chain.filter(exchange.mutate().request(mutatedRequest).build());
|
||
}
|
||
|
||
/**
|
||
* 构建租户服务组名称
|
||
*/
|
||
private String buildTenantGroup(String tenantId) {
|
||
return routingProperties.getGroupSeparator() + tenantId.toUpperCase();
|
||
}
|
||
|
||
/**
|
||
* 判断是否为共享服务
|
||
*/
|
||
private boolean isSharedService(ServerWebExchange exchange) {
|
||
String path = exchange.getRequest().getURI().getPath();
|
||
return routingProperties.getSharedServices().stream()
|
||
.anyMatch(path::contains);
|
||
}
|
||
}
|
||
```
|
||
|
||
**3. 自定义负载均衡器(按租户路由)**
|
||
|
||
```java
|
||
/**
|
||
* 租户感知的负载均衡器
|
||
*/
|
||
public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {
|
||
|
||
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);
|
||
|
||
ServiceInstanceListSupplier supplier = supplierProvider.getIfAvailable();
|
||
|
||
return supplier.get().next()
|
||
.map(instances -> filterByTenantGroup(instances, tenantGroup))
|
||
.map(this::getInstanceResponse);
|
||
}
|
||
|
||
/**
|
||
* 根据租户组过滤服务实例
|
||
*/
|
||
private List<ServiceInstance> filterByTenantGroup(
|
||
List<ServiceInstance> instances, String tenantGroup) {
|
||
|
||
// 优先选择租户专属实例
|
||
List<ServiceInstance> tenantInstances = instances.stream()
|
||
.filter(inst -> tenantGroup.equals(inst.getMetadata().get("tenant-group")))
|
||
.collect(Collectors.toList());
|
||
|
||
if (!tenantInstances.isEmpty()) {
|
||
return tenantInstances;
|
||
}
|
||
|
||
// 回退到默认实例
|
||
return instances.stream()
|
||
.filter(inst -> !inst.getMetadata().containsKey("tenant-group"))
|
||
.collect(Collectors.toList());
|
||
}
|
||
|
||
private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances) {
|
||
if (instances.isEmpty()) {
|
||
return new EmptyResponse();
|
||
}
|
||
// 轮询选择
|
||
int index = ThreadLocalRandom.current().nextInt(instances.size());
|
||
return new DefaultResponse(instances.get(index));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 租户负载均衡配置
|
||
*/
|
||
@Configuration
|
||
public class TenantLoadBalancerConfig {
|
||
|
||
@Bean
|
||
public ReactorLoadBalancer<ServiceInstance> tenantAwareLoadBalancer(
|
||
Environment environment,
|
||
LoadBalancerClientFactory loadBalancerClientFactory) {
|
||
|
||
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
|
||
|
||
return new TenantAwareLoadBalancer(name,
|
||
loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class));
|
||
}
|
||
}
|
||
```
|
||
|
||
**4. Feign 调用链拦截器(租户 + 用户信息传递)**
|
||
|
||
```java
|
||
/**
|
||
* Feign 调用链拦截器
|
||
* 在服务间调用时传递租户信息和当前操作用户信息
|
||
*/
|
||
@Component
|
||
public class FeignChainInterceptor implements RequestInterceptor {
|
||
|
||
@Autowired
|
||
private TenantRoutingProperties routingProperties;
|
||
|
||
private static final String HEADER_UID = "X-Uid";
|
||
private static final String HEADER_UNAME = "X-Uname";
|
||
private static final String HEADER_TENANT_ID = "X-Tenant-Id";
|
||
private static final String HEADER_TENANT_GROUP = "X-Tenant-Group";
|
||
|
||
@Override
|
||
public void apply(RequestTemplate template) {
|
||
// 1. 传递租户信息
|
||
String tenantId = TenantContextHolder.getTenantId();
|
||
if (StringUtils.isNotEmpty(tenantId)) {
|
||
template.header(HEADER_TENANT_ID, tenantId);
|
||
|
||
String tenantGroup = buildTenantGroup(tenantId);
|
||
template.header(HEADER_TENANT_GROUP, tenantGroup);
|
||
}
|
||
|
||
// 2. 传递当前操作用户信息(关键)
|
||
Long uid = UserContext.getCurrentUserId();
|
||
String uname = UserContext.getCurrentUsername();
|
||
|
||
if (uid != null) {
|
||
template.header(HEADER_UID, String.valueOf(uid));
|
||
log.debug("[FeignChain] Add uid to header: {}", uid);
|
||
}
|
||
|
||
if (StringUtils.isNotEmpty(uname)) {
|
||
template.header(HEADER_UNAME, uname);
|
||
log.debug("[FeignChain] Add uname to header: {}", uname);
|
||
}
|
||
|
||
// 3. 传递 TraceId(保持链路追踪)
|
||
String traceId = TraceIdUtil.getCurrentTraceId();
|
||
if (StringUtils.isNotEmpty(traceId)) {
|
||
template.header(TraceIdUtil.TRACE_ID_HEADER, traceId);
|
||
}
|
||
}
|
||
|
||
private String buildTenantGroup(String tenantId) {
|
||
return routingProperties.getGroupSeparator() + tenantId.toUpperCase();
|
||
}
|
||
}
|
||
```
|
||
|
||
**5. 用户上下文管理(支持调用链传递)**
|
||
|
||
```java
|
||
/**
|
||
* 用户上下文持有者
|
||
* 存储当前登录用户信息,支持跨服务传递
|
||
* 支持同步和异步场景
|
||
*/
|
||
public class UserContext {
|
||
|
||
// ========== ThreadLocal 存储(同步场景)==========
|
||
private static final ThreadLocal<Long> CURRENT_USER_ID = new ThreadLocal<>();
|
||
private static final ThreadLocal<String> CURRENT_USERNAME = new ThreadLocal<>();
|
||
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
|
||
|
||
// ========== 用户上下文载体(支持异步传递)==========
|
||
@Data
|
||
@AllArgsConstructor
|
||
@NoArgsConstructor
|
||
public static class UserContextHolder {
|
||
private Long userId;
|
||
private String username;
|
||
private String tenantId;
|
||
private String traceId;
|
||
|
||
public static UserContextHolder fromCurrent() {
|
||
return new UserContextHolder(
|
||
getCurrentUserId(),
|
||
getCurrentUsername(),
|
||
TenantContextHolder.getTenantId(),
|
||
TraceIdUtil.getCurrentTraceId()
|
||
);
|
||
}
|
||
}
|
||
|
||
// ========== 同步场景方法 ==========
|
||
|
||
/**
|
||
* 设置当前用户(登录时调用)
|
||
*/
|
||
public static void setCurrentUser(User user) {
|
||
if (user != null) {
|
||
CURRENT_USER_ID.set(user.getUserId());
|
||
CURRENT_USERNAME.set(user.getUsername());
|
||
CURRENT_USER.set(user);
|
||
|
||
// 同时设置到 MDC,用于日志输出
|
||
MDC.put("uid", String.valueOf(user.getUserId()));
|
||
MDC.put("uname", user.getUsername());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从请求头解析用户(服务间调用时)
|
||
*/
|
||
public static void setFromHeaders(String uid, String uname) {
|
||
if (StringUtils.isNotEmpty(uid)) {
|
||
CURRENT_USER_ID.set(Long.valueOf(uid));
|
||
MDC.put("uid", uid);
|
||
}
|
||
if (StringUtils.isNotEmpty(uname)) {
|
||
CURRENT_USERNAME.set(uname);
|
||
MDC.put("uname", uname);
|
||
}
|
||
}
|
||
|
||
public static Long getCurrentUserId() {
|
||
return CURRENT_USER_ID.get();
|
||
}
|
||
|
||
public static String getCurrentUsername() {
|
||
return CURRENT_USERNAME.get();
|
||
}
|
||
|
||
public static User getCurrentUser() {
|
||
return CURRENT_USER.get();
|
||
}
|
||
|
||
/**
|
||
* 获取当前操作用户(用于审计日志)
|
||
*/
|
||
public static String getOperator() {
|
||
String uname = CURRENT_USERNAME.get();
|
||
return StringUtils.isNotEmpty(uname) ? uname : "system";
|
||
}
|
||
|
||
public static void clear() {
|
||
CURRENT_USER_ID.remove();
|
||
CURRENT_USERNAME.remove();
|
||
CURRENT_USER.remove();
|
||
MDC.remove("uid");
|
||
MDC.remove("uname");
|
||
}
|
||
|
||
// ========== 异步场景支持 ==========
|
||
|
||
/**
|
||
* 包装 Runnable,传递用户上下文到异步线程
|
||
*/
|
||
public static Runnable wrap(Runnable runnable) {
|
||
UserContextHolder holder = UserContextHolder.fromCurrent();
|
||
return () -> {
|
||
try {
|
||
setFromHolder(holder);
|
||
runnable.run();
|
||
} finally {
|
||
clear();
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 包装 Callable,传递用户上下文到异步线程
|
||
*/
|
||
public static <T> Callable<T> wrap(Callable<T> callable) {
|
||
UserContextHolder holder = UserContextHolder.fromCurrent();
|
||
return () -> {
|
||
try {
|
||
setFromHolder(holder);
|
||
return callable.call();
|
||
} finally {
|
||
clear();
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 包装 Supplier,传递用户上下文到异步线程
|
||
*/
|
||
public static <T> Supplier<T> wrap(Supplier<T> supplier) {
|
||
UserContextHolder holder = UserContextHolder.fromCurrent();
|
||
return () -> {
|
||
try {
|
||
setFromHolder(holder);
|
||
return supplier.get();
|
||
} finally {
|
||
clear();
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 包装 Function,传递用户上下文到异步线程
|
||
*/
|
||
public static <T, R> Function<T, R> wrap(Function<T, R> function) {
|
||
UserContextHolder holder = UserContextHolder.fromCurrent();
|
||
return (T t) -> {
|
||
try {
|
||
setFromHolder(holder);
|
||
return function.apply(t);
|
||
} finally {
|
||
clear();
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 从 Holder 设置上下文
|
||
*/
|
||
private static void setFromHolder(UserContextHolder holder) {
|
||
if (holder != null) {
|
||
if (holder.getUserId() != null) {
|
||
CURRENT_USER_ID.set(holder.getUserId());
|
||
MDC.put("uid", String.valueOf(holder.getUserId()));
|
||
}
|
||
if (StringUtils.isNotEmpty(holder.getUsername())) {
|
||
CURRENT_USERNAME.set(holder.getUsername());
|
||
MDC.put("uname", holder.getUsername());
|
||
}
|
||
if (StringUtils.isNotEmpty(holder.getTenantId())) {
|
||
TenantContextHolder.setTenantId(holder.getTenantId());
|
||
MDC.put("tenantId", holder.getTenantId());
|
||
}
|
||
if (StringUtils.isNotEmpty(holder.getTraceId())) {
|
||
TraceIdUtil.setTraceId(holder.getTraceId());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建 CompletableFuture 上下文包装器
|
||
*/
|
||
public static <T> CompletableFuture<T> wrapFuture(Supplier<CompletableFuture<T>> supplier) {
|
||
UserContextHolder holder = UserContextHolder.fromCurrent();
|
||
return supplier.get().whenComplete((result, ex) -> {
|
||
// 确保上下文清理
|
||
clear();
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
**5. 异步场景使用示例**
|
||
|
||
```java
|
||
/**
|
||
* 异步服务示例
|
||
*/
|
||
@Service
|
||
public class AsyncService {
|
||
|
||
@Autowired
|
||
private ThreadPoolExecutor executor;
|
||
|
||
@Autowired
|
||
private ProjectMapper projectMapper;
|
||
|
||
/**
|
||
* 异步处理 - 方式1:使用包装器
|
||
*/
|
||
public void asyncProcessWithWrapper(Long projectId) {
|
||
// 包装 Runnable,自动传递当前用户上下文
|
||
executor.execute(UserContext.wrap(() -> {
|
||
// 在异步线程中也能获取用户信息
|
||
Long uid = UserContext.getCurrentUserId();
|
||
String uname = UserContext.getCurrentUsername();
|
||
|
||
log.info("[Async] 用户 {} 处理项目 {}", uname, projectId);
|
||
|
||
// 数据库操作
|
||
Project project = projectMapper.selectById(projectId);
|
||
project.setProcessedBy(uid);
|
||
projectMapper.updateById(project);
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 异步处理 - 方式2:使用 CompletableFuture
|
||
*/
|
||
public CompletableFuture<Project> asyncQueryProject(Long projectId) {
|
||
return CompletableFuture.supplyAsync(
|
||
UserContext.wrap(() -> {
|
||
// 异步查询时保持用户上下文
|
||
log.info("[Async] 用户 {} 查询项目 {}",
|
||
UserContext.getCurrentUsername(), projectId);
|
||
|
||
return projectMapper.selectById(projectId);
|
||
}),
|
||
executor
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 批量异步处理
|
||
*/
|
||
public void batchAsyncProcess(List<Long> projectIds) {
|
||
List<CompletableFuture<Void>> futures = projectIds.stream()
|
||
.map(id -> CompletableFuture.runAsync(
|
||
UserContext.wrap(() -> processSingleProject(id)),
|
||
executor
|
||
))
|
||
.collect(Collectors.toList());
|
||
|
||
// 等待所有任务完成
|
||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||
}
|
||
|
||
private void processSingleProject(Long projectId) {
|
||
// 每个异步线程都有独立的用户上下文
|
||
Long uid = UserContext.getCurrentUserId();
|
||
String uname = UserContext.getCurrentUsername();
|
||
|
||
log.info("[BatchAsync] 用户 {} 处理项目 {}", uname, projectId);
|
||
|
||
// 业务处理...
|
||
}
|
||
|
||
/**
|
||
* 链式异步调用
|
||
*/
|
||
public CompletableFuture<String> chainAsyncProcess(Long projectId) {
|
||
return CompletableFuture.supplyAsync(
|
||
UserContext.wrap(() -> fetchProject(projectId)), executor)
|
||
.thenApplyAsync(UserContext.wrap(project -> {
|
||
// 转换操作,上下文自动传递
|
||
log.info("[Chain] 用户 {} 转换项目 {}",
|
||
UserContext.getCurrentUsername(), project.getProjectName());
|
||
return transformProject(project);
|
||
}), executor)
|
||
.thenComposeAsync(UserContext.wrap(transformed ->
|
||
// 异步保存
|
||
CompletableFuture.supplyAsync(
|
||
UserContext.wrap(() -> saveProject(transformed)), executor)
|
||
), executor);
|
||
}
|
||
|
||
private Project fetchProject(Long id) { /* ... */ return null; }
|
||
private Project transformProject(Project p) { /* ... */ return p; }
|
||
private String saveProject(Project p) { /* ... */ return "saved"; }
|
||
}
|
||
|
||
/**
|
||
* Spring @Async 支持
|
||
*/
|
||
@Configuration
|
||
@EnableAsync
|
||
public class AsyncConfig implements AsyncConfigurer {
|
||
|
||
@Override
|
||
@Bean(name = "taskExecutor")
|
||
public Executor getAsyncExecutor() {
|
||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||
executor.setCorePoolSize(10);
|
||
executor.setMaxPoolSize(50);
|
||
executor.setQueueCapacity(200);
|
||
executor.setThreadNamePrefix("async-");
|
||
|
||
// 关键:使用 ContextPropagatingTaskDecorator 传递上下文
|
||
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
|
||
executor.initialize();
|
||
return executor;
|
||
}
|
||
|
||
@Override
|
||
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||
return new SimpleAsyncUncaughtExceptionHandler();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上下文传递装饰器(用于 @Async)
|
||
*/
|
||
public class ContextPropagatingTaskDecorator implements TaskDecorator {
|
||
|
||
@Override
|
||
public Runnable decorate(Runnable runnable) {
|
||
// 使用 UserContext 的包装方法
|
||
return UserContext.wrap(runnable);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用 @Async 注解
|
||
*/
|
||
@Service
|
||
public class NotificationService {
|
||
|
||
/**
|
||
* 异步发送通知(自动传递用户上下文)
|
||
*/
|
||
@Async("taskExecutor")
|
||
public void sendNotificationAsync(Long userId, String message) {
|
||
// 在异步方法中也能获取当前操作用户
|
||
String operator = UserContext.getCurrentUsername();
|
||
log.info("[Notification] 用户 {} 发送通知给 {}: {}", operator, userId, message);
|
||
|
||
// 记录操作日志
|
||
operationLogService.saveLog(new OperationLog()
|
||
.setOperation("发送通知")
|
||
.setOperatorId(UserContext.getCurrentUserId())
|
||
.setOperatorName(operator)
|
||
.setTargetId(userId)
|
||
.setContent(message)
|
||
);
|
||
|
||
// 发送通知...
|
||
}
|
||
}
|
||
```
|
||
|
||
**6. 响应式编程支持(WebFlux)**
|
||
|
||
```java
|
||
/**
|
||
* Reactor 上下文工具(用于 WebFlux)
|
||
*/
|
||
public class ReactiveUserContext {
|
||
|
||
private static final String USER_CONTEXT_KEY = "UserContext";
|
||
|
||
/**
|
||
* 将用户上下文写入 Reactor Context
|
||
*/
|
||
public static <T> Mono<T> withUserContext(Mono<T> mono, UserContextHolder holder) {
|
||
return mono.contextWrite(Context.of(USER_CONTEXT_KEY, holder));
|
||
}
|
||
|
||
/**
|
||
* 从 Reactor Context 读取用户上下文
|
||
*/
|
||
public static Mono<UserContextHolder> getUserContext() {
|
||
return Mono.deferContextual(ctx -> {
|
||
if (ctx.hasKey(USER_CONTEXT_KEY)) {
|
||
return Mono.just(ctx.get(USER_CONTEXT_KEY));
|
||
}
|
||
return Mono.empty();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 在响应式链中获取用户ID
|
||
*/
|
||
public static Mono<Long> getUserId() {
|
||
return getUserContext().map(UserContextHolder::getUserId);
|
||
}
|
||
|
||
/**
|
||
* 包装响应式操作
|
||
*/
|
||
public static <T> Mono<T> wrap(Mono<T> mono) {
|
||
UserContextHolder holder = UserContextHolder.fromCurrent();
|
||
return withUserContext(mono, holder);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WebFlux 过滤器
|
||
*/
|
||
@Component
|
||
public class ReactiveContextFilter implements WebFilter {
|
||
|
||
@Override
|
||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||
ServerHttpRequest request = exchange.getRequest();
|
||
|
||
// 解析用户信息
|
||
String uid = request.getHeaders().getFirst("X-Uid");
|
||
String uname = request.getHeaders().getFirst("X-Uname");
|
||
String tenantId = request.getHeaders().getFirst("X-Tenant-Id");
|
||
String traceId = request.getHeaders().getFirst("X-Trace-Id");
|
||
|
||
UserContextHolder holder = new UserContextHolder(
|
||
StringUtils.isNotEmpty(uid) ? Long.valueOf(uid) : null,
|
||
uname,
|
||
tenantId,
|
||
traceId
|
||
);
|
||
|
||
// 写入 Reactor Context
|
||
return chain.filter(exchange)
|
||
.contextWrite(Context.of("UserContext", holder));
|
||
}
|
||
}
|
||
```
|
||
|
||
**原 5. 用户上下文管理(已升级为支持异步的版本)**
|
||
|
||
|
||
**7. 调用链上下文过滤器(接收方)**
|
||
|
||
```java
|
||
/**
|
||
* 调用链上下文过滤器
|
||
* 接收上游服务传递的租户信息和用户信息
|
||
*/
|
||
@Component
|
||
@Order(-90) // 在租户过滤器之后,业务过滤器之前
|
||
public class ChainContextFilter implements Filter {
|
||
|
||
@Override
|
||
public void doFilter(ServletRequest request, ServletResponse response,
|
||
FilterChain chain) throws IOException, ServletException {
|
||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||
|
||
try {
|
||
// 1. 解析租户信息
|
||
String tenantId = httpRequest.getHeader("X-Tenant-Id");
|
||
String tenantGroup = httpRequest.getHeader("X-Tenant-Group");
|
||
|
||
if (StringUtils.isNotEmpty(tenantId)) {
|
||
TenantContextHolder.setTenantId(tenantId);
|
||
TenantContextHolder.setTenantGroup(tenantGroup);
|
||
MDC.put("tenantId", tenantId);
|
||
}
|
||
|
||
// 2. 解析用户信息(来自上游服务)
|
||
String uid = httpRequest.getHeader("X-Uid");
|
||
String uname = httpRequest.getHeader("X-Uname");
|
||
|
||
if (StringUtils.isNotEmpty(uid)) {
|
||
UserContext.setFromHeaders(uid, uname);
|
||
log.debug("[ChainContext] Received from upstream - uid: {}, uname: {}", uid, uname);
|
||
} else {
|
||
// 如果没有上游传递,尝试从 Shiro 获取(网关直接访问)
|
||
Subject subject = SecurityUtils.getSubject();
|
||
if (subject.isAuthenticated()) {
|
||
Long userId = (Long) subject.getPrincipal();
|
||
User user = userService.getById(userId);
|
||
if (user != null) {
|
||
UserContext.setCurrentUser(user);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 解析 TraceId
|
||
String traceId = httpRequest.getHeader(TraceIdUtil.TRACE_ID_HEADER);
|
||
if (StringUtils.isNotEmpty(traceId)) {
|
||
TraceIdUtil.setTraceId(traceId);
|
||
}
|
||
|
||
chain.doFilter(request, response);
|
||
} finally {
|
||
// 清理所有上下文
|
||
TenantContextHolder.clear();
|
||
UserContext.clear();
|
||
TraceIdUtil.clear();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**8. 调用链传递示意图**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ 服务调用链用户信息传递 │
|
||
│ │
|
||
│ 用户请求 │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────┐ Headers: │
|
||
│ │ Gateway │ Authorization: Bearer xxx │
|
||
│ │ (网关) │ X-Tenant-Id: tenant_001 │
|
||
│ └──────┬──────┘ │
|
||
│ │ │
|
||
│ │ 解析 Token 获取 uid/uname │
|
||
│ │ 存入 UserContext │
|
||
│ ▼ │
|
||
│ ┌─────────────┐ Headers: │
|
||
│ │ fund-sys │ X-Tenant-Id: tenant_001 │
|
||
│ │ (用户服务) │ X-Tenant-Group: TENANT_001 │
|
||
│ │ │ X-Uid: 1001 ◄── 当前操作用户ID │
|
||
│ │ │ X-Uname: admin ◄── 当前操作用户名 │
|
||
│ │ │ X-Trace-Id: abc123 │
|
||
│ └──────┬──────┘ │
|
||
│ │ │
|
||
│ │ Feign 调用 fund-cust 服务 │
|
||
│ │ FeignChainInterceptor 自动添加 Headers │
|
||
│ ▼ │
|
||
│ ┌─────────────┐ Headers: │
|
||
│ │ fund-cust │ X-Tenant-Id: tenant_001 │
|
||
│ │ (客户服务) │ X-Tenant-Group: TENANT_001 │
|
||
│ │ │ X-Uid: 1001 ◄── 透传用户ID │
|
||
│ │ │ X-Uname: admin ◄── 透传用户名 │
|
||
│ │ │ X-Trace-Id: abc123 │
|
||
│ └──────┬──────┘ │
|
||
│ │ │
|
||
│ │ Feign 调用 fund-proj 服务 │
|
||
│ ▼ │
|
||
│ ┌─────────────┐ Headers: │
|
||
│ │ fund-proj │ X-Tenant-Id: tenant_001 │
|
||
│ │ (项目服务) │ X-Tenant-Group: TENANT_001 │
|
||
│ │ │ X-Uid: 1001 ◄── 持续透传 │
|
||
│ │ │ X-Uname: admin ◄── 持续透传 │
|
||
│ └─────────────┘ │
|
||
│ │
|
||
│ 特点: │
|
||
│ • 每个服务都能获取原始操作用户(uid/uname) │
|
||
│ • 用于操作审计、数据权限控制、日志记录 │
|
||
│ • 无需在每个服务重复解析 Token │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**9. 使用场景示例(包含异步场景)**
|
||
|
||
```java
|
||
/**
|
||
* 项目服务 - 创建项目
|
||
*/
|
||
@Service
|
||
public class ProjectServiceImpl implements ProjectService {
|
||
|
||
@Autowired
|
||
private ProjectMapper projectMapper;
|
||
|
||
@Autowired
|
||
private OperationLogService logService;
|
||
|
||
@Override
|
||
@Transactional
|
||
public void createProject(ProjectCreateDTO dto) {
|
||
// 1. 获取当前操作用户(来自调用链传递)
|
||
Long operatorId = UserContext.getCurrentUserId();
|
||
String operatorName = UserContext.getCurrentUsername();
|
||
|
||
// 2. 设置项目创建人
|
||
Project project = new Project();
|
||
project.setProjectName(dto.getProjectName());
|
||
project.setCustomerId(dto.getCustomerId());
|
||
project.setCreatedBy(operatorId); // 使用传递过来的 uid
|
||
project.setCreatedTime(LocalDateTime.now());
|
||
|
||
projectMapper.insert(project);
|
||
|
||
// 3. 记录操作日志(包含操作人信息)
|
||
logService.saveLog(new OperationLog()
|
||
.setOperation("创建项目")
|
||
.setOperatorId(operatorId)
|
||
.setOperatorName(operatorName) // 使用传递过来的 uname
|
||
.setTargetId(project.getProjectId())
|
||
.setContent("创建项目:" + dto.getProjectName())
|
||
);
|
||
|
||
log.info("[Project] 用户 {} 创建了项目 {}", operatorName, project.getProjectName());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 操作日志服务 - 记录日志
|
||
*/
|
||
@Service
|
||
public class OperationLogServiceImpl implements OperationLogService {
|
||
|
||
@Autowired
|
||
private OperationLogMapper logMapper;
|
||
|
||
@Override
|
||
public void saveLog(OperationLog log) {
|
||
// 自动补充操作人信息(如果未设置)
|
||
if (log.getOperatorId() == null) {
|
||
log.setOperatorId(UserContext.getCurrentUserId());
|
||
}
|
||
if (StringUtils.isEmpty(log.getOperatorName())) {
|
||
log.setOperatorName(UserContext.getCurrentUsername());
|
||
}
|
||
|
||
// 补充租户信息
|
||
log.setTenantId(TenantContextHolder.getTenantId());
|
||
|
||
logMapper.insert(log);
|
||
}
|
||
}
|
||
```
|
||
|
||
**9. 日志格式(包含 uid/uname)**
|
||
|
||
```xml
|
||
<!-- logback-spring.xml -->
|
||
<configuration>
|
||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||
<encoder>
|
||
<!-- 格式: 时间 [线程] [TraceId] [租户ID] [用户ID] [用户名] 级别 类名 - 消息 -->
|
||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{tenantId}] [%X{uid}] [%X{uname}] %-5level %logger{36} - %msg%n</pattern>
|
||
</encoder>
|
||
</appender>
|
||
</configuration>
|
||
```
|
||
|
||
输出示例:
|
||
```
|
||
2026-02-13 14:30:25.123 [http-nio-8120-exec-1] [abc123def456] [tenant_001] [1001] [admin] INFO c.f.p.s.ProjectService - 用户 admin 创建了项目 资金管理系统
|
||
```
|
||
|
||
**原 4. Feign 租户路由拦截器(已合并到 FeignChainInterceptor)**
|
||
|
||
|
||
**10. Nacos 服务注册(带租户标记)**
|
||
|
||
```java
|
||
/**
|
||
* Nacos 租户服务注册配置
|
||
*/
|
||
@Configuration
|
||
public class NacosTenantRegistrationConfig {
|
||
|
||
@Value("${tenant.id:}")
|
||
private String tenantId;
|
||
|
||
@Bean
|
||
public NacosRegistrationCustomizer tenantNacosRegistrationCustomizer() {
|
||
return registration -> {
|
||
if (StringUtils.isNotEmpty(tenantId)) {
|
||
// 添加租户标记到服务元数据
|
||
registration.getMetadata().put("tenant-id", tenantId);
|
||
registration.getMetadata().put("tenant-group", "TENANT_" + tenantId.toUpperCase());
|
||
|
||
// 修改服务分组
|
||
registration.setGroup("TENANT_" + tenantId.toUpperCase());
|
||
|
||
log.info("[NacosRegistration] Register service with tenant: {}", tenantId);
|
||
}
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
**11. 租户上下文管理**
|
||
|
||
```java
|
||
/**
|
||
* 租户上下文持有者
|
||
*/
|
||
public class TenantContextHolder {
|
||
|
||
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
|
||
private static final ThreadLocal<String> CURRENT_TENANT_GROUP = new ThreadLocal<>();
|
||
|
||
public static void setTenantId(String tenantId) {
|
||
CURRENT_TENANT.set(tenantId);
|
||
}
|
||
|
||
public static String getTenantId() {
|
||
return CURRENT_TENANT.get();
|
||
}
|
||
|
||
public static void setTenantGroup(String tenantGroup) {
|
||
CURRENT_TENANT_GROUP.set(tenantGroup);
|
||
}
|
||
|
||
public static String getTenantGroup() {
|
||
return CURRENT_TENANT_GROUP.get();
|
||
}
|
||
|
||
public static void clear() {
|
||
CURRENT_TENANT.remove();
|
||
CURRENT_TENANT_GROUP.remove();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 租户上下文过滤器
|
||
*/
|
||
@Component
|
||
public class TenantContextFilter implements Filter {
|
||
|
||
@Override
|
||
public void doFilter(ServletRequest request, ServletResponse response,
|
||
FilterChain chain) throws IOException, ServletException {
|
||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||
|
||
try {
|
||
// 从请求头获取租户信息
|
||
String tenantId = httpRequest.getHeader("X-Tenant-Id");
|
||
String tenantGroup = httpRequest.getHeader("X-Tenant-Group");
|
||
|
||
if (StringUtils.isNotEmpty(tenantId)) {
|
||
TenantContextHolder.setTenantId(tenantId);
|
||
TenantContextHolder.setTenantGroup(tenantGroup);
|
||
|
||
// 设置到 MDC 用于日志
|
||
MDC.put("tenantId", tenantId);
|
||
}
|
||
|
||
chain.doFilter(request, response);
|
||
} finally {
|
||
TenantContextHolder.clear();
|
||
MDC.remove("tenantId");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.6.3 配置示例(包含调用链配置)
|
||
|
||
```yaml
|
||
# application-tenant.yml
|
||
spring:
|
||
cloud:
|
||
nacos:
|
||
discovery:
|
||
# 动态服务组,由代码设置
|
||
group: ${TENANT_GROUP:DEFAULT}
|
||
metadata:
|
||
tenant-id: ${TENANT_ID:}
|
||
tenant-group: ${TENANT_GROUP:DEFAULT}
|
||
loadbalancer:
|
||
configurations: tenant-aware
|
||
|
||
tenant:
|
||
routing:
|
||
enabled: true
|
||
tenant-header: X-Tenant-Id
|
||
group-separator: TENANT_
|
||
# 共享服务(所有租户共用)
|
||
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://localhost:3306/tenant_001_db
|
||
username: tenant_001
|
||
password: xxx
|
||
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://localhost:3306/tenant_002_db
|
||
username: tenant_002
|
||
password: xxx
|
||
```
|
||
|
||
#### 2.6.4 调用链配置示例
|
||
|
||
```
|
||
```yaml
|
||
# application.yml - 调用链配置
|
||
spring:
|
||
cloud:
|
||
openfeign:
|
||
client:
|
||
config:
|
||
default:
|
||
# Feign 拦截器配置
|
||
requestInterceptors:
|
||
- com.fundplatform.common.feign.FeignChainInterceptor
|
||
# 连接超时
|
||
connectTimeout: 5000
|
||
# 读取超时
|
||
readTimeout: 10000
|
||
|
||
# 调用链上下文过滤器配置
|
||
chain:
|
||
context:
|
||
# 是否启用调用链传递
|
||
enabled: true
|
||
# 传递的 Headers
|
||
propagate-headers:
|
||
- X-Tenant-Id
|
||
- X-Tenant-Group
|
||
- X-Uid
|
||
- X-Uname
|
||
- X-Trace-Id
|
||
```
|
||
|
||
#### 2.6.5 部署架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Kubernetes 部署示例 │
|
||
│ │
|
||
│ apiVersion: apps/v1 │
|
||
│ kind: Deployment │
|
||
│ metadata: │
|
||
│ name: fund-sys-tenant-001 │
|
||
│ labels: │
|
||
│ app: fund-sys │
|
||
│ tenant: tenant-001 │
|
||
│ spec: │
|
||
│ replicas: 2 │
|
||
│ selector: │
|
||
│ matchLabels: │
|
||
│ app: fund-sys │
|
||
│ tenant: tenant-001 │
|
||
│ template: │
|
||
│ metadata: │
|
||
│ labels: │
|
||
│ app: fund-sys │
|
||
│ tenant: tenant-001 │
|
||
│ spec: │
|
||
│ containers: │
|
||
│ - name: fund-sys │
|
||
│ image: fundplatform/fund-sys:1.0.0 │
|
||
│ env: │
|
||
│ - name: TENANT_ID │
|
||
│ value: "tenant_001" │
|
||
│ - name: TENANT_GROUP │
|
||
│ value: "TENANT_001" │
|
||
│ - name: DB_URL │
|
||
│ value: "jdbc:mysql://mysql:3306/tenant_001_db" │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 2.6.5 两种方案最终对比
|
||
|
||
| 场景 | 推荐方案 | 原因 |
|
||
|------|----------|------|
|
||
| 中小租户 (< 100) | DynamicDataSource | 资源利用率高,运维简单 |
|
||
| 大客户/私有化 | **Feign 动态路由** | 物理隔离,独立扩容 |
|
||
| 金融/政务行业 | **Feign 动态路由** | 安全合规要求高 |
|
||
| 快速原型/MVP | DynamicDataSource | 快速上线,成本低 |
|
||
| 长期运营 SaaS | **Feign 动态路由** | 可持续演进,租户自治 |
|
||
|
||
---
|
||
|
||
## 三、Head 日志追踪设计
|
||
|
||
### 3.1 概述
|
||
|
||
Head 日志追踪(Header-based Logging)是一种基于请求头的全链路日志追踪方案,通过在每个请求中传递唯一的 Trace ID,实现跨服务的调用链追踪。
|
||
|
||
### 3.2 架构设计
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Head 日志追踪架构 │
|
||
│ │
|
||
│ ┌─────────┐ │
|
||
│ │ 客户端 │ X-Trace-Id: abc123 │
|
||
│ └────┬────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Gateway (网关) │ │
|
||
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
|
||
│ │ │ TraceIdFilter │ │ │
|
||
│ │ │ • 生成/接收 TraceId │ │ │
|
||
│ │ │ • 写入 MDC (Mapped Diagnostic Context) │ │ │
|
||
│ │ │ • 传递 TraceId 到下游服务 │ │ │
|
||
│ │ └─────────────────────────────────────────────────────────────┘ │ │
|
||
│ └────────────────────────────────┬───────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌───────────────────────────┼───────────────────────────┐ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||
│ │fund-sys │ │fund-cust│ │fund-proj│ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │• 接收 │ │• 接收 │ │• 接收 │ │
|
||
│ │ TraceId│ │ TraceId│ │ TraceId│ │
|
||
│ │• 写入 │ │• 写入 │ │• 写入 │ │
|
||
│ │ MDC │ │ MDC │ │ MDC │ │
|
||
│ │• 记录 │ │• 记录 │ │• 记录 │ │
|
||
│ │ 日志 │ │ 日志 │ │ 日志 │ │
|
||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||
│ │ │ │ │
|
||
│ └─────────────────────────┼─────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ ELK / Loki 日志平台 │ │
|
||
│ │ • 日志收集 → 解析 → 存储 → 检索 → 可视化 │ │
|
||
│ │ • 支持 TraceId 全链路查询 │ │
|
||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.3 技术实现
|
||
|
||
#### 3.3.1 TraceId 生成与传递
|
||
|
||
```java
|
||
/**
|
||
* TraceId 工具类
|
||
*/
|
||
public class TraceIdUtil {
|
||
|
||
public static final String TRACE_ID_HEADER = "X-Trace-Id";
|
||
public static final String TRACE_ID_MDC_KEY = "traceId";
|
||
public static final String SPAN_ID_MDC_KEY = "spanId";
|
||
|
||
/**
|
||
* 生成 TraceId
|
||
*/
|
||
public static String generateTraceId() {
|
||
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||
}
|
||
|
||
/**
|
||
* 生成 SpanId
|
||
*/
|
||
public static String generateSpanId() {
|
||
return UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||
}
|
||
|
||
/**
|
||
* 获取当前 TraceId
|
||
*/
|
||
public static String getCurrentTraceId() {
|
||
return MDC.get(TRACE_ID_MDC_KEY);
|
||
}
|
||
|
||
/**
|
||
* 设置 TraceId 到 MDC
|
||
*/
|
||
public static void setTraceId(String traceId) {
|
||
MDC.put(TRACE_ID_MDC_KEY, traceId);
|
||
}
|
||
|
||
/**
|
||
* 清除 MDC
|
||
*/
|
||
public static void clear() {
|
||
MDC.clear();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.2 Gateway TraceId 过滤器
|
||
|
||
```java
|
||
/**
|
||
* Gateway TraceId 过滤器
|
||
*/
|
||
@Component
|
||
@Order(-100)
|
||
public class TraceIdGatewayFilter implements GlobalFilter {
|
||
|
||
@Override
|
||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||
ServerHttpRequest request = exchange.getRequest();
|
||
HttpHeaders headers = request.getHeaders();
|
||
|
||
// 获取或生成 TraceId
|
||
String traceId = headers.getFirst(TraceIdUtil.TRACE_ID_HEADER);
|
||
if (StringUtils.isEmpty(traceId)) {
|
||
traceId = TraceIdUtil.generateTraceId();
|
||
}
|
||
|
||
// 生成 SpanId
|
||
String spanId = TraceIdUtil.generateSpanId();
|
||
|
||
// 添加到 MDC
|
||
TraceIdUtil.setTraceId(traceId);
|
||
MDC.put(TraceIdUtil.SPAN_ID_MDC_KEY, spanId);
|
||
|
||
// 添加到请求头,传递给下游服务
|
||
ServerHttpRequest mutatedRequest = request.mutate()
|
||
.header(TraceIdUtil.TRACE_ID_HEADER, traceId)
|
||
.header("X-Span-Id", spanId)
|
||
.header("X-Parent-Span-Id", MDC.get(TraceIdUtil.SPAN_ID_MDC_KEY))
|
||
.build();
|
||
|
||
// 记录请求日志
|
||
log.info("[Gateway] Request: {} {}, TraceId: {}",
|
||
request.getMethod(),
|
||
request.getURI(),
|
||
traceId);
|
||
|
||
final String finalTraceId = traceId;
|
||
|
||
return chain.filter(exchange.mutate().request(mutatedRequest).build())
|
||
.doFinally(signalType -> {
|
||
// 记录响应日志
|
||
log.info("[Gateway] Response: {}, TraceId: {}",
|
||
exchange.getResponse().getStatusCode(),
|
||
finalTraceId);
|
||
TraceIdUtil.clear();
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.3 服务间 TraceId 传递
|
||
|
||
```java
|
||
/**
|
||
* Feign 请求拦截器 - 传递 TraceId
|
||
*/
|
||
@Component
|
||
public class FeignTraceIdInterceptor implements RequestInterceptor {
|
||
|
||
@Override
|
||
public void apply(RequestTemplate template) {
|
||
String traceId = TraceIdUtil.getCurrentTraceId();
|
||
if (StringUtils.isNotEmpty(traceId)) {
|
||
template.header(TraceIdUtil.TRACE_ID_HEADER, traceId);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 服务端 TraceId 过滤器
|
||
*/
|
||
@Component
|
||
public class TraceIdFilter implements Filter {
|
||
|
||
@Override
|
||
public void doFilter(ServletRequest request, ServletResponse response,
|
||
FilterChain chain) throws IOException, ServletException {
|
||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||
|
||
// 从请求头获取 TraceId
|
||
String traceId = httpRequest.getHeader(TraceIdUtil.TRACE_ID_HEADER);
|
||
if (StringUtils.isEmpty(traceId)) {
|
||
traceId = TraceIdUtil.generateTraceId();
|
||
}
|
||
|
||
// 设置到 MDC
|
||
TraceIdUtil.setTraceId(traceId);
|
||
MDC.put(TraceIdUtil.SPAN_ID_MDC_KEY, TraceIdUtil.generateSpanId());
|
||
|
||
try {
|
||
chain.doFilter(request, response);
|
||
} finally {
|
||
TraceIdUtil.clear();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.4 日志配置
|
||
|
||
```xml
|
||
<!-- logback-spring.xml -->
|
||
<configuration>
|
||
<!-- 控制台输出 -->
|
||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||
<encoder>
|
||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{spanId}] %-5level %logger{36} - %msg%n</pattern>
|
||
</encoder>
|
||
</appender>
|
||
|
||
<!-- 文件输出(JSON格式,便于ELK收集) -->
|
||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||
<file>logs/application.log</file>
|
||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||
<fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||
<maxHistory>30</maxHistory>
|
||
<maxFileSize>100MB</maxFileSize>
|
||
</rollingPolicy>
|
||
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
||
<includeContext>true</includeContext>
|
||
<includeMdcKeyName>traceId</includeMdcKeyName>
|
||
<includeMdcKeyName>spanId</includeMdcKeyName>
|
||
<includeMdcKeyName>tenantId</includeMdcKeyName>
|
||
</encoder>
|
||
</appender>
|
||
|
||
<root level="INFO">
|
||
<appender-ref ref="CONSOLE" />
|
||
<appender-ref ref="FILE" />
|
||
</root>
|
||
</configuration>
|
||
```
|
||
|
||
### 3.4 日志格式规范
|
||
|
||
#### 3.4.1 标准日志格式
|
||
|
||
```
|
||
[时间] [线程] [TraceId] [SpanId] [租户ID] [级别] [类名] - [消息]
|
||
|
||
示例:
|
||
2026-02-13 14:30:25.123 [http-nio-8100-exec-1] [abc123def456] [span789] [tenant_1] INFO c.f.s.c.UserController - 用户登录成功: admin
|
||
```
|
||
|
||
#### 3.4.2 JSON 日志格式
|
||
|
||
```json
|
||
{
|
||
"@timestamp": "2026-02-13T14:30:25.123+08:00",
|
||
"level": "INFO",
|
||
"logger_name": "com.fundplatform.sys.controller.UserController",
|
||
"message": "用户登录成功: admin",
|
||
"thread_name": "http-nio-8100-exec-1",
|
||
"traceId": "abc123def456",
|
||
"spanId": "span789",
|
||
"tenantId": "tenant_1",
|
||
"service": "fund-sys",
|
||
"host": "192.168.1.100"
|
||
}
|
||
```
|
||
|
||
### 3.5 日志收集与分析
|
||
|
||
#### 3.5.1 ELK Stack 架构
|
||
|
||
```
|
||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||
│ 应用服务 │────>│ Filebeat │────>│ Logstash │────>│ Elasticsearch│
|
||
│ (日志文件) │ │ (日志收集) │ │ (日志处理) │ │ (日志存储) │
|
||
└─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘
|
||
│
|
||
▼
|
||
┌─────────────┐
|
||
│ Kibana │
|
||
│ (日志查询) │
|
||
└─────────────┘
|
||
```
|
||
|
||
#### 3.5.2 Kibana 查询示例
|
||
|
||
```
|
||
# 根据 TraceId 查询全链路日志
|
||
traceId: "abc123def456"
|
||
|
||
# 根据租户ID查询日志
|
||
tenantId: "tenant_1"
|
||
|
||
# 查询特定服务的错误日志
|
||
service: "fund-sys" AND level: "ERROR"
|
||
|
||
# 查询特定用户的操作日志
|
||
message: "admin" AND tenantId: "tenant_1"
|
||
```
|
||
|
||
### 3.6 调用链追踪增强
|
||
|
||
#### 3.6.1 与 SkyWalking 集成
|
||
|
||
```java
|
||
/**
|
||
* SkyWalking 插件配置
|
||
*/
|
||
@Component
|
||
public class SkyWalkingConfig {
|
||
|
||
/**
|
||
* 自定义 SkyWalking 标签
|
||
*/
|
||
@TraceContext
|
||
public void addCustomTags() {
|
||
// 添加租户ID标签
|
||
String tenantId = TenantContextHolder.getTenantId();
|
||
if (tenantId != null) {
|
||
Tags.ofKey("tenant.id").set(tenantId);
|
||
}
|
||
|
||
// 添加用户ID标签
|
||
String userId = SecurityUtils.getCurrentUserId();
|
||
if (userId != null) {
|
||
Tags.ofKey("user.id").set(userId);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.6.2 性能指标采集
|
||
|
||
```java
|
||
/**
|
||
* 接口性能监控
|
||
*/
|
||
@Aspect
|
||
@Component
|
||
public class PerformanceAspect {
|
||
|
||
private static final Logger perfLog = LoggerFactory.getLogger("PERFORMANCE");
|
||
|
||
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
|
||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||
long start = System.currentTimeMillis();
|
||
String method = point.getSignature().toShortString();
|
||
String traceId = TraceIdUtil.getCurrentTraceId();
|
||
|
||
try {
|
||
return point.proceed();
|
||
} finally {
|
||
long cost = System.currentTimeMillis() - start;
|
||
perfLog.info("[Performance] Method: {}, Cost: {}ms, TraceId: {}",
|
||
method, cost, traceId);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、系统整体架构
|
||
|
||
### 2.1 架构全景图
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ 接入层 (Access Layer) │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ Web端 │ │ 移动端 │ │ 小程序 │ │ H5页面 │ │
|
||
│ │ (Vue3) │ │ (UniApp) │ │ (UniApp) │ │ (UniApp) │ │
|
||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||
└─────────┼────────────────┼────────────────┼────────────────┼───────────────┘
|
||
│ │ │ │
|
||
└────────────────┴────────────────┴────────────────┘
|
||
│
|
||
┌────────────────────────────────────┼────────────────────────────────────────┐
|
||
│ 网关层 (Gateway Layer) │
|
||
│ ┌─────────────────────────────────┴─────────────────────────────────────┐ │
|
||
│ │ Nginx 负载均衡 │ │
|
||
│ │ SSL终止 / 静态资源 / 反向代理 │ │
|
||
│ └─────────────────────────────────┬─────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌─────────────────────────────────┴─────────────────────────────────────┐ │
|
||
│ │ Spring Cloud Gateway │ │
|
||
│ │ 路由转发 / 限流熔断 / 鉴权认证 / 日志记录 │ │
|
||
│ └─────────────────────────────────┬─────────────────────────────────────┘ │
|
||
└────────────────────────────────────┼────────────────────────────────────────┘
|
||
│
|
||
┌────────────────────────────────────┼────────────────────────────────────────┐
|
||
│ 服务层 (Service Layer) │
|
||
│ │ │
|
||
│ ┌─────────────────────────────────┴─────────────────────────────────────┐ │
|
||
│ │ Nacos 服务注册与配置中心 │ │
|
||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ 系统服务 │ │ 客户中心 │ │ 项目中心 │ │ 需求中心 │ │
|
||
│ │ fund-sys │ │ fund-cust │ │ fund-proj │ │ fund-req │ │
|
||
│ │ │ │ │ │ │ │ │ │
|
||
│ │ • 用户管理 │ │ • 客户管理 │ │ • 项目管理 │ │ • 需求工单 │ │
|
||
│ │ • 权限管理 │ │ • 联系人管理 │ │ • 成员管理 │ │ • 应收款管理 │ │
|
||
│ │ • 部门管理 │ │ │ │ │ │ │ │
|
||
│ │ • 日志管理 │ │ │ │ │ │ │ │
|
||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||
│ │ │ │ │ │
|
||
│ ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ │
|
||
│ │ 支出中心 │ │ 收款中心 │ │ 报表中心 │ │ 文件中心 │ │
|
||
│ │ fund-exp │ │ fund-receipt │ │ fund-report │ │ fund-file │ │
|
||
│ │ │ │ │ │ │ │ │ │
|
||
│ │ • 支出类型 │ │ • 收款记录 │ │ • 收支统计 │ │ • 文件上传 │ │
|
||
│ │ • 支出申请 │ │ • 收款凭证 │ │ • 趋势分析 │ │ • 文件存储 │ │
|
||
│ │ • 付款管理 │ │ • 账期管理 │ │ • 数据导出 │ │ • 文件下载 │ │
|
||
│ │ • 统计分析 │ │ │ │ │ │ │ │
|
||
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||
│ │
|
||
└──────────────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────────────────────────┼────────────────────────────────────────┐
|
||
│ 数据层 (Data Layer) │
|
||
│ │ │
|
||
│ ┌─────────────────────────────────┴─────────────────────────────────────┐ │
|
||
│ │ Redis 缓存集群 │ │
|
||
│ │ 会话缓存 / 热点数据 / 分布式锁 / 限流计数器 │ │
|
||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ MySQL 8.0 主从集群 │ │
|
||
│ │ 用户库 / 业务库 / 日志库 / 报表库 │ │
|
||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ 腾讯COS 对象存储 │ │
|
||
│ │ 文件存储 / 图片存储 / 凭证附件 │ │
|
||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
└──────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 服务划分
|
||
|
||
| 服务名 | 服务编码 | 职责 | 端口范围 |
|
||
|--------|----------|------|----------|
|
||
| 网关服务 | fund-gateway | 路由、鉴权、限流 | 8080 |
|
||
| 系统服务 | fund-sys | 用户、权限、部门、日志 | 8100-8109 |
|
||
| 客户中心 | fund-cust | 客户、联系人管理 | 8110-8119 |
|
||
| 项目中心 | fund-proj | 项目、成员管理 | 8120-8129 |
|
||
| 需求中心 | fund-req | 需求工单、应收款 | 8130-8139 |
|
||
| 支出中心 | fund-exp | 支出类型、支出管理 | 8140-8149 |
|
||
| 收款中心 | fund-receipt | 收款记录、账期管理 | 8150-8159 |
|
||
| 报表中心 | fund-report | 统计分析、数据导出 | 8160-8169 |
|
||
| 文件中心 | fund-file | 文件上传、存储管理 | 8170-8179 |
|
||
|
||
---
|
||
|
||
## 三、技术架构
|
||
|
||
### 3.1 技术栈选型
|
||
|
||
#### 3.1.1 后端技术栈
|
||
|
||
| 层级 | 技术选型 | 版本 | 用途 |
|
||
|------|----------|------|------|
|
||
| **基础框架** | Spring Boot | 3.2.x | 应用基础框架 |
|
||
| **微服务框架** | Spring Cloud Alibaba | 2022.x | 微服务治理 |
|
||
| **服务注册** | Nacos | 3.0.0 | 服务注册与配置中心 |
|
||
| **服务网关** | Spring Cloud Gateway | 4.x | API网关 |
|
||
| **负载均衡** | Spring Cloud LoadBalancer | 4.x | 客户端负载均衡 |
|
||
| **服务调用** | OpenFeign | 4.x | 声明式HTTP客户端 |
|
||
| **服务容错** | Sentinel | 1.8.x | 限流、熔断、降级 |
|
||
| **ORM框架** | MyBatis-Plus | 3.5.x | 数据访问层 |
|
||
| **数据库连接池** | HikariCP | 5.x | 高性能连接池 |
|
||
| **缓存框架** | Spring Data Redis | 3.x | Redis操作 |
|
||
| **安全框架** | Apache Shiro | 2.x | 认证授权、会话管理 |
|
||
| **JWT令牌** | jjwt | 0.12.x | Token生成与验证 |
|
||
| **API文档** | Knife4j | 4.x | Swagger增强 |
|
||
| **对象存储** | 腾讯云COS SDK | 5.x | 文件存储 |
|
||
| **定时任务** | XXL-JOB | 2.4.x | 分布式任务调度 |
|
||
| **日志框架** | Logback + SLF4J | - | 日志记录 |
|
||
| **工具类库** | Hutool | 5.8.x | 常用工具 |
|
||
|
||
#### 3.1.2 前端技术栈
|
||
|
||
| 端 | 技术选型 | 版本 | 用途 |
|
||
|----|----------|------|------|
|
||
| **管理后台** | Vue | 3.4.x | 前端框架 |
|
||
| | TypeScript | 5.x | 类型安全 |
|
||
| | Element Plus | 2.5.x | UI组件库 |
|
||
| | Pinia | 2.x | 状态管理 |
|
||
| | Vue Router | 4.x | 路由管理 |
|
||
| | Axios | 1.x | HTTP客户端 |
|
||
| | ECharts | 5.x | 图表库 |
|
||
| | Vite | 5.x | 构建工具 |
|
||
| **移动端** | UniApp | 3.x | 跨端框架 |
|
||
| | Vue | 3.x | 前端框架 |
|
||
| | uView UI | 2.x | 移动端UI库 |
|
||
|
||
### 3.2 架构分层
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 表现层 (Presentation) │
|
||
│ Controller / DTO / VO / 参数校验 / 异常处理 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 业务层 (Business) │
|
||
│ Service / 业务逻辑 / 事务管理 / 权限校验 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 数据访问层 (Data Access) │
|
||
│ Mapper / DAO / MyBatis-Plus / 数据库操作 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 基础设施层 (Infrastructure) │
|
||
│ Redis / MySQL / COS / MQ / 缓存 / 消息队列 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 四、数据架构
|
||
|
||
### 4.1 数据库设计
|
||
|
||
#### 4.1.1 数据库划分
|
||
|
||
| 数据库 | 用途 | 主要表 |
|
||
|--------|------|--------|
|
||
| fund_sys | 系统数据 | sys_user, sys_dept, sys_role, sys_menu, sys_log |
|
||
| fund_biz | 业务数据 | customer, project, requirement, expense, receivable, receipt |
|
||
| fund_file | 文件数据 | file_record, file_chunk |
|
||
|
||
#### 4.1.2 分库分表策略
|
||
|
||
| 表名 | 策略 | 分片键 | 说明 |
|
||
|------|------|--------|------|
|
||
| sys_operation_log | 按时间分表 | created_time | 每月一张表 |
|
||
| expense | 按时间分表 | expense_date | 每季度一张表 |
|
||
| receipt | 按时间分表 | receipt_date | 每季度一张表 |
|
||
|
||
### 4.2 缓存设计
|
||
|
||
#### 4.2.1 缓存策略
|
||
|
||
| 缓存类型 | Key前缀 | 过期时间 | 说明 |
|
||
|----------|---------|----------|------|
|
||
| 用户会话 | `session:` | 30分钟 | JWT Token 黑名单 |
|
||
| 用户信息 | `user:info:` | 1小时 | 用户基本信息缓存 |
|
||
| 字典数据 | `dict:` | 24小时 | 系统字典数据 |
|
||
| 热点数据 | `hot:` | 10分钟 | 频繁访问数据 |
|
||
| 限流计数 | `limit:` | 1分钟 | API限流计数 |
|
||
| 分布式锁 | `lock:` | 30秒 | 并发控制 |
|
||
|
||
#### 4.2.2 缓存更新策略
|
||
|
||
```
|
||
┌─────────────┐ 查询 ┌─────────────┐
|
||
│ 业务请求 │ ───────────> │ 查询缓存 │
|
||
└─────────────┘ └──────┬──────┘
|
||
│
|
||
┌───────────────┼───────────────┐
|
||
│ 命中 │ 未命中 │
|
||
▼ ▼ │
|
||
┌─────────────┐ ┌─────────────┐ │
|
||
│ 返回缓存 │ │ 查询数据库 │ │
|
||
│ 数据 │ │ │ │
|
||
└─────────────┘ └──────┬──────┘ │
|
||
│ │
|
||
▼ │
|
||
┌─────────────┐ │
|
||
│ 写入缓存 │<─────┘
|
||
│ 设置过期 │
|
||
└─────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 五、安全架构
|
||
|
||
### 5.1 认证授权(Apache Shiro)
|
||
|
||
#### 5.1.1 Shiro 架构设计
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Shiro 认证授权架构 │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Subject(主体) │ │
|
||
│ │ 封装了当前用户的登录状态、权限信息 │ │
|
||
│ └────────────────────────────────┬────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ SecurityManager(安全管理器) │ │
|
||
│ │ 核心组件,管理所有 Subject │ │
|
||
│ └──────────────┬─────────────────┼─────────────────┬───────────────────┘ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||
│ │ Authenticator │ │ Authorizer │ │ SessionManager │ │
|
||
│ │ (认证器) │ │ (授权器) │ │ (会话管理器) │ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ • 登录认证 │ │ • 角色校验 │ │ • 会话创建 │ │
|
||
│ │ • 多Realm认证 │ │ • 权限校验 │ │ • 会话存储 │ │
|
||
│ │ • rememberMe │ │ • 注解权限 │ │ • 会话超时 │ │
|
||
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||
│ │ Realm │ │ SessionDAO │ │ CacheManager │ │
|
||
│ │ (领域) │ │ (会话持久化) │ │ (缓存管理) │ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ • 用户数据查询 │ │ • Redis存储 │ │ • 认证缓存 │ │
|
||
│ │ • 密码比对 │ │ • 分布式会话 │ │ • 授权缓存 │ │
|
||
│ │ • 权限数据加载 │ │ │ │ • 会话缓存 │ │
|
||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 5.1.2 Shiro + JWT 认证流程
|
||
|
||
```
|
||
┌─────────┐ ┌─────────┐
|
||
│ 客户端 │ │ 服务端 │
|
||
└────┬────┘ └────┬────┘
|
||
│ │
|
||
│ 1. 登录请求 (username/password) │
|
||
│ ──────────────────────────────────────> │
|
||
│ │
|
||
│ ┌─────────┴─────────┐
|
||
│ │ Shiro Realm 认证 │
|
||
│ │ • 查询用户数据 │
|
||
│ │ • 密码比对(Bcrypt)│
|
||
│ │ • 加载用户权限 │
|
||
│ └─────────┬─────────┘
|
||
│ │
|
||
│ 2. 生成 JWT Token │
|
||
│ {uid, uname, tenantId, roles, perm} │
|
||
│ <────────────────────────────────────── │
|
||
│ │
|
||
│ 3. 业务请求 (Header: Authorization) │
|
||
│ Header: X-Uid: 1001 │
|
||
│ Header: X-Uname: admin │
|
||
│ ──────────────────────────────────────> │
|
||
│ │
|
||
│ ┌─────────┴─────────┐
|
||
│ │ JWT Filter 验证 │
|
||
│ │ • Token合法性 │
|
||
│ │ • Token过期检查 │
|
||
│ │ • 刷新Token(可选) │
|
||
│ └─────────┬─────────┘
|
||
│ │
|
||
│ ┌─────────┴─────────┐
|
||
│ │ Shiro 授权检查 │
|
||
│ │ • @RequiresRoles │
|
||
│ │ • @RequiresPerms │
|
||
│ └─────────┬─────────┘
|
||
│ │
|
||
│ 4. 返回业务数据 │
|
||
│ <────────────────────────────────────── │
|
||
```
|
||
|
||
#### 5.1.3 Shiro 核心配置
|
||
|
||
```
|
||
┌─────────┐ ┌─────────┐
|
||
│ 客户端 │ │ 服务端 │
|
||
└────┬────┘ └────┬────┘
|
||
│ │
|
||
│ 1. 登录请求 (username/password) │
|
||
│ ──────────────────────────────────────> │
|
||
│ │
|
||
│ ┌─────────┴─────────┐
|
||
│ │ 验证用户名密码 │
|
||
│ │ 生成JWT Token │
|
||
│ └─────────┬─────────┘
|
||
│ │
|
||
│ 2. 返回 Token │
|
||
│ <────────────────────────────────────── │
|
||
│ │
|
||
│ 3. 业务请求 (Header: Authorization) │
|
||
│ ──────────────────────────────────────> │
|
||
│ ┌─────────┴─────────┐
|
||
│ │ 验证Token有效性 │
|
||
│ │ 解析用户信息 │
|
||
│ │ 权限校验 │
|
||
│ └─────────┬─────────┘
|
||
│ │
|
||
│ 4. 返回业务数据 │
|
||
│ <────────────────────────────────────── │
|
||
```
|
||
|
||
#### 5.1.3 Shiro 核心实现
|
||
|
||
**1. Shiro 配置类**
|
||
|
||
```java
|
||
/**
|
||
* Shiro 配置类
|
||
*/
|
||
@Configuration
|
||
public class ShiroConfig {
|
||
|
||
/**
|
||
* SecurityManager 配置
|
||
*/
|
||
@Bean
|
||
public DefaultWebSecurityManager securityManager(
|
||
JwtRealm jwtRealm,
|
||
SessionManager sessionManager,
|
||
CacheManager cacheManager) {
|
||
|
||
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
|
||
|
||
// 配置 Realm
|
||
securityManager.setRealm(jwtRealm);
|
||
|
||
// 配置会话管理器
|
||
securityManager.setSessionManager(sessionManager);
|
||
|
||
// 配置缓存管理器
|
||
securityManager.setCacheManager(cacheManager);
|
||
|
||
// 配置记住我
|
||
securityManager.setRememberMeManager(rememberMeManager());
|
||
|
||
return securityManager;
|
||
}
|
||
|
||
/**
|
||
* Shiro 过滤器链配置
|
||
*/
|
||
@Bean
|
||
public ShiroFilterFactoryBean shiroFilterFactoryBean(
|
||
DefaultWebSecurityManager securityManager) {
|
||
|
||
ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
|
||
filterFactoryBean.setSecurityManager(securityManager);
|
||
|
||
// 自定义过滤器
|
||
Map<String, Filter> filters = new HashMap<>();
|
||
filters.put("jwt", new JwtAuthenticationFilter());
|
||
filterFactoryBean.setFilters(filters);
|
||
|
||
// 过滤器链规则
|
||
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
|
||
filterChainDefinitionMap.put("/api/auth/login", "anon");
|
||
filterChainDefinitionMap.put("/api/auth/logout", "anon");
|
||
filterChainDefinitionMap.put("/swagger-ui/**", "anon");
|
||
filterChainDefinitionMap.put("/v3/api-docs/**", "anon");
|
||
filterChainDefinitionMap.put("/**", "jwt");
|
||
|
||
filterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
|
||
|
||
return filterFactoryBean;
|
||
}
|
||
|
||
/**
|
||
* 会话管理器(使用 Redis 实现分布式会话)
|
||
*/
|
||
@Bean
|
||
public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
|
||
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
|
||
|
||
// 使用 Redis 存储会话
|
||
sessionManager.setSessionDAO(redisSessionDAO);
|
||
|
||
// 会话超时时间(30分钟)
|
||
sessionManager.setGlobalSessionTimeout(30 * 60 * 1000);
|
||
|
||
// 删除无效会话
|
||
sessionManager.setDeleteInvalidSessions(true);
|
||
|
||
// 会话验证间隔
|
||
sessionManager.setSessionValidationInterval(10 * 60 * 1000);
|
||
|
||
return sessionManager;
|
||
}
|
||
|
||
/**
|
||
* Redis 会话 DAO
|
||
*/
|
||
@Bean
|
||
public RedisSessionDAO redisSessionDAO(RedisTemplate<String, Object> redisTemplate) {
|
||
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
|
||
redisSessionDAO.setRedisTemplate(redisTemplate);
|
||
redisSessionDAO.setKeyPrefix("shiro:session:");
|
||
redisSessionDAO.setExpire(30 * 60);
|
||
return redisSessionDAO;
|
||
}
|
||
|
||
/**
|
||
* 缓存管理器(Redis)
|
||
*/
|
||
@Bean
|
||
public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
|
||
RedisCacheManager cacheManager = new RedisCacheManager();
|
||
cacheManager.setRedisTemplate(redisTemplate);
|
||
cacheManager.setKeyPrefix("shiro:cache:");
|
||
return cacheManager;
|
||
}
|
||
|
||
/**
|
||
* 启用 Shiro 注解
|
||
*/
|
||
@Bean
|
||
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
|
||
DefaultAdvisorAutoProxyCreator proxyCreator = new DefaultAdvisorAutoProxyCreator();
|
||
proxyCreator.setProxyTargetClass(true);
|
||
return proxyCreator;
|
||
}
|
||
|
||
@Bean
|
||
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
|
||
DefaultWebSecurityManager securityManager) {
|
||
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
|
||
advisor.setSecurityManager(securityManager);
|
||
return advisor;
|
||
}
|
||
}
|
||
```
|
||
|
||
**2. JWT Realm 实现**
|
||
|
||
```java
|
||
/**
|
||
* JWT Realm - 认证和授权
|
||
*/
|
||
@Component
|
||
public class JwtRealm extends AuthorizingRealm {
|
||
|
||
@Autowired
|
||
private JwtUtil jwtUtil;
|
||
|
||
@Autowired
|
||
private UserService userService;
|
||
|
||
@Autowired
|
||
private PermissionService permissionService;
|
||
|
||
/**
|
||
* 支持 JWT Token
|
||
*/
|
||
@Override
|
||
public boolean supports(AuthenticationToken token) {
|
||
return token instanceof JwtToken;
|
||
}
|
||
|
||
/**
|
||
* 授权(验证权限时调用)
|
||
*/
|
||
@Override
|
||
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
|
||
// 获取用户ID
|
||
Long userId = (Long) principals.getPrimaryPrincipal();
|
||
|
||
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
|
||
|
||
// 查询用户角色
|
||
Set<String> roles = permissionService.getUserRoles(userId);
|
||
authorizationInfo.setRoles(roles);
|
||
|
||
// 查询用户权限
|
||
Set<String> permissions = permissionService.getUserPermissions(userId);
|
||
authorizationInfo.setStringPermissions(permissions);
|
||
|
||
return authorizationInfo;
|
||
}
|
||
|
||
/**
|
||
* 认证(登录时调用)
|
||
*/
|
||
@Override
|
||
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
|
||
throws AuthenticationException {
|
||
|
||
JwtToken jwtToken = (JwtToken) token;
|
||
String jwt = jwtToken.getToken();
|
||
|
||
// 验证 JWT
|
||
if (!jwtUtil.validateToken(jwt)) {
|
||
throw new AuthenticationException("Token 无效或已过期");
|
||
}
|
||
|
||
// 解析 JWT 获取用户ID
|
||
Long userId = jwtUtil.getUserId(jwt);
|
||
String username = jwtUtil.getUsername(jwt);
|
||
|
||
// 查询用户信息
|
||
User user = userService.getById(userId);
|
||
if (user == null) {
|
||
throw new UnknownAccountException("用户不存在");
|
||
}
|
||
|
||
// 检查用户状态
|
||
if (user.getStatus() == 0) {
|
||
throw new LockedAccountException("账号已被禁用");
|
||
}
|
||
|
||
// 将用户信息存入上下文(供后续使用)
|
||
UserContext.setCurrentUser(user);
|
||
|
||
// 返回认证信息
|
||
return new SimpleAuthenticationInfo(
|
||
userId, // 主体(用户ID)
|
||
jwt, // 凭证(JWT)
|
||
getName() // Realm 名称
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* JWT Token 封装
|
||
*/
|
||
public class JwtToken implements AuthenticationToken {
|
||
|
||
private final String token;
|
||
|
||
public JwtToken(String token) {
|
||
this.token = token;
|
||
}
|
||
|
||
@Override
|
||
public Object getPrincipal() {
|
||
return token;
|
||
}
|
||
|
||
@Override
|
||
public Object getCredentials() {
|
||
return token;
|
||
}
|
||
|
||
public String getToken() {
|
||
return token;
|
||
}
|
||
}
|
||
```
|
||
|
||
**3. JWT 过滤器**
|
||
|
||
```java
|
||
/**
|
||
* JWT 认证过滤器
|
||
*/
|
||
public class JwtAuthenticationFilter extends BasicHttpAuthenticationFilter {
|
||
|
||
@Override
|
||
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
|
||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||
String authorization = httpRequest.getHeader("Authorization");
|
||
return authorization != null && authorization.startsWith("Bearer ");
|
||
}
|
||
|
||
@Override
|
||
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
|
||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||
String authorization = httpRequest.getHeader("Authorization");
|
||
String token = authorization.substring(7);
|
||
|
||
JwtToken jwtToken = new JwtToken(token);
|
||
|
||
try {
|
||
// 提交给 Realm 进行认证
|
||
getSubject(request, response).login(jwtToken);
|
||
return true;
|
||
} catch (AuthenticationException e) {
|
||
// 认证失败
|
||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||
httpResponse.setContentType("application/json;charset=UTF-8");
|
||
httpResponse.getWriter().write("{\"code\":401,\"message\":\"认证失败\"}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
@Override
|
||
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
|
||
if (isLoginAttempt(request, response)) {
|
||
try {
|
||
return executeLogin(request, response);
|
||
} catch (Exception e) {
|
||
return false;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
@Override
|
||
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
|
||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||
httpResponse.setContentType("application/json;charset=UTF-8");
|
||
httpResponse.getWriter().write("{\"code\":401,\"message\":\"请先登录\"}");
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
**4. 权限注解使用**
|
||
|
||
```java
|
||
/**
|
||
* 用户控制器
|
||
*/
|
||
@RestController
|
||
@RequestMapping("/api/user")
|
||
public class UserController {
|
||
|
||
/**
|
||
* 需要登录
|
||
*/
|
||
@RequiresAuthentication
|
||
@GetMapping("/info")
|
||
public Result<UserVO> getUserInfo() {
|
||
Long userId = UserContext.getCurrentUserId();
|
||
return Result.success(userService.getUserInfo(userId));
|
||
}
|
||
|
||
/**
|
||
* 需要 admin 角色
|
||
*/
|
||
@RequiresRoles("admin")
|
||
@GetMapping("/list")
|
||
public Result<Page<UserVO>> listUsers(PageParam pageParam) {
|
||
return Result.success(userService.listUsers(pageParam));
|
||
}
|
||
|
||
/**
|
||
* 需要 user:create 权限
|
||
*/
|
||
@RequiresPermissions("user:create")
|
||
@PostMapping
|
||
public Result<Void> createUser(@RequestBody @Valid UserCreateDTO dto) {
|
||
userService.createUser(dto);
|
||
return Result.success();
|
||
}
|
||
|
||
/**
|
||
* 需要多个权限(逻辑与)
|
||
*/
|
||
@RequiresPermissions({"user:update", "user:assignRole"})
|
||
@PutMapping("/{userId}/role")
|
||
public Result<Void> assignRole(@PathVariable Long userId, @RequestBody List<Long> roleIds) {
|
||
userService.assignRoles(userId, roleIds);
|
||
return Result.success();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 5.1.4 权限模型
|
||
|
||
采用 **RBAC** (Role-Based Access Control) 模型:
|
||
|
||
```
|
||
用户 (User) ──N:M──> 角色 (Role) ──N:M──> 权限 (Permission)
|
||
│
|
||
└─N:M──> 菜单/按钮 (Menu)
|
||
```
|
||
|
||
### 5.2 数据安全
|
||
|
||
| 安全措施 | 实现方式 |
|
||
|----------|----------|
|
||
| 传输加密 | HTTPS/TLS 1.3 |
|
||
| 密码加密 | BCrypt (强度10) |
|
||
| 敏感数据 | AES-256 加密存储 |
|
||
| SQL注入 | MyBatis预编译语句 |
|
||
| XSS攻击 | 前端转义 + 后端过滤 |
|
||
| CSRF攻击 | Token验证 + SameSite Cookie |
|
||
|
||
---
|
||
|
||
## 六、部署架构
|
||
|
||
### 6.1 生产环境部署
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ 负载均衡层 │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ Nginx (主) + Nginx (备) │ │
|
||
│ │ Keepalived VIP: 192.168.1.10 │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ 应用服务层 │
|
||
│ │
|
||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ Gateway-1 │ │ Gateway-2 │ │ Gateway-3 │ │
|
||
│ │ :8080 │ │ :8080 │ │ :8080 │ │
|
||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||
│ │ │ │ │
|
||
│ ┌──────┴────────────────┴────────────────┴──────┐ │
|
||
│ │ Nacos 集群 (3节点) │ │
|
||
│ │ 8848 / 9848 / 7848 │ │
|
||
│ └───────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │fund-sys │ │fund-cust │ │fund-proj │ │fund-req │ │
|
||
│ │ x 2 │ │ x 2 │ │ x 2 │ │ x 2 │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │fund-exp │ │fund-rec │ │fund-rpt │ │fund-file │ │
|
||
│ │ x 2 │ │ x 2 │ │ x 2 │ │ x 2 │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ 数据存储层 │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ Redis 集群 (6节点,3主3从) │ │
|
||
│ │ 6379 / 6380 / 6381 │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ MySQL 主从集群 │ │
|
||
│ │ Master: 3306 ──> Slave: 3306 (x2) │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 6.2 容器化部署
|
||
|
||
#### 6.2.1 Docker Compose 配置
|
||
|
||
```yaml
|
||
version: '3.8'
|
||
services:
|
||
# Nacos
|
||
nacos:
|
||
image: nacos/nacos-server:v3.0.0
|
||
ports:
|
||
- "8848:8848"
|
||
- "9848:9848"
|
||
environment:
|
||
- MODE=standalone
|
||
|
||
# Redis
|
||
redis:
|
||
image: redis:8.0.2
|
||
ports:
|
||
- "6379:6379"
|
||
command: redis-server --requirepass zjf@123456
|
||
|
||
# MySQL
|
||
mysql:
|
||
image: mysql:8.0
|
||
ports:
|
||
- "3306:3306"
|
||
environment:
|
||
- MYSQL_ROOT_PASSWORD=zjf@123456
|
||
volumes:
|
||
- ./sql:/docker-entrypoint-initdb.d
|
||
|
||
# Gateway
|
||
gateway:
|
||
image: fundplatform/fund-gateway:1.0.0
|
||
ports:
|
||
- "8080:8080"
|
||
environment:
|
||
- NACOS_SERVER=nacos:8848
|
||
|
||
# Services
|
||
fund-sys:
|
||
image: fundplatform/fund-sys:1.0.0
|
||
deploy:
|
||
replicas: 2
|
||
environment:
|
||
- NACOS_SERVER=nacos:8848
|
||
- DB_HOST=mysql
|
||
- REDIS_HOST=redis
|
||
```
|
||
|
||
---
|
||
|
||
## 七、监控与运维
|
||
|
||
### 7.1 监控体系
|
||
|
||
| 监控类型 | 工具 | 监控内容 |
|
||
|----------|------|----------|
|
||
| **应用监控** | Prometheus + Grafana | JVM、接口响应、错误率 |
|
||
| **链路追踪** | SkyWalking | 调用链、性能瓶颈 |
|
||
| **日志收集** | ELK Stack | 业务日志、错误日志 |
|
||
| **告警通知** | AlertManager | 钉钉/邮件/短信告警 |
|
||
|
||
### 7.2 运维脚本
|
||
|
||
```bash
|
||
# 服务启动脚本
|
||
#!/bin/bash
|
||
SERVICES=("gateway" "sys" "cust" "proj" "req" "exp" "receipt" "report" "file")
|
||
for service in "${SERVICES[@]}"; do
|
||
docker-compose up -d fund-${service}
|
||
sleep 5
|
||
done
|
||
|
||
# 健康检查脚本
|
||
#!/bin/bash
|
||
curl -f http://localhost:8080/actuator/health || exit 1
|
||
```
|
||
|
||
---
|
||
|
||
## 八、开发规范
|
||
|
||
### 8.1 代码结构
|
||
|
||
```
|
||
fund-sys/
|
||
├── src/
|
||
│ ├── main/
|
||
│ │ ├── java/
|
||
│ │ │ └── com/fundplatform/sys/
|
||
│ │ │ ├── SysApplication.java
|
||
│ │ │ ├── config/ # 配置类
|
||
│ │ │ ├── controller/ # 控制器层
|
||
│ │ │ ├── service/ # 业务层
|
||
│ │ │ │ └── impl/
|
||
│ │ │ ├── mapper/ # 数据访问层
|
||
│ │ │ ├── entity/ # 实体类
|
||
│ │ │ ├── dto/ # 数据传输对象
|
||
│ │ │ ├── vo/ # 视图对象
|
||
│ │ │ ├── enums/ # 枚举类
|
||
│ │ │ ├── utils/ # 工具类
|
||
│ │ │ └── handler/ # 处理器
|
||
│ │ └── resources/
|
||
│ │ ├── application.yml
|
||
│ │ ├── application-dev.yml
|
||
│ │ ├── application-prod.yml
|
||
│ │ └── mapper/ # XML映射文件
|
||
│ └── test/
|
||
├── Dockerfile
|
||
├── pom.xml
|
||
└── README.md
|
||
```
|
||
|
||
### 8.2 API 设计规范
|
||
|
||
| 规范项 | 要求 |
|
||
|--------|------|
|
||
| **URL** | /api/{service}/{version}/{resource}/{action} |
|
||
| **HTTP方法** | GET(查)、POST(增)、PUT(改)、DELETE(删) |
|
||
| **响应格式** | { "code": 200, "message": "", "data": {} } |
|
||
| **分页** | pageNum, pageSize, total, list |
|
||
| **版本** | v1, v2 在URL中体现 |
|
||
|
||
---
|
||
|
||
## 九、附录
|
||
|
||
### 9.1 端口分配表
|
||
|
||
| 服务 | 端口 | 说明 |
|
||
|------|------|------|
|
||
| Nginx | 80/443 | 负载均衡 |
|
||
| Gateway | 8080 | API网关 |
|
||
| Nacos | 8848/9848 | 注册配置中心 |
|
||
| Redis | 6379 | 缓存服务 |
|
||
| MySQL | 3306 | 数据库 |
|
||
| XXL-JOB | 8081 | 任务调度中心 |
|
||
|
||
### 9.2 文档修订记录
|
||
|
||
| 版本 | 修订日期 | 修订内容 | 修订人 |
|
||
|------|----------|----------|--------|
|
||
| v1.0 | 2026-02-13 | 初始版本 | zhangjf |
|
||
| v1.1 | 2026-02-13 | 补充多租户架构(一库多租户/一库一租户)和 Head 日志追踪设计 | zhangjf |
|
||
| v1.2 | 2026-02-13 | 补充 Shiro 认证框架、服务调用链 uid/uname 传递设计 | zhangjf |
|
||
| v1.3 | 2026-02-13 | 补充 HikariCP 连接池、支持异步场景的 UserContext 封装 | zhangjf |
|
||
|
||
---
|
||
|
||
**文档结束**
|