fundplatform/doc/资金服务平台 FundPlatform 架构设计文档.md
zhangjf ad4176ae8a docs: 更新移动端技术栈为 Vue3 + Vite5 + Vant4
- 架构设计文档:更新前端架构说明和接入层架构图
- 需求文档:更新移动端技术栈描述
- 功能清单:更新移动端模块技术栈
- 移除 UniApp 跨端架构,改为独立的 H5 移动端应用
2026-02-20 08:19:46 +08:00

3792 lines
161 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 资金服务平台 (FundPlatform) - 架构设计文档
> **文档版本**: v1.0
> **创建日期**: 2026-02-13
> **项目名称**: 资金服务平台
> **项目代号**: fundplatform
---
## 一、架构设计概述
### 1.1 设计目标
| 目标 | 描述 |
|------|------|
| **高可用性** | 系统可用性≥99.5%,支持故障自动恢复 |
| **高性能** | 支持≥100人并发接口响应<3秒 |
| **可扩展性** | 微服务架构支持水平扩展 |
| **安全性** | 数据加密传输完善的权限控制 |
| **可维护性** | 模块化设计代码结构清晰 |
| **多租户** | 支持一库多租户和一库一租户两种模式 |
| **可观测性** | 全链路日志跟踪支持 Head 日志追踪 |
### 1.2 架构风格
采用 **微服务架构** + **前后端分离** + **多租户架构** 模式
- 后端Spring Cloud Alibaba 微服务框架
- 前端Vue 3 + Element Plus 管理后台
- 移动端Vue 3 + Vite 5 + Vant 4 移动端应用
- 数据层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**
为了统一管理和传递全局参数tiduidunametraceId 设计一个综合的 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 + Element Plus) │ │ (Vue3 + Vite5 + Vant) │ │
│ └──────────────┬──────────────────┘ └──────────────┬──────────────────┘ │
└─────────────────┼─────────────────────────────────────┼─────────────────────┘
│ │
└─────────────────────┬───────────────┘
┌────────────────────────────────────┼────────────────────────────────────────┐
│ 网关层 (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 | 构建工具 |
| **移动端** | Vue | 3.4.x | 前端框架 |
| | Vite | 5.x | 构建工具 |
| | TypeScript | 5.x | 类型安全 |
| | Vant | 4.x | 移动端UI库 |
| | Pinia | 2.x | 状态管理 |
| | Vue Router | 4.x | 路由管理 |
| | Axios | 1.x | HTTP客户端 |
### 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 |
---
**文档结束**