接上篇 monorepo。前端 13 个工具塞一个仓库,那后端呢?总不能 13 套独立服务各自为政吧。前端收敛了,后端不收敛,等于把混乱从仓库搬到了网络层。
我们最后的选择是:一套 Spring Boot 服务,multi-module 结构,把 13 个工具的 API 全部收进来。这篇讲清楚怎么做的。
为什么是一个服务,不是 13 个微服务
我知道”微服务”听着高级。但在金融工具这个场景,13 个微服务是过度设计:
- 工具之间数据强耦合:理财计算工具和退休规划工具共用同一份客户档案,拆成两个服务就要做数据同步,反而更脆弱。
- 团队规模不够:维护 13 个服务的运维成本(监控、发布、链路追踪)我们这种团队扛不动。
- 金融监管要求统一审计:一个服务一套审计逻辑,监管来看你直接懵。
我的判断是:先做模块化的单体(Modular Monolith),等真正出现瓶颈再拆。这不是偷懒,是工程上的克制。
multi-module 怎么分
Maven multi-module 是这次的主角。我们把整个服务分成几个模块,每个工具是一个独立的 module:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| fintech-backend/ ├── pom.xml # 根 POM,统一依赖版本 ├── common/ # 公共模块 │ ├── common-core/ # 统一响应、错误码、工具类 │ ├── common-web/ # 拦截器、过滤器、全局异常处理 │ ├── common-security/ # 鉴权、JWT、权限 │ └── common-audit/ # 审计日志 ├── domain/ # 领域模块 │ ├── domain-wealth/ # 理财领域 │ ├── domain-retirement/ # 退休领域 │ ├── domain-tax/ # 税务领域 │ └── ... ├── api-wealth-calc/ # 某理财计算工具的 API ├── api-retirement-plan/ # 某退休规划工具的 API ├── api-tax-optimize/ # 某税务优化工具的 API ├── app/ # 启动模块,聚合所有 API └── docker/
|
这个结构有个关键设计:common 和 domain 是独立 module,api 模块依赖它们但不互相依赖。也就是说 api-wealth-calc 不能直接调用 api-retirement-plan 的代码,要协作就走 domain 层或者事件。
根 POM 用 dependencyManagement 把版本统死:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>3.2.5</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>com.suite</groupId> <artifactId>common-core</artifactId> <version>${project.version}</version> </dependency> </dependencies> </dependencyManagement>
|
统一响应格式
13 个工具如果返回的 JSON 结构各不相同,前端 api-client 就得写 13 套适配代码。我们在 common-core 里定了一个统一信封:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public class ApiResponse<T> { private int code; private String message; private T data; private String traceId;
public static <T> ApiResponse<T> ok(T data) { return new ApiResponse<>(0, "OK", data, TraceContext.currentId()); }
public static <T> ApiResponse<T> fail(ErrorCode errorCode) { return new ApiResponse<>(errorCode.getCode(), errorCode.getMessage(), null, TraceContext.currentId()); } }
|
统一信封看着是小决策,但它让前端的错误处理从 13 套变成了 1 套。
前端那边的 api-client 就可以统一拦截:
1 2 3 4 5 6 7 8
| apiClient.interceptors.response.use((response) => { const body = response.data; if (body.code !== 0) { return Promise.reject(new BizError(body.code, body.message)); } return body.data; });
|
全局异常处理
Spring 的 @RestControllerAdvice 在这里很关键。我们把所有业务异常收口到一个地方,避免每个 controller 重复写 try-catch:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @RestControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class) public ApiResponse<?> handleBiz(BizException e) { return ApiResponse.fail(e.getErrorCode()); }
@ExceptionHandler(MethodArgumentNotValidException.class) public ApiResponse<?> handleValidation(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(f -> f.getField() + ": " + f.getDefaultMessage()) .collect(Collectors.joining("; ")); return ApiResponse.fail(ErrorCode.VALIDATION_FAILED.withMessage(msg)); }
@ExceptionHandler(Exception.class) public ApiResponse<?> handleUnknown(Exception e) { log.error("unhandled exception, traceId={}", TraceContext.currentId(), e); return ApiResponse.fail(ErrorCode.INTERNAL_ERROR); } }
|
注意最后那个 catch-all,金融系统绝不能把堆栈泄露给前端,只返回一个模糊的”系统错误”,详细信息走 traceId 在日志里查。
鉴权:每个工具有独立的 scope
13 个工具,不是每个用户都有权用所有工具。我们在 JWT 里塞了 tools 字段,标记当前用户可以用哪些工具:
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class ToolAccessInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { String tool = req.getHeader("X-Tool-Id"); UserContext user = UserContext.fromRequest(req); if (!user.hasToolAccess(tool)) { resp.setStatus(403); return false; } return true; } }
|
注册的时候给每个 api module 的路径打上标记:
1 2 3 4 5 6 7 8
| @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(toolAccessInterceptor()) .addPathPatterns("/api/wealth/**", "/api/retirement/**", "/api/tax/**"); } }
|
审计日志:金融场景的硬要求
这块我觉得是金融项目和普通项目最大的差别:每一次计算请求都必须留痕。监管来了要能说清楚”某年某月某日,某顾问用某工具做了什么计算,输入是什么”。
我们的做法是在 common-audit 里定义一个切面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Aspect @Component public class AuditAspect {
@Autowired private AuditLogRepository auditRepo;
@Around("@annotation(audited)") public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable { Object result = pjp.proceed(); AuditLog log = AuditLog.builder() .userId(UserContext.currentId()) .toolId(audited.tool()) .action(audited.action()) .requestHash(DigestUtils.sha256Hex(toJson(pjp.getArgs()))) .timestamp(Instant.now()) .build(); auditRepo.save(log); return result; } }
|
注意 requestHash 这里,我们不存原始入参(含客户敏感数据),只存 hash,既能追溯”是否同一份输入”,又不泄露客户信息。这个设计是和合规团队磨了好几轮才定的。
前后端契约:OpenAPI 生成 TS 类型
最后一个关键点:前后端的类型对齐。手写 TS 类型必然和后端 drift。我们用 springdoc 生成 OpenAPI spec,然后前端自动生成类型:
1 2 3 4 5 6
| <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.3.0</version> </dependency>
|
前端 monorepo 的 api-client 包里:
1 2
| openapi-typescript http://localhost:8080/v3/api-docs -o src/types/api.d.ts
|
类型不靠人维护,靠工具同步,这是 13 个工具规模下唯一可持续的做法。
小结
13 个工具收敛到一套 Spring Boot 后端,关键就几件事:multi-module 把模块化分清楚、common-core 统一 API 信封、common-audit 让审计可追溯。不是花哨的架构,但稳。
下一篇讲计算引擎,金融报表怎么做到”每一张都能复算”。