问题:TenantRoutingProperties 定义了配置但未被使用 解决方案: 1. TenantAwareLoadBalancer 注入 TenantRoutingProperties - 使用配置的 tenantHeader 名称 - 使用配置的 buildTenantGroup 方法 - 使用配置的 isSharedService 判断 - 使用配置的 isFallbackToShared 策略 2. 新增功能 - 支持 enabled=false 禁用租户路由 - 共享服务跳过租户过滤 - 可配置是否回退到共享实例 3. 更新测试适配新构造函数
3787 lines
161 KiB
Markdown
3787 lines
161 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. 统一全局上下文管理(GlobalContext)**
|
||
|
||
为了统一管理和传递全局参数(tid、uid、uname、traceId 等),设计一个综合的 GlobalContext:
|
||
|
||
```java
|
||
/**
|
||
* 全局上下文持有者
|
||
* 统一管理租户ID、用户ID、用户名、TraceId等全局参数
|
||
* 支持同步和异步场景
|
||
*/
|
||
public class GlobalContext {
|
||
|
||
// ========== 全局参数键名 ==========
|
||
public static final String KEY_TENANT_ID = "tid";
|
||
public static final String KEY_USER_ID = "uid";
|
||
public static final String KEY_USERNAME = "uname";
|
||
public static final String KEY_TRACE_ID = "traceId";
|
||
public static final String KEY_TENANT_GROUP = "tgroup";
|
||
|
||
// ========== ThreadLocal 存储 ==========
|
||
private static final ThreadLocal<Map<String, Object>> CONTEXT = ThreadLocal.withInitial(HashMap::new);
|
||
|
||
// ========== 全局上下文载体(支持异步传递)==========
|
||
@Data
|
||
@Builder
|
||
@NoArgsConstructor
|
||
@AllArgsConstructor
|
||
public static class ContextSnapshot {
|
||
private String tenantId;
|
||
private Long userId;
|
||
private String username;
|
||
private String traceId;
|
||
private String tenantGroup;
|
||
private Map<String, Object> extra;
|
||
|
||
public static ContextSnapshot create() {
|
||
return ContextSnapshot.builder()
|
||
.tenantId(getTenantId())
|
||
.userId(getUserId())
|
||
.username(getUsername())
|
||
.traceId(getTraceId())
|
||
.tenantGroup(getTenantGroup())
|
||
.extra(new HashMap<>(getAll()))
|
||
.build();
|
||
}
|
||
|
||
public void apply() {
|
||
if (tenantId != null) set(KEY_TENANT_ID, tenantId);
|
||
if (userId != null) set(KEY_USER_ID, userId);
|
||
if (username != null) set(KEY_USERNAME, username);
|
||
if (traceId != null) set(KEY_TRACE_ID, traceId);
|
||
if (tenantGroup != null) set(KEY_TENANT_GROUP, tenantGroup);
|
||
if (extra != null) extra.forEach((k, v) -> {
|
||
if (!CONTEXT.get().containsKey(k)) set(k, v);
|
||
});
|
||
}
|
||
}
|
||
|
||
// ========== 基础操作 ==========
|
||
@SuppressWarnings("unchecked")
|
||
public static <T> T get(String key) { return (T) CONTEXT.get().get(key); }
|
||
|
||
public static void set(String key, Object value) {
|
||
CONTEXT.get().put(key, value);
|
||
if (value != null) MDC.put(key, String.valueOf(value));
|
||
}
|
||
|
||
public static void clear() {
|
||
CONTEXT.get().clear();
|
||
MDC.clear();
|
||
}
|
||
|
||
// ========== 便捷方法 ==========
|
||
public static String getTenantId() { return get(KEY_TENANT_ID); }
|
||
public static void setTenantId(String tenantId) { set(KEY_TENANT_ID, tenantId); }
|
||
|
||
public static Long getUserId() {
|
||
Object uid = get(KEY_USER_ID);
|
||
return uid instanceof String ? Long.valueOf((String) uid) : (Long) uid;
|
||
}
|
||
public static void setUserId(Long userId) { set(KEY_USER_ID, userId); }
|
||
|
||
public static String getUsername() { return get(KEY_USERNAME); }
|
||
public static void setUsername(String username) { set(KEY_USERNAME, username); }
|
||
|
||
public static String getTraceId() { return get(KEY_TRACE_ID); }
|
||
public static void setTraceId(String traceId) { set(KEY_TRACE_ID, traceId); }
|
||
|
||
// ========== 异步场景支持 ==========
|
||
public static ContextSnapshot snapshot() { return ContextSnapshot.create(); }
|
||
|
||
public static Runnable wrap(Runnable runnable) {
|
||
ContextSnapshot snapshot = snapshot();
|
||
return () -> { try { snapshot.apply(); runnable.run(); } finally { clear(); } };
|
||
}
|
||
|
||
public static <T> Callable<T> wrap(Callable<T> callable) {
|
||
ContextSnapshot snapshot = snapshot();
|
||
return () -> { try { snapshot.apply(); return callable.call(); } finally { clear(); } };
|
||
}
|
||
|
||
public static <T> Supplier<T> wrap(Supplier<T> supplier) {
|
||
ContextSnapshot snapshot = snapshot();
|
||
return () -> { try { snapshot.apply(); return supplier.get(); } finally { clear(); } };
|
||
}
|
||
|
||
public static <T> CompletableFuture<T> supplyAsync(Supplier<T> supplier, Executor executor) {
|
||
return CompletableFuture.supplyAsync(wrap(supplier), executor);
|
||
}
|
||
|
||
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor) {
|
||
return CompletableFuture.runAsync(wrap(runnable), executor);
|
||
}
|
||
}
|
||
```
|
||
|
||
**6. GlobalContext 使用示例**
|
||
|
||
```java
|
||
// 同步场景
|
||
@Service
|
||
public class ProjectService {
|
||
public void createProject(ProjectDTO dto) {
|
||
String tid = GlobalContext.getTenantId();
|
||
Long uid = GlobalContext.getUserId();
|
||
String uname = GlobalContext.getUsername();
|
||
|
||
log.info("[{}] 租户 {} 用户 {} 创建项目", tid, uname);
|
||
|
||
Project project = new Project();
|
||
project.setTenantId(tid);
|
||
project.setCreatedBy(uid);
|
||
projectMapper.insert(project);
|
||
}
|
||
}
|
||
|
||
// 异步场景
|
||
@Service
|
||
public class AsyncService {
|
||
@Autowired private ThreadPoolExecutor executor;
|
||
|
||
public void asyncProcess(Long projectId) {
|
||
executor.execute(GlobalContext.wrap(() -> {
|
||
String tid = GlobalContext.getTenantId();
|
||
Long uid = GlobalContext.getUserId();
|
||
String uname = GlobalContext.getUsername();
|
||
|
||
log.info("[Async] 租户 {} 用户 {} 处理项目 {}", tid, uname, projectId);
|
||
|
||
Project project = projectMapper.selectById(projectId);
|
||
project.setProcessedBy(uid);
|
||
projectMapper.updateById(project);
|
||
}));
|
||
}
|
||
|
||
public CompletableFuture<Project> asyncQuery(Long projectId) {
|
||
return GlobalContext.supplyAsync(() -> {
|
||
log.info("[Async] 用户 {} 查询项目 {}", GlobalContext.getUsername(), projectId);
|
||
return projectMapper.selectById(projectId);
|
||
}, executor);
|
||
}
|
||
}
|
||
```
|
||
|
||
**原 5. 用户上下文管理(已合并到 GlobalContext)**
|
||
|
||
**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);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.7 API请求日志
|
||
|
||
#### 3.7.1 概述
|
||
|
||
API请求日志通过AOP(面向切面编程)实现,用于记录所有Controller层接口的请求信息,包括请求地址、请求参数、请求头和返回结果,并输出到独立的日志文件(aop.log),便于问题排查和审计追踪。
|
||
|
||
#### 3.7.2 技术实现
|
||
|
||
**1. Maven依赖**
|
||
|
||
```xml
|
||
<!-- AOP支持 -->
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-aop</artifactId>
|
||
</dependency>
|
||
|
||
<!-- Jackson Java 8日期时间支持 -->
|
||
<dependency>
|
||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||
</dependency>
|
||
```
|
||
|
||
**2. AOP切面实现**
|
||
|
||
```java
|
||
/**
|
||
* API请求日志切面
|
||
* 记录请求地址、请求参数、请求header和返回参数
|
||
*/
|
||
@Aspect
|
||
@Component
|
||
public class ApiLogAspect {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger("API_LOG");
|
||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||
|
||
static {
|
||
// 注册JavaTimeModule以支持Java 8日期时间类型
|
||
objectMapper.registerModule(new JavaTimeModule());
|
||
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||
}
|
||
|
||
/**
|
||
* 切点:所有Controller层方法
|
||
*/
|
||
@Pointcut("execution(* com.fundplatform..controller..*.*(..))")
|
||
public void controllerPointcut() {
|
||
}
|
||
|
||
/**
|
||
* 环绕通知:记录请求和响应信息
|
||
*/
|
||
@Around("controllerPointcut()")
|
||
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
|
||
long startTime = System.currentTimeMillis();
|
||
|
||
// 获取请求信息
|
||
ServletRequestAttributes attributes =
|
||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||
if (attributes == null) {
|
||
return joinPoint.proceed();
|
||
}
|
||
|
||
HttpServletRequest request = attributes.getRequest();
|
||
|
||
// 构建日志信息
|
||
Map<String, Object> logInfo = new HashMap<>();
|
||
|
||
// 1. 请求地址
|
||
logInfo.put("requestUrl", request.getRequestURL().toString());
|
||
logInfo.put("requestMethod", request.getMethod());
|
||
logInfo.put("requestUri", request.getRequestURI());
|
||
|
||
// 2. 请求参数(URL参数)
|
||
Map<String, Object> params = new HashMap<>();
|
||
Enumeration<String> paramNames = request.getParameterNames();
|
||
while (paramNames.hasMoreElements()) {
|
||
String paramName = paramNames.nextElement();
|
||
params.put(paramName, request.getParameter(paramName));
|
||
}
|
||
logInfo.put("requestParams", params);
|
||
|
||
// 3. 请求头(仅记录关键header)
|
||
Map<String, String> headers = new HashMap<>();
|
||
String[] keyHeaders = {"X-User-Id", "X-Tenant-Id", "Content-Type"};
|
||
for (String headerName : keyHeaders) {
|
||
String value = request.getHeader(headerName);
|
||
if (value != null) {
|
||
headers.put(headerName, value);
|
||
}
|
||
}
|
||
logInfo.put("requestHeaders", headers);
|
||
|
||
// 4. 请求体参数(POST/PUT等)
|
||
Object[] args = joinPoint.getArgs();
|
||
if (args != null && args.length > 0) {
|
||
for (Object arg : args) {
|
||
if (arg != null &&
|
||
!(arg instanceof HttpServletRequest) &&
|
||
!arg.getClass().getName().startsWith("jakarta.servlet")) {
|
||
logInfo.put("requestBody", arg);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 方法信息
|
||
logInfo.put("className", joinPoint.getTarget().getClass().getName());
|
||
logInfo.put("methodName", joinPoint.getSignature().getName());
|
||
|
||
// 执行方法并记录响应
|
||
Object result = null;
|
||
try {
|
||
result = joinPoint.proceed();
|
||
logInfo.put("status", "SUCCESS");
|
||
if (result != null) {
|
||
logInfo.put("responseType", result.getClass().getSimpleName());
|
||
try {
|
||
logInfo.put("responseBody", objectMapper.writeValueAsString(result));
|
||
} catch (Exception e) {
|
||
logInfo.put("responseBody", result.toString());
|
||
}
|
||
}
|
||
} catch (Exception e) {
|
||
logInfo.put("status", "FAILED");
|
||
logInfo.put("errorMessage", e.getMessage());
|
||
throw e;
|
||
} finally {
|
||
long costTime = System.currentTimeMillis() - startTime;
|
||
logInfo.put("costTime", costTime + "ms");
|
||
log.info(objectMapper.writeValueAsString(logInfo));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
}
|
||
```
|
||
|
||
**3. Logback配置**
|
||
|
||
```xml
|
||
<!-- API请求日志文件(AOP) -->
|
||
<appender name="FILE_AOP" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||
<file>${LOG_PATH}/${APP_NAME}/aop.log</file>
|
||
<encoder>
|
||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] - %msg%n</pattern>
|
||
<charset>UTF-8</charset>
|
||
</encoder>
|
||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||
<fileNamePattern>${LOG_PATH}/${APP_NAME}/aop-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||
<timeBasedFileNamingAndTriggeringPolicy
|
||
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
|
||
<maxFileSize>100MB</maxFileSize>
|
||
</timeBasedFileNamingAndTriggeringPolicy>
|
||
<maxHistory>30</maxHistory>
|
||
</rollingPolicy>
|
||
</appender>
|
||
|
||
<!-- API_LOG专用logger,输出到aop.log -->
|
||
<logger name="API_LOG" level="INFO" additivity="false">
|
||
<appender-ref ref="FILE_AOP"/>
|
||
</logger>
|
||
```
|
||
|
||
#### 3.7.3 日志格式
|
||
|
||
AOP日志以JSON格式输出,包含以下字段:
|
||
|
||
| 字段 | 说明 | 示例 |
|
||
|------|------|------|
|
||
| requestUrl | 完整请求URL | http://localhost:8100/api/v1/users |
|
||
| requestMethod | HTTP方法 | GET/POST/PUT/DELETE |
|
||
| requestUri | 请求URI | /api/v1/users |
|
||
| requestParams | URL查询参数 | {"page": "1", "size": "10"} |
|
||
| requestHeaders | 关键请求头 | {"X-User-Id": "1", "X-Tenant-Id": "1"} |
|
||
| requestBody | 请求体参数 | {"username": "admin"} |
|
||
| className | 处理类名 | UserController |
|
||
| methodName | 处理方法名 | list |
|
||
| status | 执行状态 | SUCCESS/FAILED |
|
||
| responseBody | 响应结果 | {"code": 200, "data": [...]} |
|
||
| errorMessage | 错误信息(失败时) | 用户不存在 |
|
||
| costTime | 执行耗时 | 25ms |
|
||
|
||
**日志示例:**
|
||
|
||
```json
|
||
{
|
||
"requestUrl": "http://localhost:8100/api/v1/auth/login",
|
||
"requestMethod": "POST",
|
||
"requestUri": "/api/v1/auth/login",
|
||
"requestParams": {},
|
||
"requestHeaders": {
|
||
"Content-Type": "application/json"
|
||
},
|
||
"requestBody": {
|
||
"username": "admin",
|
||
"password": "******"
|
||
},
|
||
"className": "com.fundplatform.sys.controller.AuthController",
|
||
"methodName": "login",
|
||
"status": "SUCCESS",
|
||
"responseType": "Result",
|
||
"responseBody": "{\"code\":200,\"data\":{\"token\":\"xxx\"}}",
|
||
"costTime": "125ms"
|
||
}
|
||
```
|
||
|
||
#### 3.7.4 日志文件说明
|
||
|
||
| 服务 | 日志路径 | 说明 |
|
||
|------|----------|------|
|
||
| fund-sys | logs/fund-sys/aop.log | fund-sys服务的API请求日志 |
|
||
| fund-cust | logs/fund-cust/aop.log | fund-cust服务的API请求日志 |
|
||
| fund-proj | logs/fund-proj/aop.log | fund-proj服务的API请求日志 |
|
||
|
||
#### 3.7.5 注意事项
|
||
|
||
1. **敏感信息过滤**:对于密码、token等敏感信息,应在记录前进行脱敏处理
|
||
2. **大对象处理**:对于响应体过大的接口,可考虑截断或跳过响应体记录
|
||
3. **性能影响**:AOP日志会增加少量性能开销,生产环境建议配置合理的日志级别
|
||
4. **文件滚动**:日志文件按日期和大小滚动,最大保留30天
|
||
|
||
---
|
||
|
||
## 四、系统整体架构
|
||
|
||
### 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 |
|
||
|
||
### 2.3 模块通信方式
|
||
|
||
系统采用**分层通信架构**,根据模块职责划分为基础服务和业务服务,采用不同的通信方式:
|
||
|
||
#### 2.3.1 基础服务 - Maven 依赖方式
|
||
|
||
**定义**:提供基础能力、不对外暴露 HTTP 接口的模块
|
||
|
||
| 基础服务 | 说明 | 提供能力 | 依赖方式 |
|
||
|---------|------|---------|---------|
|
||
| **fund-common** | 公共模块 | 基础实体、工具类、统一响应格式 | Maven 依赖 |
|
||
| **fund-gateway** | API 网关 | 路由转发、统一鉴权、限流熔断 | 独立部署 |
|
||
|
||
**特点**:
|
||
- ✅ **编译时依赖**:通过 Maven `<dependency>` 直接引入
|
||
- ✅ **本地调用**:方法级别直接调用,无网络开销
|
||
- ✅ **强类型约束**:编译期类型检查,更安全
|
||
- ✅ **版本统一**:通过父 POM 统一管理版本
|
||
|
||
**示例**:
|
||
```xml
|
||
<!-- 业务模块依赖 fund-common -->
|
||
<dependency>
|
||
<groupId>com.fundplatform</groupId>
|
||
<artifactId>fund-common</artifactId>
|
||
<version>${project.version}</version>
|
||
</dependency>
|
||
```
|
||
|
||
#### 2.3.2 业务服务 - OpenFeign 通信方式
|
||
|
||
**定义**:提供 HTTP API 接口、独立部署的业务模块
|
||
|
||
| 业务服务 | 说明 | 提供 API | 通信方式 |
|
||
|---------|------|---------|---------|
|
||
| **fund-sys** | 系统服务 | 用户、角色、权限、部门 API | OpenFeign |
|
||
| **fund-cust** | 客户中心 | 客户、联系人管理 API | OpenFeign |
|
||
| **fund-proj** | 项目中心 | 项目、成员管理 API | OpenFeign |
|
||
| **fund-req** | 需求中心 | 需求工单、应收款 API | OpenFeign |
|
||
| **fund-exp** | 支出中心 | 支出类型、支出管理 API | OpenFeign |
|
||
| **fund-receipt** | 收款中心 | 收款记录、账期管理 API | OpenFeign |
|
||
| **fund-report** | 报表中心 | 统计分析、数据导出 API | OpenFeign |
|
||
| **fund-file** | 文件中心 | 文件上传、存储管理 API | OpenFeign |
|
||
|
||
**特点**:
|
||
- ✅ **松耦合**:服务间独立部署、独立升级
|
||
- ✅ **声明式调用**:通过接口注解定义,自动生成实现
|
||
- ✅ **负载均衡**:集成 LoadBalancer,自动分发请求
|
||
- ✅ **服务发现**:通过 Nacos 动态获取服务地址
|
||
- ✅ **熔断降级**:集成 Sentinel,提供容错能力
|
||
- ✅ **链路追踪**:自动传递 TraceId、租户ID、用户信息
|
||
|
||
**示例**:
|
||
```java
|
||
/**
|
||
* 客户服务 Feign 客户端
|
||
*/
|
||
@FeignClient(
|
||
name = "fund-cust", // 服务名(Nacos注册名)
|
||
path = "/api/v1/customer", // 接口路径前缀
|
||
fallbackFactory = CustomerFallbackFactory.class // 降级处理
|
||
)
|
||
public interface CustomerFeignClient {
|
||
|
||
/**
|
||
* 根据客户ID查询客户信息
|
||
*/
|
||
@GetMapping("/{customerId}")
|
||
Result<Customer> getCustomerById(@PathVariable Long customerId);
|
||
|
||
/**
|
||
* 批量查询客户信息
|
||
*/
|
||
@PostMapping("/batch")
|
||
Result<List<Customer>> getCustomersByIds(@RequestBody List<Long> customerIds);
|
||
}
|
||
```
|
||
|
||
**调用示例**:
|
||
```java
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
public class ProjectService {
|
||
|
||
// 注入 Feign 客户端
|
||
private final CustomerFeignClient customerFeignClient;
|
||
|
||
public ProjectDetail getProjectDetail(Long projectId) {
|
||
// 1. 查询项目基本信息
|
||
Project project = projectMapper.selectById(projectId);
|
||
|
||
// 2. 通过 Feign 调用客户服务获取客户信息
|
||
Result<Customer> result = customerFeignClient.getCustomerById(project.getCustomerId());
|
||
Customer customer = result.getData();
|
||
|
||
// 3. 组装返回
|
||
return ProjectDetail.builder()
|
||
.project(project)
|
||
.customer(customer)
|
||
.build();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.3.3 通信链路追踪
|
||
|
||
所有 Feign 调用自动传递以下 Header 信息:
|
||
|
||
| Header 名称 | 说明 | 来源 | 用途 |
|
||
|------------|------|------|------|
|
||
| `X-Tenant-Id` | 租户ID | TenantContextHolder | 多租户数据隔离 |
|
||
| `X-Uid` | 用户ID | 当前登录用户 | 操作日志记录 |
|
||
| `X-Uname` | 用户名 | 当前登录用户 | 操作日志记录 |
|
||
| `X-Trace-Id` | 链路追踪ID | MDC.get("traceId") | 全链路日志追踪 |
|
||
|
||
**实现机制**:
|
||
```java
|
||
/**
|
||
* Feign 调用链拦截器
|
||
* 自动添加租户、用户、链路追踪信息到请求头
|
||
*/
|
||
@Component
|
||
public class FeignChainInterceptor implements RequestInterceptor {
|
||
|
||
@Override
|
||
public void apply(RequestTemplate template) {
|
||
// 1. 传递租户ID
|
||
Long tenantId = TenantContextHolder.getTenantId();
|
||
if (tenantId != null) {
|
||
template.header("X-Tenant-Id", String.valueOf(tenantId));
|
||
}
|
||
|
||
// 2. 传递用户信息
|
||
Long uid = GlobalContext.getUid();
|
||
String uname = GlobalContext.getUname();
|
||
if (uid != null) {
|
||
template.header("X-Uid", String.valueOf(uid));
|
||
template.header("X-Uname", uname);
|
||
}
|
||
|
||
// 3. 传递链路追踪ID
|
||
String traceId = MDC.get("traceId");
|
||
if (traceId != null) {
|
||
template.header("X-Trace-Id", traceId);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.3.4 通信架构图
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ 前端应用 │
|
||
│ (管理后台 + 移动端) │
|
||
└────────────────────────────┬────────────────────────────────────────┘
|
||
│ HTTP/HTTPS
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ fund-gateway (API网关) │
|
||
│ 路由、鉴权、限流、熔断 │
|
||
└──┬──────────────┬──────────────┬──────────────┬─────────────────────┘
|
||
│ │ │ │
|
||
│ HTTP │ HTTP │ HTTP │ HTTP
|
||
▼ ▼ ▼ ▼
|
||
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
|
||
│fund-sys│ │fund-cust│ │fund-proj│ │fund-exp│
|
||
│系统服务│ │客户中心│ │项目中心│ │支出中心│
|
||
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
|
||
│ │ │ │
|
||
│ Maven依赖 │ OpenFeign ◄──┤ OpenFeign ◄──┤
|
||
▼ ▼ ▼ ▼
|
||
┌──────────────────────────────────────────────────┐
|
||
│ fund-common (公共模块) │
|
||
│ (Result、BaseEntity、工具类等) │
|
||
└──────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 2.3.5 通信方式选择标准
|
||
|
||
| 场景 | 推荐方式 | 理由 |
|
||
|------|---------|------|
|
||
| 使用公共实体类 | Maven 依赖 | 编译期检查,无运行时开销 |
|
||
| 使用工具类方法 | Maven 依赖 | 直接调用,性能最优 |
|
||
| 跨服务调用 API | OpenFeign | 服务解耦,支持独立部署 |
|
||
| 查询其他服务数据 | OpenFeign | 通过 HTTP API,标准化通信 |
|
||
| 调用第三方服务 | OpenFeign | 统一的调用方式和错误处理 |
|
||
|
||
**反模式**(不推荐):
|
||
- ❌ 业务服务间通过 Maven 依赖互相引用(导致强耦合)
|
||
- ❌ 直接通过数据库访问其他服务的表(破坏服务边界)
|
||
- ❌ 使用 RestTemplate 而非 OpenFeign(缺少服务发现和负载均衡)
|
||
|
||
#### 2.3.6 参数对象(DTO)管理策略
|
||
|
||
OpenFeign 调用中涉及的参数对象(请求 DTO / 响应 DTO),采用 **“通用参数独立模块 + 领域参数分模块管理”** 的组合策略:
|
||
|
||
##### 2.3.6.1 通用参数对象(独立模块管理)
|
||
|
||
**定义**:跨多个业务域都会用到的通用结构,由基础公共模块统一提供。
|
||
|
||
| 对象类别 | 示例 | 所在模块 |
|
||
|----------|------|----------|
|
||
| 统一返回结果 | `Result<T>`、`PageResult<T>` | `fund-common` |
|
||
| 分页/排序参数 | `PageRequest`、`SortRequest`(预留) | `fund-common` |
|
||
| 公共基础实体 | `BaseEntity`(含 `tenant_id`、审计字段) | `fund-common` |
|
||
|
||
**约定**:
|
||
- 任何服务如果需要统一返回格式或分页能力,优先复用 `fund-common` 中的通用对象;
|
||
- 通用对象由架构组维护,变更需评估对所有服务的影响。
|
||
|
||
##### 2.3.6.2 领域参数对象(分模块管理)
|
||
|
||
**定义**:只属于某个业务域的请求 / 响应对象,例如:
|
||
- 客户查询条件:`CustomerQueryDTO`(fund-cust)
|
||
- 项目创建请求:`ProjectCreateRequest`(fund-proj)
|
||
- 收款记录筛选条件:`ReceiptFilterDTO`(fund-receipt)
|
||
|
||
**管理策略**:
|
||
- **分模块管理**:
|
||
- 每个提供 API 的服务,在**自身模块内部**定义领域参数对象;
|
||
- 参数对象的 Java 类不在多个业务模块之间通过 Maven 互相依赖,避免形成隐性耦合;
|
||
- 服务之间通过 **JSON 结构契约** 进行解耦,具体字段以 API 文档为准。
|
||
- **消费方处理方式**:
|
||
- 服务消费方可以:
|
||
- 直接使用与提供方字段结构一致的本地 VO/DTO,并通过 JSON 序列化/反序列化完成转换;或
|
||
- 在消费方内部定义更贴近本地业务的 DTO,并在 Service 层做一次字段映射。
|
||
|
||
**示例**(领域请求 DTO 定义在提供方模块):
|
||
```java
|
||
// fund-cust 模块内部
|
||
@Data
|
||
public class CustomerQueryDTO {
|
||
private String keyword;
|
||
private Integer status;
|
||
private LocalDate startDate;
|
||
private LocalDate endDate;
|
||
}
|
||
|
||
@RestController
|
||
@RequestMapping("/api/v1/customer")
|
||
public class CustomerController {
|
||
|
||
@PostMapping("/search")
|
||
public Result<List<CustomerVO>> search(@RequestBody CustomerQueryDTO query) {
|
||
// ... 省略具体实现
|
||
}
|
||
}
|
||
```
|
||
|
||
**消费方调用示例**:
|
||
```java
|
||
// fund-proj 模块内部定义本地请求对象
|
||
@Data
|
||
public class CustomerSearchRequest {
|
||
private String keyword;
|
||
}
|
||
|
||
@FeignClient(name = "fund-cust", path = "/api/v1/customer")
|
||
public interface CustomerFeignClient {
|
||
|
||
@PostMapping("/search")
|
||
Result<List<CustomerVO>> search(@RequestBody CustomerQueryDTO query);
|
||
}
|
||
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
public class ProjectService {
|
||
|
||
private final CustomerFeignClient customerFeignClient;
|
||
|
||
public List<CustomerVO> findCustomers(CustomerSearchRequest request) {
|
||
CustomerQueryDTO dto = new CustomerQueryDTO();
|
||
dto.setKeyword(request.getKeyword());
|
||
return customerFeignClient.search(dto).getData();
|
||
}
|
||
}
|
||
```
|
||
|
||
> **结论**:
|
||
> - 通用参数对象放在 `fund-common` 独立模块统一管理;
|
||
> - 具体业务领域的参数对象采用**分模块管理**,定义在各自服务内部;
|
||
> - 业务服务间通过 OpenFeign + JSON 契约解耦,不共享彼此的领域实体类。
|
||
|
||
---
|
||
|
||
## 三、技术架构
|
||
|
||
### 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 / 缓存 / 消息队列 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**层次职责与依赖方向**:
|
||
- 表现层:负责请求接收、参数校验、调用应用服务、统一返回封装;不直接包含业务规则。
|
||
- 应用服务层(App Service):以“用例”为中心,负责业务流程编排、事务边界控制、跨服务调用(OpenFeign)等;依赖领域服务和数据访问层。
|
||
- 领域服务层(Domain Service):承载核心领域规则和计算逻辑,不直接依赖基础设施(数据库、缓存等),尽量保持纯业务逻辑。
|
||
- 数据访问层:由 `XxxDataService` + Mapper + Entity 组成,仅负责数据查询、持久化和简单数据转换。
|
||
- 基础设施层:对外部资源(数据库、缓存、消息中间件、对象存储等)的适配封装。
|
||
|
||
**依赖规范**:
|
||
- 依赖方向自上而下:Controller → AppService → DomainService → DataService/Mapper → 基础设施层;
|
||
- 下层禁止依赖上层(例如 DataService 不得调用 Controller 或 AppService);
|
||
- 业务模块之间通过 OpenFeign 进行调用,不通过 Maven 直接依赖其他模块的 DataService/Entity。
|
||
|
||
---
|
||
|
||
## 四、数据架构
|
||
|
||
### 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.0 开发规则总览
|
||
|
||
| 规则类别 | 内容摘要 | 详细章节 |
|
||
|----------|----------|----------|
|
||
| 分层架构 | 表现层 / 应用服务层 / 领域服务层 / 数据访问层 / 基础设施层,单向依赖、各司其职 | 3.2 架构分层 |
|
||
| 模块通信 | 基础服务走 Maven 依赖,业务服务之间通过 OpenFeign + DTO/JSON 契约解耦 | 2.3 模块通信方式 |
|
||
| API 设计 | 契约优先设计,统一使用 DTO/VO,对外响应统一 Result 结构 | 8.2 API 设计规范 |
|
||
| 数据访问 | MyBatis-Plus 的 ServiceImpl 只做数据访问,命名为 XxxDataService,不做业务 | 8.3 MyBatis-Plus 使用规范 |
|
||
| Controller | 只处理数据接收/校验、调用业务 Service、封装返回结果,不写业务逻辑 | 8.4 Controller 与请求参数校验规范 |
|
||
| 参数校验 | 使用 @Valid/@Validated + @NotBlank/@NotNull 等进行 Bean Validation,全局异常统一返回 | 8.4 Controller 与请求参数校验规范 |
|
||
| 事务边界 | 事务由应用服务层控制,DataService/Mapper 不主动开事务,跨服务一致性优先事件驱动 | 8.5 事务与测试规范 |
|
||
| 测试策略 | DomainService 做单元测试,AppService 做少量集成测试,Controller 做契约测试 | 8.5 事务与测试规范 |
|
||
|
||
> **说明**:本章节仅作为开发规则的索引入口,具体细则以对应章节为准。
|
||
|
||
### 8.1 代码结构
|
||
|
||
```
|
||
fund-sys/
|
||
├── src/
|
||
│ ├── main/
|
||
│ │ ├── java/
|
||
│ │ │ └── com/fundplatform/sys/
|
||
│ │ │ ├── SysApplication.java
|
||
│ │ │ ├── config/ # 配置类
|
||
│ │ │ ├── controller/ # 控制器层(表现层)
|
||
│ │ │ ├── service/ # 业务服务层(AppService/DomainService)
|
||
│ │ │ ├── data/ # MyBatis-Plus 数据访问层
|
||
│ │ │ │ ├── entity/ # 实体类(Entity)
|
||
│ │ │ │ ├── mapper/ # Mapper 接口
|
||
│ │ │ │ └── service/ # XxxDataService(继承 ServiceImpl)
|
||
│ │ │ ├── dto/ # 数据传输对象(DTO)
|
||
│ │ │ ├── vo/ # 视图对象(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中体现 |
|
||
|
||
**设计原则**:
|
||
- 接口设计采用 **契约优先(Contract-First)**,优先在文档/注释中定义好路径、入参 DTO、出参 VO,再落地实现;
|
||
- 对外暴露的数据结构统一使用 DTO/VO,不直接复用持久化实体 Entity,避免前后端强耦合;
|
||
- DTO 负责接口语义表达,Entity 负责数据库持久化,两者通过组装/转换解耦;
|
||
- 所有对外接口应结合 8.4 小节的参数校验规范,保证输入合法性。
|
||
|
||
### 8.3 MyBatis-Plus 使用规范
|
||
|
||
1. **职责边界**
|
||
- MyBatis-Plus 的 `ServiceImpl` / `IService` 实现类 **只负责数据访问、数据封装和转换**,不承载业务流程、业务校验、权限控制等逻辑;
|
||
- 所有业务逻辑必须放在**业务服务层**(如 `XxxService` / `XxxDomainService`),通过组合调用 `XxxDataService`/Mapper 完成。
|
||
|
||
2. **命名规范:区分数据服务与业务服务**
|
||
- 基于 MyBatis-Plus 的数据访问服务类,统一命名为:`XxxDataService` 或 `XxxDataServiceImpl`,例如:`UserDataService`;
|
||
- 业务服务类命名为:`XxxService` / `XxxDomainService` / `XxxAppService`,例如:`UserService`;
|
||
- 禁止出现既继承 `ServiceImpl` 又以 `XxxService` 命名、同时承载业务逻辑的“万能 Service”。
|
||
|
||
3. **日志规范:MyBatis Service 不使用 @Slf4j**
|
||
- 任何继承 `ServiceImpl` 或实现 `IService` 的数据访问服务类,**禁止使用 Lombok 的 `@Slf4j` 注解**;
|
||
- 如需记录业务日志,应在上层业务服务类中使用日志记录,或在数据服务中手动声明 `private static final Logger logger = LoggerFactory.getLogger(XxxDataService.class);` 且避免字段名 `log`,防止与 MyBatis 内部 `Log log` 冲突;
|
||
- 避免出现 `log.info(...)` 这类调用 MyBatis `Log` 接口的错误用法。
|
||
|
||
4. **包结构规范:数据访问层独立归档**
|
||
- 将 MyBatis-Plus 相关的 `Entity`、`Mapper`、`ServiceImpl` 等统一放在**独立的 data 包**中,例如:
|
||
- `com.fundplatform.sys.data.entity`
|
||
- `com.fundplatform.sys.data.mapper`
|
||
- `com.fundplatform.sys.data.service`
|
||
- 业务服务层使用 `com.fundplatform.sys.service` / `com.fundplatform.sys.domain` 等包名,与数据访问层在物理结构上清晰分离;
|
||
- Controller 只依赖业务服务层,不直接依赖 `ServiceImpl` 或 Mapper,避免 MyBatis 数据服务与业务 Service 在包名和职责上产生混淆。
|
||
|
||
### 8.4 Controller 与请求参数校验规范
|
||
|
||
1. **Controller 职责**
|
||
- 仅负责:
|
||
- 接收请求数据(包含 Header、Path、Query、Body);
|
||
- 调用**业务服务层**(`XxxService` / `XxxDomainService`);
|
||
- 对业务层返回结果进行封装和格式转换(如封装为统一 `Result<T>`);
|
||
- 明确禁止在 Controller 中编写业务规则、事务控制、跨聚合业务编排等复杂逻辑;
|
||
- Controller 不直接依赖 MyBatis 的 `ServiceImpl` / Mapper,只依赖业务服务接口。
|
||
|
||
2. **请求参数校验(Bean Validation)**
|
||
- 强制启用 Spring Validation,对外暴露的接口必须进行参数校验:
|
||
- 使用 `@Valid` 或 `@Validated` 标注方法参数或类;
|
||
- 对必填字段使用 `@NotBlank`(字符串)、`@NotNull`(对象)、`@NotEmpty`(集合/数组)等注解;
|
||
- 对数值范围使用 `@Min`、`@Max`、`@Positive`、`@PositiveOrZero` 等注解;
|
||
- 对字符串长度使用 `@Size`;
|
||
- 推荐在 DTO 层(如 `LoginRequestDTO`、`CustomerCreateDTO`)上声明校验规则,而不是在 Entity 上;
|
||
- 统一使用全局异常处理(如 `@RestControllerAdvice`)拦截 `MethodArgumentNotValidException` / `ConstraintViolationException`,返回统一错误结构。
|
||
|
||
3. **示例:登录接口参数校验**
|
||
|
||
```java
|
||
@Data
|
||
public class LoginRequestDTO {
|
||
|
||
@NotBlank(message = "用户名不能为空")
|
||
private String username;
|
||
|
||
@NotBlank(message = "密码不能为空")
|
||
private String password;
|
||
}
|
||
|
||
@RestController
|
||
@RequestMapping("/api/v1/auth")
|
||
@RequiredArgsConstructor
|
||
public class AuthController {
|
||
|
||
private final AuthService authService;
|
||
|
||
@PostMapping("/login")
|
||
public Result<LoginVO> login(@Valid @RequestBody LoginRequestDTO request) {
|
||
LoginVO vo = authService.login(request);
|
||
return Result.success(vo);
|
||
}
|
||
}
|
||
```
|
||
|
||
4. **示例:全局参数校验异常处理**
|
||
|
||
```java
|
||
@RestControllerAdvice
|
||
public class GlobalExceptionHandler {
|
||
|
||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||
public Result<Void> handleValidationException(MethodArgumentNotValidException ex) {
|
||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||
.findFirst()
|
||
.map(FieldError::getDefaultMessage)
|
||
.orElse("请求参数不合法");
|
||
return Result.error(400, message);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 8.5 事务与测试规范
|
||
|
||
1. **事务边界**
|
||
- 事务主要由应用服务层(App Service)控制,在应用服务方法上使用 `@Transactional`,统一管理一个用例内的多次数据访问;
|
||
- Controller 不直接开启事务,数据访问层(DataService/Mapper)不主动声明事务,只参与由上层开启的事务;
|
||
- 跨服务的一致性场景(如应收款 + 收款 + 统计)优先通过事件驱动或异步补偿等方式设计,避免在多个服务间使用分布式强一致事务作为默认方案。
|
||
|
||
2. **可测试性**
|
||
- 领域服务(Domain Service)尽量保持无状态、无基础设施依赖,方便通过单元测试验证核心业务规则(金额计算、状态流转、风控规则等);
|
||
- 应用服务层可通过少量集成测试(SpringBootTest)验证典型用例的调用链和事务行为;
|
||
- Controller 层以契约测试为主,重点验证请求/响应结构是否符合 API 设计规范以及参数校验是否生效。
|
||
|
||
---
|
||
|
||
## 九、附录
|
||
|
||
### 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 |
|
||
| v1.4 | 2026-02-13 | 补充统一全局上下文 GlobalContext,统筹 tid/uid/uname 获取和异步传递 | zhangjf |
|
||
| v1.5 | 2026-02-13 | 补充模块通信与 OpenFeign 参数对象管理策略、分层架构职责说明、MyBatis-Plus 使用规范、Controller 与参数校验规范、事务与测试规范及开发规则总览 | zhangjf |
|
||
|
||
---
|
||
|
||
**文档结束**
|