# 资金服务平台 (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 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 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 sharedServices = Arrays.asList("fund-gateway", "fund-report"); /** 租户服务配置 */ private Map tenantConfigs = new HashMap<>(); } /** * 租户服务配置 */ @Data public class TenantServiceConfig { /** 租户ID */ private String tenantId; /** 服务实例列表 */ private Map 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 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 supplierProvider; public TenantAwareLoadBalancer(String serviceId, ObjectProvider supplierProvider) { this.serviceId = serviceId; this.supplierProvider = supplierProvider; } @Override public Mono> choose(Request request) { // 从请求上下文获取租户ID String tenantId = getTenantIdFromRequest(request); String tenantGroup = buildTenantGroup(tenantId); ServiceInstanceListSupplier supplier = supplierProvider.getIfAvailable(); return supplier.get().next() .map(instances -> filterByTenantGroup(instances, tenantGroup)) .map(this::getInstanceResponse); } /** * 根据租户组过滤服务实例 */ private List filterByTenantGroup( List instances, String tenantGroup) { // 优先选择租户专属实例 List 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 getInstanceResponse(List 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 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 CURRENT_USER_ID = new ThreadLocal<>(); private static final ThreadLocal CURRENT_USERNAME = new ThreadLocal<>(); private static final ThreadLocal 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 Callable wrap(Callable callable) { UserContextHolder holder = UserContextHolder.fromCurrent(); return () -> { try { setFromHolder(holder); return callable.call(); } finally { clear(); } }; } /** * 包装 Supplier,传递用户上下文到异步线程 */ public static Supplier wrap(Supplier supplier) { UserContextHolder holder = UserContextHolder.fromCurrent(); return () -> { try { setFromHolder(holder); return supplier.get(); } finally { clear(); } }; } /** * 包装 Function,传递用户上下文到异步线程 */ public static Function wrap(Function 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 CompletableFuture wrapFuture(Supplier> 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 asyncQueryProject(Long projectId) { return CompletableFuture.supplyAsync( UserContext.wrap(() -> { // 异步查询时保持用户上下文 log.info("[Async] 用户 {} 查询项目 {}", UserContext.getCurrentUsername(), projectId); return projectMapper.selectById(projectId); }), executor ); } /** * 批量异步处理 */ public void batchAsyncProcess(List projectIds) { List> 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 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 Mono withUserContext(Mono mono, UserContextHolder holder) { return mono.contextWrite(Context.of(USER_CONTEXT_KEY, holder)); } /** * 从 Reactor Context 读取用户上下文 */ public static Mono 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 getUserId() { return getUserContext().map(UserContextHolder::getUserId); } /** * 包装响应式操作 */ public static Mono wrap(Mono mono) { UserContextHolder holder = UserContextHolder.fromCurrent(); return withUserContext(mono, holder); } } /** * WebFlux 过滤器 */ @Component public class ReactiveContextFilter implements WebFilter { @Override public Mono 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> 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 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 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 Callable wrap(Callable callable) { ContextSnapshot snapshot = snapshot(); return () -> { try { snapshot.apply(); return callable.call(); } finally { clear(); } }; } public static Supplier wrap(Supplier supplier) { ContextSnapshot snapshot = snapshot(); return () -> { try { snapshot.apply(); return supplier.get(); } finally { clear(); } }; } public static CompletableFuture supplyAsync(Supplier supplier, Executor executor) { return CompletableFuture.supplyAsync(wrap(supplier), executor); } public static CompletableFuture 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 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 %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{tenantId}] [%X{uid}] [%X{uname}] %-5level %logger{36} - %msg%n ``` 输出示例: ``` 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 CURRENT_TENANT = new ThreadLocal<>(); private static final ThreadLocal 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 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 %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{spanId}] %-5level %logger{36} - %msg%n logs/application.log logs/application.%d{yyyy-MM-dd}.%i.log 30 100MB true traceId spanId tenantId ``` ### 3.4 日志格式规范 #### 3.4.1 标准日志格式 ``` [时间] [线程] [TraceId] [SpanId] [租户ID] [级别] [类名] - [消息] 示例: 2026-02-13 14:30:25.123 [http-nio-8100-exec-1] [abc123def456] [span789] [tenant_1] INFO c.f.s.c.UserController - 用户登录成功: admin ``` #### 3.4.2 JSON 日志格式 ```json { "@timestamp": "2026-02-13T14:30:25.123+08:00", "level": "INFO", "logger_name": "com.fundplatform.sys.controller.UserController", "message": "用户登录成功: admin", "thread_name": "http-nio-8100-exec-1", "traceId": "abc123def456", "spanId": "span789", "tenantId": "tenant_1", "service": "fund-sys", "host": "192.168.1.100" } ``` ### 3.5 日志收集与分析 #### 3.5.1 ELK Stack 架构 ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 应用服务 │────>│ Filebeat │────>│ Logstash │────>│ Elasticsearch│ │ (日志文件) │ │ (日志收集) │ │ (日志处理) │ │ (日志存储) │ └─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘ │ ▼ ┌─────────────┐ │ Kibana │ │ (日志查询) │ └─────────────┘ ``` #### 3.5.2 Kibana 查询示例 ``` # 根据 TraceId 查询全链路日志 traceId: "abc123def456" # 根据租户ID查询日志 tenantId: "tenant_1" # 查询特定服务的错误日志 service: "fund-sys" AND level: "ERROR" # 查询特定用户的操作日志 message: "admin" AND tenantId: "tenant_1" ``` ### 3.6 调用链追踪增强 #### 3.6.1 与 SkyWalking 集成 ```java /** * SkyWalking 插件配置 */ @Component public class SkyWalkingConfig { /** * 自定义 SkyWalking 标签 */ @TraceContext public void addCustomTags() { // 添加租户ID标签 String tenantId = TenantContextHolder.getTenantId(); if (tenantId != null) { Tags.ofKey("tenant.id").set(tenantId); } // 添加用户ID标签 String userId = SecurityUtils.getCurrentUserId(); if (userId != null) { Tags.ofKey("user.id").set(userId); } } } ``` #### 3.6.2 性能指标采集 ```java /** * 接口性能监控 */ @Aspect @Component public class PerformanceAspect { private static final Logger perfLog = LoggerFactory.getLogger("PERFORMANCE"); @Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)") public Object around(ProceedingJoinPoint point) throws Throwable { long start = System.currentTimeMillis(); String method = point.getSignature().toShortString(); String traceId = TraceIdUtil.getCurrentTraceId(); try { return point.proceed(); } finally { long cost = System.currentTimeMillis() - start; perfLog.info("[Performance] Method: {}, Cost: {}ms, TraceId: {}", method, cost, traceId); } } } ``` --- ## 四、系统整体架构 ### 2.1 架构全景图 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 接入层 (Access Layer) │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Web端 │ │ 移动端 │ │ 小程序 │ │ H5页面 │ │ │ │ (Vue3) │ │ (UniApp) │ │ (UniApp) │ │ (UniApp) │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ └─────────┼────────────────┼────────────────┼────────────────┼───────────────┘ │ │ │ │ └────────────────┴────────────────┴────────────────┘ │ ┌────────────────────────────────────┼────────────────────────────────────────┐ │ 网关层 (Gateway Layer) │ │ ┌─────────────────────────────────┴─────────────────────────────────────┐ │ │ │ Nginx 负载均衡 │ │ │ │ SSL终止 / 静态资源 / 反向代理 │ │ │ └─────────────────────────────────┬─────────────────────────────────────┘ │ │ │ │ │ ┌─────────────────────────────────┴─────────────────────────────────────┐ │ │ │ Spring Cloud Gateway │ │ │ │ 路由转发 / 限流熔断 / 鉴权认证 / 日志记录 │ │ │ └─────────────────────────────────┬─────────────────────────────────────┘ │ └────────────────────────────────────┼────────────────────────────────────────┘ │ ┌────────────────────────────────────┼────────────────────────────────────────┐ │ 服务层 (Service Layer) │ │ │ │ │ ┌─────────────────────────────────┴─────────────────────────────────────┐ │ │ │ Nacos 服务注册与配置中心 │ │ │ └───────────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 系统服务 │ │ 客户中心 │ │ 项目中心 │ │ 需求中心 │ │ │ │ fund-sys │ │ fund-cust │ │ fund-proj │ │ fund-req │ │ │ │ │ │ │ │ │ │ │ │ │ │ • 用户管理 │ │ • 客户管理 │ │ • 项目管理 │ │ • 需求工单 │ │ │ │ • 权限管理 │ │ • 联系人管理 │ │ • 成员管理 │ │ • 应收款管理 │ │ │ │ • 部门管理 │ │ │ │ │ │ │ │ │ │ • 日志管理 │ │ │ │ │ │ │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ │ │ ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ │ │ │ 支出中心 │ │ 收款中心 │ │ 报表中心 │ │ 文件中心 │ │ │ │ fund-exp │ │ fund-receipt │ │ fund-report │ │ fund-file │ │ │ │ │ │ │ │ │ │ │ │ │ │ • 支出类型 │ │ • 收款记录 │ │ • 收支统计 │ │ • 文件上传 │ │ │ │ • 支出申请 │ │ • 收款凭证 │ │ • 趋势分析 │ │ • 文件存储 │ │ │ │ • 付款管理 │ │ • 账期管理 │ │ • 数据导出 │ │ • 文件下载 │ │ │ │ • 统计分析 │ │ │ │ │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────────────┘ │ ┌────────────────────────────────────┼────────────────────────────────────────┐ │ 数据层 (Data Layer) │ │ │ │ │ ┌─────────────────────────────────┴─────────────────────────────────────┐ │ │ │ Redis 缓存集群 │ │ │ │ 会话缓存 / 热点数据 / 分布式锁 / 限流计数器 │ │ │ └───────────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ MySQL 8.0 主从集群 │ │ │ │ 用户库 / 业务库 / 日志库 / 报表库 │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ 腾讯COS 对象存储 │ │ │ │ 文件存储 / 图片存储 / 凭证附件 │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────────────┘ ``` ### 2.2 服务划分 | 服务名 | 服务编码 | 职责 | 端口范围 | |--------|----------|------|----------| | 网关服务 | fund-gateway | 路由、鉴权、限流 | 8080 | | 系统服务 | fund-sys | 用户、权限、部门、日志 | 8100-8109 | | 客户中心 | fund-cust | 客户、联系人管理 | 8110-8119 | | 项目中心 | fund-proj | 项目、成员管理 | 8120-8129 | | 需求中心 | fund-req | 需求工单、应收款 | 8130-8139 | | 支出中心 | fund-exp | 支出类型、支出管理 | 8140-8149 | | 收款中心 | fund-receipt | 收款记录、账期管理 | 8150-8159 | | 报表中心 | fund-report | 统计分析、数据导出 | 8160-8169 | | 文件中心 | fund-file | 文件上传、存储管理 | 8170-8179 | ### 2.3 模块通信方式 系统采用**分层通信架构**,根据模块职责划分为基础服务和业务服务,采用不同的通信方式: #### 2.3.1 基础服务 - Maven 依赖方式 **定义**:提供基础能力、不对外暴露 HTTP 接口的模块 | 基础服务 | 说明 | 提供能力 | 依赖方式 | |---------|------|---------|---------| | **fund-common** | 公共模块 | 基础实体、工具类、统一响应格式 | Maven 依赖 | | **fund-gateway** | API 网关 | 路由转发、统一鉴权、限流熔断 | 独立部署 | **特点**: - ✅ **编译时依赖**:通过 Maven `` 直接引入 - ✅ **本地调用**:方法级别直接调用,无网络开销 - ✅ **强类型约束**:编译期类型检查,更安全 - ✅ **版本统一**:通过父 POM 统一管理版本 **示例**: ```xml com.fundplatform fund-common ${project.version} ``` #### 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 getCustomerById(@PathVariable Long customerId); /** * 批量查询客户信息 */ @PostMapping("/batch") Result> getCustomersByIds(@RequestBody List 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 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(缺少服务发现和负载均衡) --- ## 三、技术架构 ### 3.1 技术栈选型 #### 3.1.1 后端技术栈 | 层级 | 技术选型 | 版本 | 用途 | |------|----------|------|------| | **基础框架** | Spring Boot | 3.2.x | 应用基础框架 | | **微服务框架** | Spring Cloud Alibaba | 2022.x | 微服务治理 | | **服务注册** | Nacos | 3.0.0 | 服务注册与配置中心 | | **服务网关** | Spring Cloud Gateway | 4.x | API网关 | | **负载均衡** | Spring Cloud LoadBalancer | 4.x | 客户端负载均衡 | | **服务调用** | OpenFeign | 4.x | 声明式HTTP客户端 | | **服务容错** | Sentinel | 1.8.x | 限流、熔断、降级 | | **ORM框架** | MyBatis-Plus | 3.5.x | 数据访问层 | | **数据库连接池** | HikariCP | 5.x | 高性能连接池 | | **缓存框架** | Spring Data Redis | 3.x | Redis操作 | | **安全框架** | Apache Shiro | 2.x | 认证授权、会话管理 | | **JWT令牌** | jjwt | 0.12.x | Token生成与验证 | | **API文档** | Knife4j | 4.x | Swagger增强 | | **对象存储** | 腾讯云COS SDK | 5.x | 文件存储 | | **定时任务** | XXL-JOB | 2.4.x | 分布式任务调度 | | **日志框架** | Logback + SLF4J | - | 日志记录 | | **工具类库** | Hutool | 5.8.x | 常用工具 | #### 3.1.2 前端技术栈 | 端 | 技术选型 | 版本 | 用途 | |----|----------|------|------| | **管理后台** | Vue | 3.4.x | 前端框架 | | | TypeScript | 5.x | 类型安全 | | | Element Plus | 2.5.x | UI组件库 | | | Pinia | 2.x | 状态管理 | | | Vue Router | 4.x | 路由管理 | | | Axios | 1.x | HTTP客户端 | | | ECharts | 5.x | 图表库 | | | Vite | 5.x | 构建工具 | | **移动端** | UniApp | 3.x | 跨端框架 | | | Vue | 3.x | 前端框架 | | | uView UI | 2.x | 移动端UI库 | ### 3.2 架构分层 ``` ┌─────────────────────────────────────────────────────────────┐ │ 表现层 (Presentation) │ │ Controller / DTO / VO / 参数校验 / 异常处理 │ └─────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────┐ │ 业务层 (Business) │ │ Service / 业务逻辑 / 事务管理 / 权限校验 │ └─────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────┐ │ 数据访问层 (Data Access) │ │ Mapper / DAO / MyBatis-Plus / 数据库操作 │ └─────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────┐ │ 基础设施层 (Infrastructure) │ │ Redis / MySQL / COS / MQ / 缓存 / 消息队列 │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 四、数据架构 ### 4.1 数据库设计 #### 4.1.1 数据库划分 | 数据库 | 用途 | 主要表 | |--------|------|--------| | fund_sys | 系统数据 | sys_user, sys_dept, sys_role, sys_menu, sys_log | | fund_biz | 业务数据 | customer, project, requirement, expense, receivable, receipt | | fund_file | 文件数据 | file_record, file_chunk | #### 4.1.2 分库分表策略 | 表名 | 策略 | 分片键 | 说明 | |------|------|--------|------| | sys_operation_log | 按时间分表 | created_time | 每月一张表 | | expense | 按时间分表 | expense_date | 每季度一张表 | | receipt | 按时间分表 | receipt_date | 每季度一张表 | ### 4.2 缓存设计 #### 4.2.1 缓存策略 | 缓存类型 | Key前缀 | 过期时间 | 说明 | |----------|---------|----------|------| | 用户会话 | `session:` | 30分钟 | JWT Token 黑名单 | | 用户信息 | `user:info:` | 1小时 | 用户基本信息缓存 | | 字典数据 | `dict:` | 24小时 | 系统字典数据 | | 热点数据 | `hot:` | 10分钟 | 频繁访问数据 | | 限流计数 | `limit:` | 1分钟 | API限流计数 | | 分布式锁 | `lock:` | 30秒 | 并发控制 | #### 4.2.2 缓存更新策略 ``` ┌─────────────┐ 查询 ┌─────────────┐ │ 业务请求 │ ───────────> │ 查询缓存 │ └─────────────┘ └──────┬──────┘ │ ┌───────────────┼───────────────┐ │ 命中 │ 未命中 │ ▼ ▼ │ ┌─────────────┐ ┌─────────────┐ │ │ 返回缓存 │ │ 查询数据库 │ │ │ 数据 │ │ │ │ └─────────────┘ └──────┬──────┘ │ │ │ ▼ │ ┌─────────────┐ │ │ 写入缓存 │<─────┘ │ 设置过期 │ └─────────────┘ ``` --- ## 五、安全架构 ### 5.1 认证授权(Apache Shiro) #### 5.1.1 Shiro 架构设计 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Shiro 认证授权架构 │ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ Subject(主体) │ │ │ │ 封装了当前用户的登录状态、权限信息 │ │ │ └────────────────────────────────┬────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ SecurityManager(安全管理器) │ │ │ │ 核心组件,管理所有 Subject │ │ │ └──────────────┬─────────────────┼─────────────────┬───────────────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ │ Authenticator │ │ Authorizer │ │ SessionManager │ │ │ │ (认证器) │ │ (授权器) │ │ (会话管理器) │ │ │ │ │ │ │ │ │ │ │ │ • 登录认证 │ │ • 角色校验 │ │ • 会话创建 │ │ │ │ • 多Realm认证 │ │ • 权限校验 │ │ • 会话存储 │ │ │ │ • rememberMe │ │ • 注解权限 │ │ • 会话超时 │ │ │ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ │ Realm │ │ SessionDAO │ │ CacheManager │ │ │ │ (领域) │ │ (会话持久化) │ │ (缓存管理) │ │ │ │ │ │ │ │ │ │ │ │ • 用户数据查询 │ │ • Redis存储 │ │ • 认证缓存 │ │ │ │ • 密码比对 │ │ • 分布式会话 │ │ • 授权缓存 │ │ │ │ • 权限数据加载 │ │ │ │ • 会话缓存 │ │ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` #### 5.1.2 Shiro + JWT 认证流程 ``` ┌─────────┐ ┌─────────┐ │ 客户端 │ │ 服务端 │ └────┬────┘ └────┬────┘ │ │ │ 1. 登录请求 (username/password) │ │ ──────────────────────────────────────> │ │ │ │ ┌─────────┴─────────┐ │ │ Shiro Realm 认证 │ │ │ • 查询用户数据 │ │ │ • 密码比对(Bcrypt)│ │ │ • 加载用户权限 │ │ └─────────┬─────────┘ │ │ │ 2. 生成 JWT Token │ │ {uid, uname, tenantId, roles, perm} │ │ <────────────────────────────────────── │ │ │ │ 3. 业务请求 (Header: Authorization) │ │ Header: X-Uid: 1001 │ │ Header: X-Uname: admin │ │ ──────────────────────────────────────> │ │ │ │ ┌─────────┴─────────┐ │ │ JWT Filter 验证 │ │ │ • Token合法性 │ │ │ • Token过期检查 │ │ │ • 刷新Token(可选) │ │ └─────────┬─────────┘ │ │ │ ┌─────────┴─────────┐ │ │ Shiro 授权检查 │ │ │ • @RequiresRoles │ │ │ • @RequiresPerms │ │ └─────────┬─────────┘ │ │ │ 4. 返回业务数据 │ │ <────────────────────────────────────── │ ``` #### 5.1.3 Shiro 核心配置 ``` ┌─────────┐ ┌─────────┐ │ 客户端 │ │ 服务端 │ └────┬────┘ └────┬────┘ │ │ │ 1. 登录请求 (username/password) │ │ ──────────────────────────────────────> │ │ │ │ ┌─────────┴─────────┐ │ │ 验证用户名密码 │ │ │ 生成JWT Token │ │ └─────────┬─────────┘ │ │ │ 2. 返回 Token │ │ <────────────────────────────────────── │ │ │ │ 3. 业务请求 (Header: Authorization) │ │ ──────────────────────────────────────> │ │ ┌─────────┴─────────┐ │ │ 验证Token有效性 │ │ │ 解析用户信息 │ │ │ 权限校验 │ │ └─────────┬─────────┘ │ │ │ 4. 返回业务数据 │ │ <────────────────────────────────────── │ ``` #### 5.1.3 Shiro 核心实现 **1. Shiro 配置类** ```java /** * Shiro 配置类 */ @Configuration public class ShiroConfig { /** * SecurityManager 配置 */ @Bean public DefaultWebSecurityManager securityManager( JwtRealm jwtRealm, SessionManager sessionManager, CacheManager cacheManager) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); // 配置 Realm securityManager.setRealm(jwtRealm); // 配置会话管理器 securityManager.setSessionManager(sessionManager); // 配置缓存管理器 securityManager.setCacheManager(cacheManager); // 配置记住我 securityManager.setRememberMeManager(rememberMeManager()); return securityManager; } /** * Shiro 过滤器链配置 */ @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean( DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean(); filterFactoryBean.setSecurityManager(securityManager); // 自定义过滤器 Map filters = new HashMap<>(); filters.put("jwt", new JwtAuthenticationFilter()); filterFactoryBean.setFilters(filters); // 过滤器链规则 Map 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 redisTemplate) { RedisSessionDAO redisSessionDAO = new RedisSessionDAO(); redisSessionDAO.setRedisTemplate(redisTemplate); redisSessionDAO.setKeyPrefix("shiro:session:"); redisSessionDAO.setExpire(30 * 60); return redisSessionDAO; } /** * 缓存管理器(Redis) */ @Bean public CacheManager cacheManager(RedisTemplate 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 roles = permissionService.getUserRoles(userId); authorizationInfo.setRoles(roles); // 查询用户权限 Set 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 getUserInfo() { Long userId = UserContext.getCurrentUserId(); return Result.success(userService.getUserInfo(userId)); } /** * 需要 admin 角色 */ @RequiresRoles("admin") @GetMapping("/list") public Result> listUsers(PageParam pageParam) { return Result.success(userService.listUsers(pageParam)); } /** * 需要 user:create 权限 */ @RequiresPermissions("user:create") @PostMapping public Result createUser(@RequestBody @Valid UserCreateDTO dto) { userService.createUser(dto); return Result.success(); } /** * 需要多个权限(逻辑与) */ @RequiresPermissions({"user:update", "user:assignRole"}) @PutMapping("/{userId}/role") public Result assignRole(@PathVariable Long userId, @RequestBody List roleIds) { userService.assignRoles(userId, roleIds); return Result.success(); } } ``` #### 5.1.4 权限模型 采用 **RBAC** (Role-Based Access Control) 模型: ``` 用户 (User) ──N:M──> 角色 (Role) ──N:M──> 权限 (Permission) │ └─N:M──> 菜单/按钮 (Menu) ``` ### 5.2 数据安全 | 安全措施 | 实现方式 | |----------|----------| | 传输加密 | HTTPS/TLS 1.3 | | 密码加密 | BCrypt (强度10) | | 敏感数据 | AES-256 加密存储 | | SQL注入 | MyBatis预编译语句 | | XSS攻击 | 前端转义 + 后端过滤 | | CSRF攻击 | Token验证 + SameSite Cookie | --- ## 六、部署架构 ### 6.1 生产环境部署 ``` ┌─────────────────────────────────────────────────────────────────┐ │ 负载均衡层 │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Nginx (主) + Nginx (备) │ │ │ │ Keepalived VIP: 192.168.1.10 │ │ │ └─────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ 应用服务层 │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Gateway-1 │ │ Gateway-2 │ │ Gateway-3 │ │ │ │ :8080 │ │ :8080 │ │ :8080 │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ ┌──────┴────────────────┴────────────────┴──────┐ │ │ │ Nacos 集群 (3节点) │ │ │ │ 8848 / 9848 / 7848 │ │ │ └───────────────────────────────────────────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │fund-sys │ │fund-cust │ │fund-proj │ │fund-req │ │ │ │ x 2 │ │ x 2 │ │ x 2 │ │ x 2 │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │fund-exp │ │fund-rec │ │fund-rpt │ │fund-file │ │ │ │ x 2 │ │ x 2 │ │ x 2 │ │ x 2 │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ 数据存储层 │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Redis 集群 (6节点,3主3从) │ │ │ │ 6379 / 6380 / 6381 │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ MySQL 主从集群 │ │ │ │ Master: 3306 ──> Slave: 3306 (x2) │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 6.2 容器化部署 #### 6.2.1 Docker Compose 配置 ```yaml version: '3.8' services: # Nacos nacos: image: nacos/nacos-server:v3.0.0 ports: - "8848:8848" - "9848:9848" environment: - MODE=standalone # Redis redis: image: redis:8.0.2 ports: - "6379:6379" command: redis-server --requirepass zjf@123456 # MySQL mysql: image: mysql:8.0 ports: - "3306:3306" environment: - MYSQL_ROOT_PASSWORD=zjf@123456 volumes: - ./sql:/docker-entrypoint-initdb.d # Gateway gateway: image: fundplatform/fund-gateway:1.0.0 ports: - "8080:8080" environment: - NACOS_SERVER=nacos:8848 # Services fund-sys: image: fundplatform/fund-sys:1.0.0 deploy: replicas: 2 environment: - NACOS_SERVER=nacos:8848 - DB_HOST=mysql - REDIS_HOST=redis ``` --- ## 七、监控与运维 ### 7.1 监控体系 | 监控类型 | 工具 | 监控内容 | |----------|------|----------| | **应用监控** | Prometheus + Grafana | JVM、接口响应、错误率 | | **链路追踪** | SkyWalking | 调用链、性能瓶颈 | | **日志收集** | ELK Stack | 业务日志、错误日志 | | **告警通知** | AlertManager | 钉钉/邮件/短信告警 | ### 7.2 运维脚本 ```bash # 服务启动脚本 #!/bin/bash SERVICES=("gateway" "sys" "cust" "proj" "req" "exp" "receipt" "report" "file") for service in "${SERVICES[@]}"; do docker-compose up -d fund-${service} sleep 5 done # 健康检查脚本 #!/bin/bash curl -f http://localhost:8080/actuator/health || exit 1 ``` --- ## 八、开发规范 ### 8.1 代码结构 ``` fund-sys/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/fundplatform/sys/ │ │ │ ├── SysApplication.java │ │ │ ├── config/ # 配置类 │ │ │ ├── controller/ # 控制器层 │ │ │ ├── service/ # 业务层 │ │ │ │ └── impl/ │ │ │ ├── mapper/ # 数据访问层 │ │ │ ├── entity/ # 实体类 │ │ │ ├── dto/ # 数据传输对象 │ │ │ ├── vo/ # 视图对象 │ │ │ ├── enums/ # 枚举类 │ │ │ ├── utils/ # 工具类 │ │ │ └── handler/ # 处理器 │ │ └── resources/ │ │ ├── application.yml │ │ ├── application-dev.yml │ │ ├── application-prod.yml │ │ └── mapper/ # XML映射文件 │ └── test/ ├── Dockerfile ├── pom.xml └── README.md ``` ### 8.2 API 设计规范 | 规范项 | 要求 | |--------|------| | **URL** | /api/{service}/{version}/{resource}/{action} | | **HTTP方法** | GET(查)、POST(增)、PUT(改)、DELETE(删) | | **响应格式** | { "code": 200, "message": "", "data": {} } | | **分页** | pageNum, pageSize, total, list | | **版本** | v1, v2 在URL中体现 | --- ## 九、附录 ### 9.1 端口分配表 | 服务 | 端口 | 说明 | |------|------|------| | Nginx | 80/443 | 负载均衡 | | Gateway | 8080 | API网关 | | Nacos | 8848/9848 | 注册配置中心 | | Redis | 6379 | 缓存服务 | | MySQL | 3306 | 数据库 | | XXL-JOB | 8081 | 任务调度中心 | ### 9.2 文档修订记录 | 版本 | 修订日期 | 修订内容 | 修订人 | |------|----------|----------|--------| | v1.0 | 2026-02-13 | 初始版本 | zhangjf | | v1.1 | 2026-02-13 | 补充多租户架构(一库多租户/一库一租户)和 Head 日志追踪设计 | zhangjf | | v1.2 | 2026-02-13 | 补充 Shiro 认证框架、服务调用链 uid/uname 传递设计 | zhangjf | | v1.3 | 2026-02-13 | 补充 HikariCP 连接池、支持异步场景的 UserContext 封装 | zhangjf | | v1.4 | 2026-02-13 | 补充统一全局上下文 GlobalContext,统筹 tid/uid/uname 获取和异步传递 | zhangjf | --- **文档结束**