本文档基于一个真实落地的 Spring Boot 多模块后端项目,提炼其可跨项目复用 的核心设计、代码组织方式,以及开发中真实踩过的坑与应保持的警惕 。
文中已去除一切企业专属命名与业务术语,统一使用中性概念(基础公共服务、主数据平台、对象存储、工作流引擎、搜索引擎等)。
站在开发者视角:每个模块都会说明「解决了什么问题 / 该怎么用 / 必须注意的坑 / 潜在问题 / 该提前考虑什么」。
1. 整体架构与分层
项目采用 Maven 多模块(Multi-Module) 组织,按"依赖方向单向、能力逐层沉淀"的原则分为三层:
业务系统(聚合层)
│ 依赖
▼
通用能力模块(业务无关的可复用能力:缓存 / 文件 / 定时任务 / 审计日志 / 消息 / 流程 / 主数据适配 / 规则 / 树 / 队列 / 算法)
│ 依赖
▼
基础公共服务模块(统一响应、统一异常、通用工具、公共配置)
│ 依赖
▼
基础设施封装(Redis / 对象存储 / MQ / 搜索引擎 / 分布式调度 SDK 的轻量封装)
设计要点与潜在问题:
- 基础模块不反向依赖任何业务模块,保证可整体平移到其他项目。
- 通用能力模块只有一个父依赖(基础模块),彼此之间按需依赖,可被任意业务系统一站式引入。
- 基础设施封装把第三方 SDK 的差异收敛在底层,上层只面对稳定接口。
- ⚠️ 潜在问题:模块一旦增多,容易出现"循环依赖"或"通用模块偷偷依赖了业务模块"。建议在 CI 中做依赖方向校验,基础模块 pom 不应出现任何业务 artifact。
关键技术栈:Spring Boot 2.6.x、Spring Cloud 2021.x、MyBatis-Plus 3.5.x、Redis(Redisson)、对象存储(S3 兼容)、分布式调度(ElasticJob-Lite)、消息队列(RocketMQ)、搜索引擎(Elasticsearch)、工作流引擎(Camunda / 第三方平台)。
2. 基础公共服务模块(common)
这是所有模块的公共父依赖,沉淀"放之四海皆准"的能力。
2.1 统一响应与异常
BaseResponse<T>:统一 API 返回体(code / message / data),所有接口返回它,前端无需为不同接口写多种解析。- 业务异常体系:自定义
BusinessException,配合全局异常处理器统一转换为BaseResponse,避免散落的 try-catch。 - ⚠️ 坑 :
BaseResponse一旦被当作"万能载体"塞入非响应字段(如审计用的operateRecord列表),就会污染响应结构。该项目中审计数据正是通过BaseResponse.operateRecord透传给切面,写入后必须setOperateRecord(null)再返回前端(见第 6 节),否则会把内部审计数据泄露到响应体。
2.2 公共配置(自动生效)
- 数据访问 :MyBatis-Plus 分页拦截器(
PaginationInnerInterceptor)统一注册,业务模块直接page(...)即可分页,无需重复配置。 - 服务调用透传 :Feign
RequestInterceptor实现,在微服务间自动透传链路追踪 ID(traceId)、鉴权令牌、平台标识、客户端标识等请求头,解决分布式调用上下文丢失问题。 - 序列化:FastJson / Jackson 统一配置(含时间类型序列化处理)。
- HTTP 客户端:Feign OkHttp 连接池配置,提升服务间调用性能。
⚠️ 坑 / 注意:
- 分页拦截器必须注意多数据源 场景------若项目有多个
SqlSessionFactory,分页插件只对在它所属工厂创建的SqlSession生效,漏配的库分页会失效但仍返回"看似正确"的第一页数据。 - Feign 透传拦截器里读取的 header(如
bucketName、storagePath)只在 HTTP 调用链 中存在;内部方法直接调用、定时任务、MQ 消费线程里RequestContextHolder为空,此时透传头取不到,要靠参数显式传递(见第 4、5 节)。 - ⚠️ 潜在问题 :
RequestInterceptor属于"隐式传参",排查问题时容易忽略"为什么这个线程没有 token"------建议在该拦截器里对缺失关键头时打印 WARN 日志,而非静默放过。
2.3 通用工具集(utils)
集中沉淀高频工具,避免各模块重复造轮子:
ExcelUtil/ExcelUtils:基于 EasyExcel 的导入导出。BeanUtil/BeanCopier:对象拷贝(Orika/MapStruct 封装)。DateUtil:日期处理;PageUtil:分页对象转换。ValidatorUtil:参数校验;WebUtil:IP / 设备信息提取。SpringUtil:Spring 上下文静态获取(getBean);JacksonUtil/LogStashUtil:JSON 与日志。PinyinUtil:中文转拼音。
⚠️ 坑 :SpringUtil.getBean() 是静态拿 Bean 的便捷通道,但不能在 Bean 的构造器或 @PostConstruct 早期 使用(容器未就绪会 NPE)。另外它会绕过依赖注入,使依赖关系不可见,大量使用会让单测难以 mock------仅在"非 Bean 上下文(如监听器、静态方法)"中使用,正常业务优先用 @Resource 注入。
2.4 通用模型与枚举
bean:通用 DTO / VO / 分页PageData/ 操作记录OperateRecordDTO。enums:通用枚举(操作类型、结果等,带 code/value 便于落库)。constant/exceptions/valid/feign(主数据客户端抽象)。
可复用点 :整个 common 模块可直接作为新项目的 starter 基础;其中的 Feign 透传拦截器、统一响应、分页配置是强复用样板。
3. 缓存模块(cache)
解决"缓存数据来源多样、需要热插拔不同加载逻辑"的问题。
3.1 核心设计:策略工厂 + Spring Cache 抽象
CacheService:基于 Spring@Cacheable/@CachePut提供基础缓存读写。CacheProvider<T, E>:缓存数据提供者策略接口 ,仅一个方法E getCacheDataFrom(T param)------不同数据源(用户、配置、字典等)各自实现。CacheProviderFactory:实现ApplicationContextAware,启动时自动收集容器中所有CacheProviderBean,并按"缓存 key 前缀 → provider 名称"的映射路由。调用方只需拼出带前缀的 key,即可命中对应加载逻辑。- 多级缓存:
RedissonV2Config提供 Redis(Redisson)分布式缓存与分布式锁;本地可结合 Caffeine,形成"本地 + 分布式"两级缓存。
java
// 示意接口
public interface CacheProvider<T, E> {
E getCacheDataFrom(T param);
}
// 调用方:key = "userInfo#123" -> 工厂按 "userInfo" 前缀路由到对应 provider
3.2 ⚠️ 必须注意的坑与潜在问题
- 前缀映射是"手工静态表" :工厂内部维护
cacheKeyProviderNameMap(如"userInfo" -> "userInfoCacheProvider")。新增一类缓存数据时,除了写CacheProvider实现类,还必须手动往这张映射表注册 ,否则调用方拼出带新前缀的 key 会抛IllegalArgumentException("无效的入参")。这是最易漏、最难排查的点------建议改为"约定优于配置"(provider 用注解声明前缀,启动时自动注册),消除手工映射。 ApplicationContextAware把 Bean 放进static Map:cacheProviderMap是静态字段。在同一 JVM 多 ApplicationContext(如测试用独立 context、或模块被多次启动)场景下,后启动的 context 会覆盖前者,导致前一个 context 的 provider 失效。生产单 context 没问题,但单测要警惕。- "本地 + 分布式"两级缓存的一致性 :本地 Caffeine 没有自动失效广播,更新分布式缓存不会主动清本地。写少读多、允许短暂不一致的场景才适合;强一致数据不要用本地缓存层,或引入 Redis 发布订阅主动失效本地。
- 分布式锁用 Redisson 但缓存读用 Spring Cache :两者 key 命名体系不同,别把
@Cacheable的 key 和分布式锁 key 混为一谈,避免误以为"加了缓存就线程安全"。 - 潜在问题 :
CacheProvider.getCacheDataFrom内部若访问数据库且 SQL 慢,会被缓存框架"雪崩式"并发触发(缓存未命中时多个线程同时回源)。建议对热点 key 加"互斥重建"或sync=true的@Cacheable。
4. 文件模块(file)
对对象存储做门面(Facade)封装,屏蔽底层 SDK,统一文件全生命周期管理。
4.1 核心组件:FileComponent
对外提供与存储引擎无关的方法:
upload/uploadBatch:单文件、多文件上传(自动按年/月分目录 + UUID 重命名防冲突)。uploadBatchZip:批量上传并自动解压 ZIP,对 docx 类文件额外提取正文内容,普通文件直接转存。download/downloadBatch/downloadRealName:单文件、打包下载、保留原文件名下载。preview/remotePreview:本地对象预览与远程 URL 预览。delete/deleteBatch:删除。- 桶名(bucket)与存储路径支持从方法参数或 HTTP 请求头(
bucketName/storagePath)解析,多租户/多空间场景通用。
4.2 ⚠️ 必须注意的坑与潜在问题
- 流资源泄漏风险(最实际的一坑) :
analyzeZip用ZipArchiveInputStream、BufferedInputStream、XWPFDocument三个流嵌套,虽然finally关闭了三者,但convertZipArchiveInputStreamToInputStream会把 zip 输入流完整读入内存ByteArrayOutputStream------大 ZIP(几百 MB)会直接 OOM。更严重的是:外层zipIn.getNextEntry()循环里,每次convert(...)已消耗了inputStream,但 docx 分支把同一inputStream既用于解析正文又用于minioComponent.put,依赖"流可被重复读"的假设,实际ByteArrayInputStream可以,但代码耦合脆弱。建议:大文件走流式边读边传,禁止一次性读入 byte\[\]。 - 中文文件名下载乱码 / 空格被截断 :
download()直接对 objectName 做URLEncoder.encode,若 objectName 含路径斜杠会被一并编码;downloadRealName()用attachment;fileName=而非标准的filename*=UTF-8'',部分浏览器(旧版 Safari)会乱码。建议 :统一用 RFC 5987 的filename*=UTF-8''+ 双写兼容。 - ZIP 中文条目名编码 :解压用
GB2312,但打的 ZIP 若由 Windows(GBK)或 macOS(UTF-8)生成会解错名。应探测编码或强制约定,否则文件名乱码导致后续按名查询失败。 - 删除是"硬删" :
delete直接调底层remove,无回收站、无引用计数 。业务表还引用着该 objectName 时误删,文件即永久丢失。建议:删除前校验业务引用,或先标记软删 + 异步物理清理。 remotePreview走 HttpRequest 拉远程流 :没有超时与大小限制,恶意 URL 或超大文件会拖垮业务线程。建议:加连接/读取超时与最大字节上限。- 桶名/路径从请求头取 :意味着调用方可以伪造 bucket 。必须做白名单校验(当前
getBucketNameInternal取不到才回退到配置默认 bucket,存在越权风险)。 - 路径分片用
LocalDate.now()拼字符串 :月/日不带前导零(如2026/8/2),与很多对象存储"层级前缀"最佳实践(带零便于分区)略有出入,但能用;注意查询时拼接要与之一致。
5. 定时任务模块(timer)
基于 ElasticJob-Lite 的注解驱动分布式调度框架,含执行日志。
5.1 注解声明式作业:@Scheduler
作用于类(@Component),属性包括:
name:作业名(缺省取服务名_类名)。cron:表达式。shardingTotalCount/shardingItemParameters:分片总数与分片参数(支持集群分片执行)。jobParameters:自定义参数。
框架在启动时扫描该注解,自动向调度中心(ZooKeeper)注册作业,无需 XML 配置。
5.2 执行日志
TimerExecuteLogListener:作业生命周期钩子(beforeJobExecuted记录开始、afterJobExecuted调用reportCron写入执行结果)。注意它被声明为ElasticJobListener(非 Spring 组件),通过SpringUtil.getBean(...)反向拿组件,以免"额外注册 Job Class 导致 ZK 元数据冲突"。TimerComponent:作业的动态注册与执行入口。TimerExecuteLogComponent.reportCron:把执行结果(成功/失败、耗时、异常)写入日志表。
5.3 ⚠️ 必须注意的坑与潜在问题
- 执行日志的"元数据缺失"坑(高频) :
reportCron(jobName)写日志时需要的字段(作业中文名、所属模块、责任人等)依赖作业元数据。若这些元数据来自进程内缓存或远程查询 ,beforeJobExecuted能拿到但afterJobExecuted时若缓存被清空、节点切换、或刚重启还没预热,就会出现日志字段为 NULL / 空 。这正是先前排查到的"执行日志里部分字段为 NULL"的根因。建议 :reportCron时直接通过 Feign 查询完整作业配置(带失败兜底默认值),不要只依赖内存缓存的那个 vo ;且before/after之间不要共享可变状态。 ElasticJobListener不是 Spring Bean 的隐患 :它在 ZK 侧注册,若被当作普通@Component还会额外生成一个 Job 类引用,可能触发 ZK 元数据冲突。用SpringUtil.getBean取依赖是对的,但要确保监听器自身不被 Spring 扫成 Bean 又同时被作业注册两次。- 分片参数与作业逻辑必须对齐 :
shardingItemParameters="0=a,1=b,2=c"必须与shardingTotalCount=3对应的分片项一一对应 ,否则某分片永远拿不到参数、某分片被重复执行。分片逻辑里务必用shardingContext.getShardingItem()取自己的分片,不要硬编码。 - cron 与集群 :ElasticJob 保证"同一作业在集群中只在一台机器跑某分片",但同一份代码部署多套环境 (测试/生产共用同一 ZK 命名空间)会互相抢作业。建议:ZK namespace 按环境隔离。
- 潜在问题 :作业方法内若抛的是
Error而非Exception,afterJobExecuted仍会触发,但异常信息可能没被reportCron捕获,日志显示"成功"实际失败------异常捕获要对Throwable兜底。 - 幂等 :定时任务可能因网络抖动被 ZK 重新触发(失效转移),作业逻辑必须幂等,否则重复执行会重复写数据。
6. 审计日志模块(log)------重点:ThreadLocal 计时与上下文
注解 + AOP 实现无侵入操作审计,统一落搜索引擎。这是本项目最值得讲清"为什么这么写、坑在哪"的模块。
6.1 注解与切面
@OperationRecord(type, content):标注在需要记录的方法上,声明操作类型与内容。OperationHistoryAspect:切面在方法@Before记录开始时间,@AfterReturning/@AfterThrowing分别处理成功/失败,自动采集:- 操作人(从当前用户上下文获取,缺失时取系统用户)。
- 客户端 IP、设备信息、模块/功能编码。
- 执行耗时(endTime - startTime)。
- 业务数据变更前后值 (
OperateRecordDTO.businessData/businessDataAfter)。 - 操作结果(成功/失败)。
- 最终批量写入搜索引擎(索引
OperationRecordIndex),支持全文检索与审计追溯。 - 另有
@Action/@QueryRecord/@LogTrace等注解及对应切面,覆盖动作日志、查询记录、链路追踪。
6.2 🔑 为什么用 ThreadLocal 计时(核心设计说明)
切面需要计算"方法执行耗时 = 结束时间 − 开始时间"。开始时间在 @Before、结束时间在 @AfterReturning/@AfterThrowing,两者属于同一次方法调用的前后两个通知 ,但它们处于不同的方法栈帧,没有共同的局部变量可以传递 startTime。
常见的错误写法:把 startTime 声明成切面的成员变量(字段)。
// 错误:成员变量在并发下会串数据
private long startTime; // 多线程共用一个 aspect 实例(Spring 默认单例)
@Before ... startTime = now();
@AfterReturning ... long cost = now() - startTime; // 可能读到别的线程设的值
因为 Spring 的 @Aspect 默认是单例 ,@Before 和 @After 又可能落在不同请求线程上(即使是同一线程,AOP 通知之间也无法通过方法参数传值),用成员变量必然导致并发串值、耗时计算错误。
因此项目使用 org.springframework.core.NamedThreadLocal:
java
private final NamedThreadLocal<Long> startTimeThreadLocal =
new NamedThreadLocal<>("StartTime-EndTime");
@Before(value = "pointCut()")
public void before(JoinPoint joinPoint) {
startTimeThreadLocal.set(System.currentTimeMillis()); // 绑定到当前线程
}
@AfterReturning(...) / @AfterThrowing(...)
private void addRecord(...) {
long startTime = startTimeThreadLocal.get(); // 同一线程取回
long endTime = System.currentTimeMillis();
BigDecimal executeTime = BigDecimal.valueOf((endTime - startTime) / 1000.0)...;
}
NamedThreadLocal 是线程隔离 的:每个请求线程有自己的一份 startTime,前后通知同处一个线程即可安全传递,且 Named 前缀便于排查内存泄漏时定位是哪个 ThreadLocal。这是"跨通知传递上下文"的标准解法,必须这样写,不能退化成成员变量。
6.3 ⚠️ ThreadLocal 计时方案的坑与潜在问题(开发者视角)
- ThreadLocal 必须
remove,否则内存泄漏 + 脏数据 :当前实现只在set后取值,没有remove。@Before设值后,若方法异常且未走@AfterThrowing(如被别的切面吞掉、或Error)、或线程被线程池复用,下次同一线程跑别的请求时会get到上一次的旧值 ,导致耗时/上下文错乱。尤其是 Web 容器用线程池,线程会被复用。强烈建议 :在finally中startTimeThreadLocal.remove()。这是本项目真实存在的隐患点。 - 异步方法会让 ThreadLocal 失效 :
@OperationRecord标注的方法如果内部用@Async或自己new Thread/CompletableFuture执行真正逻辑,那么@Before(主线程)设的 ThreadLocal,在子线程的@AfterReturning里get不到(子线程没有父线程的 ThreadLocal)。结论 :审计切面只适用于同步执行的同一线程 场景;异步方法要么把审计逻辑放进异步体内单独记,要么用TaskDecorator做 ThreadLocal 透传。 RequestContextHolder在异步/非 Web 线程为空 :切面里取 IP、模块编码依赖RequestContextHolder.getRequestAttributes()。定时任务、MQ 消费线程里没有 Request,会null,此时host=deviceInfo=空、moduleCode取不到------必须对每个从 request 取的值做空兜底 (当前对 host 有兜底0.0.0.0,但 moduleName/functionName 可能为 null 落库,需确认索引字段是否允许 null)。- 异常分支拿不到"业务数据前后值" :
@AfterThrowing分支只记了操作类型/内容/结果,没有businessDataAfter(因为方法没返回OperateRecordDTO)。这是合理的------失败时本就没有"新值"。但要注意:如果业务在抛异常前已部分写库,审计只记"失败"却看不到"改了一半的数据",排障时缺信息。建议:异常时尽量把入参 DTO 的 before 值也记上。 - 返回值约定脆弱 :切面靠"返回值必须是
BaseResponse或OperateRecordDTO或List<OperateRecordDTO>"来提取审计数据;若开发者返回了别的类型(如直接返回String、或BaseResponse但忘了塞operateRecord),切面会静默return(只打 info 日志),审计悄无声息地丢失 。这是"魔法约定"的代价------建议抽一个接口(如OperateRecordCarrier)让返回类型显式声明"我携带审计数据",编译期就能约束。 - 写入搜索引擎是"同步远程调用" :
EsTemplateComponent.save/saveList在切面里同步执行,意味着任何被审计的方法都会因 ES 抖动而变慢甚至失败 。ES 不可用时应降级(记本地文件/丢弃 + 告警),绝不能让审计影响主业务。当前实现若 ES 抛异常会沿切面抛到业务方法,导致业务也失败------这是高风险点。 @Order与事务切面的顺序 :审计切面需要"方法已提交完 DB 再记 before/after 值"。若与@Transactional切面顺序不当,可能记录的是事务未提交前的数据。需保证审计切面在事务切面之后(order 值更大)执行。- 用户上下文同样来自 ThreadLocal :
UserInfoPrincipalHolder.getPrincipal()也是 ThreadLocal;异步线程/非 Web 入口同样取不到,会回退到"系统用户"------批量脚本操作的审计会全部归因到系统账号,需评估是否符合审计合规要求。
可复用点 :注解 + AOP 审计是通用强复用模式;把存储介质从搜索引擎换成数据库/消息队列也只是改 save 一行。但上述 ThreadLocal/异步/降级/顺序坑,是任何想照搬该模式的团队都必须提前处理的。
7. 消息模块(message)
注解声明式 MQ 消费 + 消费日志。
7.1 核心注解:@MessageListener
作用于消费者类(@Target TYPE),属性:
topic:消费主题。batch:是否批量消费(默认 false)。
框架扫描该注解自动注册消费者,并内置 MqLogComponent 记录消息消费日志(投递/消费/异常)。
7.2 ⚠️ 必须注意的坑与潜在问题
- 消费幂等是硬要求 :MQ(RocketMQ/Kafka)在"至少一次"语义下可能重复投递。同一
topic的消费逻辑若写库,必须按业务主键去重或用数据库唯一键兜底,否则重复消息会产生脏数据。@MessageListener本身不提供幂等能力,需业务自己保证。 - 注解在类上,一个类一个 topic:若想同一逻辑消费多个 topic,需写多个类;类级注解也意味着"消费逻辑与 topic 强绑定",重构 topic 名时要同步改注解------建议 topic 抽到常量类,避免散落字符串。
- 消费线程里同样没有 Request 上下文 :
RequestContextHolder为空,需要 traceId 时应在消费入口手动生成并MDC.put,否则链路追踪断在 MQ 处。 MqLogComponent落库不能拖慢消费:消费日志若同步写 DB 且 DB 慢,会积压消费线程。建议消费日志异步化或采样。- 潜在问题 :
batch=true时消费到的是消息列表,单条失败的处理策略(整批重试 / 跳过单条)要和产品确认,否则一条坏消息会卡住整批。
8. 流程模块(flow)
适配器模式统一多工作流引擎,业务层无感知。
8.1 核心设计
FlowComponent:对外提供流程查询/发起等能力;内部按配置项workflow-type选择具体引擎实现。WorkFlowAdapter:工作流适配器接口;AdapterWorkflowEnum按类型路由到不同实现(如 Camunda 实现、第三方平台实现)。- 配套
CamundaComponent/AuditFlowComponent/FormComponent:封装具体引擎的表单与审批能力。
8.2 ⚠️ 必须注意的坑与潜在问题
- 配置路由一旦配错,运行时才暴露 :
workflow-type决定走哪套引擎,配错值会落到默认分支或直接抛"不支持"。建议启动期校验配置合法性,Fail-Fast。 - 适配器之间的行为差异要收敛在接口契约内 :不同引擎对"审批人、会签、退回"语义不同,若接口方法把这些差异透传给业务层,适配器就失去意义。应在适配器内抹平差异(统一返回结构),否则业务层还是要写
if (Camunda) ... else ...。 - 弱复用提醒:仅多引擎或需预留切换时才值得引入该抽象;单引擎项目直接依赖 SDK 即可,过度抽象反而增加理解成本。
- 潜在问题:跨引擎迁移历史流程数据时,实例状态机不兼容,需单独做数据迁移脚本,不是换个 Adapter 就能解决。
9. 主数据 / 组织适配模块(adapter)
统一人员、组织、角色查询,屏蔽外部主数据平台差异。
9.1 核心设计
OrgAdapter:组织/人员查询统一接口(按组织取人、按父组织取子树、获取当前登录人、按角色取人等)。Org4XxxAdapter:具体平台实现(实现support(adapter)做兼容性判断)。XxxComponent:提供"获取当前登录人 / 系统用户 ID"等上下文能力(同样基于 ThreadLocal)。
9.2 ⚠️ 必须注意的坑与潜在问题
- 外部平台不可用的降级 :
OrgAdapter实现里若直接远程调用主数据平台,平台抖动会让所有"取当前人/取组织"的接口失败,连带核心业务 500。必须:对"取当前登录人"这类高频只读场景做本地缓存 + 失败兜底(回退到 token 中的冗余信息),不要把可用性绑死在外部平台。 support(adapter)路由判断要无歧义 :多个 Adapter 实现并存时,若support条件重叠,可能命中错的实现。建议用明确枚举匹配,避免模糊字符串包含判断。- 用户上下文 ThreadLocal 的清空 :同第 6 节,登录人信息用 ThreadLocal 持有,请求结束时必须
remove,否则线程池复用会串号(A 用户的操作记成 B 用户)------这是合规与安全的严重问题。 - 潜在问题 :主数据模型升级(字段改名、接口版本)会穿透到
OrgAdapter接口;接口方法签名若直接耦合对方模型,升级成本高。建议适配器内部做 DTO 转换,对外暴露本项目自己的模型。
10. 其他能力模块(简介)
| 模块 | 职责 | 通用借鉴点 | 主要坑 |
|---|---|---|---|
| 树结构(tree) | 组织/分类树构建与遍历工具 | 父ID→树的通用递归/栈算法可复用 | 深层级递归小心栈溢出;超大树用 Map 索引法而非递归 |
| 规则引擎(rule) | 基于表达式(QLExpress)的规则计算 | 规则与代码解耦,配置化业务规则 | 表达式由运营配置时存在注入/死循环风险,必须沙箱化 + 超时限制 |
| 振动/算法(vibrate) | 信号处理与算法分析 | 算法模块独立封装,不污染业务 | 计算密集,避免在主请求线程跑;注意数值精度与 NaN |
| 队列(queue) | 队列封装 | 异步解耦的通用封装 | 内存队列重启丢数据,重要消息要用持久化 MQ |
| 聚合(all) | 聚合所有通用能力模块 | 业务系统一站式引入的"bom 式"模块 | 引入即带入全部传递依赖,注意依赖冲突与体积 |
| 基础设施封装(wipi-*) | Redis/对象存储/MQ/搜索引擎/调度 SDK 封装 | 把第三方 SDK 收敛在底层 | SDK 版本升级集中在此,需做兼容测试 |
11. 跨项目复用建议(含避坑优先级)
强复用(建议直接照搬,但先修掉文中标注的坑)
- 基础模块 :统一响应
BaseResponse、业务异常、MyBatis-Plus 分页配置、Feign 透传拦截器。→ 先处理BaseResponse误塞审计字段问题。 - 缓存策略工厂 (
CacheProvider+CacheProviderFactory):热插拔缓存数据源。→ 先把"手工前缀映射表"改为自动注册。 - 文件门面组件:上传/下载/批量/ZIP 解析/预览。→ 先修流泄漏、中文名、bucket 越权三坑。
- 注解驱动定时任务 (
@Scheduler+ 分片 + 执行日志)。→ 先解决reportCron元数据 NULL 坑与分片参数对齐。 - 注解 + AOP 审计日志 (
@OperationRecord)。→ 必须补 ThreadLocal.remove、ES 降级、异步失效说明、切面 Order。 - 注解声明式 MQ 消费 (
@MessageListener+ 消费日志)。→ 补消费幂等与 MQ 处 traceId。
弱复用 / 需改造
- 工作流适配器:依赖具体引擎,仅多引擎场景需要。
- 主数据/组织适配器:接口需随外部平台模型调整,且必须加降级与 ThreadLocal 清空。
- 业务系统(sys):纯业务聚合层,无通用价值,不在借鉴范围。
架构样板
- 多模块单向依赖、基础模块零业务依赖,是大型后端项目的通用骨架。
- "基础设施封装 → 基础公共服务 → 通用能力 → 业务系统"四层结构,可直接作为新项目的分层模板。
- 贯穿所有模块的一条铁律 :凡是"请求级 / 用户级上下文"都靠 ThreadLocal 传递(耗时、登录人、traceId),用完必 remove,且异步线程不继承------这是本项目反复踩坑后最该写进规范的结论。
12. 关键技术栈清单
| 类别 | 技术 |
|---|---|
| 框架 | Spring Boot 2.6.x、Spring Cloud 2021.x |
| 数据访问 | MyBatis-Plus 3.5.x、MySQL |
| 缓存 | Redis(Redisson)、Caffeine(本地) |
| 对象存储 | S3 兼容对象存储(MinIO 等) |
| 分布式调度 | ElasticJob-Lite(ZooKeeper 协调) |
| 消息队列 | RocketMQ |
| 搜索引擎 | Elasticsearch |
| 工作流 | Camunda / 第三方流程平台(适配器切换) |
| 规则引擎 | QLExpress 表达式 |
| 文档处理 | EasyExcel、Apache POI、PDF(flying-saucer/itext) |
| 工具 | Hutool、Guava、Orika、Lombok、FastJson/Jackson |
| 服务调用 | OpenFeign + OkHttp |
本总结描述架构、设计模式与真实避坑经验,可作为新项目的通用参考样板与"踩坑 checklist"。