如何设计 billing system 的 microservices 通信与 API
结论
建议将 Public API 和 Private API 分别设计,但在业务层复用实现。
具体来说:
- Public API 面向 React FE,根据页面展示和交互需求设计。
- Private API 面向 billing engine,提供稳定的服务契约。
- 两类 API 分别使用不同的 Controller、DTO、权限和版本策略。
- SQL 查询、事务和领域规则下沉到共享的 Application Service、Domain Service 和 Repository,避免重复实现。
- billing engine 不应长期依赖”获取全部用户、全部产品”一类的接口。数据量较小时,可以先使用分页 REST API;规模扩大后,优先通过事件同步 billing 所需的配置,并在结算任务中保存配置快照。
不要把 Public API 原样开放给 Microservice 2,也不要为了减少重复而让两个调用方共用完全相同的接口模型。两类 API 的消费者、数据粒度、兼容性和安全要求都不一样。
推荐的分层结构
Microservice 1 可以采用下面的结构:
interfaces
├── publicapi
│ ├── PublicUserController
│ ├── PublicProductController
│ └── dto
└── internalapi
├── BillingConfigController
└── dto
application
├── UserQueryService
├── ProductQueryService
├── BillingConfigQueryService
└── BillingConfigurationService
domain
├── User
├── BillingProduct
├── BillingPeriod
├── Charge
└── service
infrastructure
├── persistence
│ ├── SpringDataUserRepository
│ └── SpringDataProductRepository
└── messaging
各层的职责是:
- Controller 处理 HTTP 请求、参数校验、身份认证和 DTO 转换。
- Application Service 组织用例、事务、查询流程和权限上下文。
- Domain Service 实现具体的计费配置规则。
- Repository 负责持久化访问。
- DTO Mapper 负责领域对象、查询投影和 API DTO 之间的转换。
Controller 不应直接调用 Spring Data JPA Repository,否则分页、权限、事务、业务校验和查询规则很容易散落到多个接口中。
Public API 与 Private API 如何划分
Public API
Public API 面向人机交互,通常需要提供:
- 分页、筛选和排序;
- 适合表格展示的字段;
- 表单校验信息;
- 创建、编辑和停用等命令;
- 针对当前操作者的权限控制;
- 便于前端处理的错误信息。
例如:
GET /api/v1/users?page=0&size=20&status=ACTIVE
POST /api/v1/users
PATCH /api/v1/users/{userId}
GET /api/v1/billing-products?page=0&size=20
PUT /api/v1/billing-products/{productId}
Private API
Private API 面向 billing engine,应根据计费任务设计,而不是机械地暴露所有数据库表。
更合适的接口示例:
GET /internal/v1/billing-configurations?updatedAfter=2026-08-01T00:00:00Z&cursor=...
GET /internal/v1/billing-configurations/{customerId}?effectiveAt=2026-08-29T00:00:00Z
GET /internal/v1/billing-periods/{periodId}/snapshot
相比下面这些接口:
GET /internal/v1/users/all
GET /internal/v1/products/all
GET /internal/v1/charges/all
以 “billing configuration” 为单位的聚合接口更合适,原因包括:
- billing engine 不需要了解 Microservice 1 的表结构;
- 一次请求就能获取某个客户在指定时间生效的完整配置;
- 可以减少网络调用和数据不一致;
- Microservice 1 重构内部表结构时,不会直接影响消费者;
- 更容易实现版本管理、快照和审计。
这里的 “private” 只表示接口的使用范围,并不意味着它天然安全。Private API 仍然需要认证、授权、超时控制和访问审计。
复用业务代码,而不是强行复用 DTO
Public API DTO 与 Internal API DTO 可以共享少量稳定的值对象,但不应默认共用完整模型。
例如,前端列表 DTO 只包含展示需要的内容:
public record UserListItemResponse(
UUID id,
String displayName,
String status
) {
}
billing engine 可能需要不可变的计费标识和生效时间:
public record BillableUserResponse(
UUID userId,
String billingAccountId,
String currency,
Instant effectiveFrom,
Instant effectiveTo
) {
}
两类数据可以由同一个应用服务或查询层提供:
@Service
@Transactional(readOnly = true)
public class UserQueryService {
private final UserRepository userRepository;
public UserQueryService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Page<UserSummary> findUsers(UserSearchCriteria criteria, Pageable pageable) {
return userRepository.findUsers(criteria, pageable);
}
public List<BillableUser> findBillableUsers(
Instant effectiveAt,
UUID afterId,
int limit
) {
return userRepository.findBillableUsers(effectiveAt, afterId, limit);
}
}
Controller 再将结果转换成各自的接口契约:
@RestController
@RequestMapping("/api/v1/users")
public class PublicUserController {
private final UserQueryService userQueryService;
private final PublicUserMapper mapper;
@GetMapping
public Page<UserListItemResponse> list(
UserSearchRequest request,
Pageable pageable
) {
return userQueryService
.findUsers(request.toCriteria(), pageable)
.map(mapper::toResponse);
}
}
@RestController
@RequestMapping("/internal/v1/billing-configurations")
public class BillingConfigController {
private final BillingConfigQueryService queryService;
private final BillingConfigMapper mapper;
@GetMapping("/{customerId}")
public BillingConfigurationResponse getConfiguration(
@PathVariable UUID customerId,
@RequestParam Instant effectiveAt
) {
return mapper.toResponse(
queryService.getEffectiveConfiguration(customerId, effectiveAt)
);
}
}
这样的结构既能复用查询和业务规则,也允许两类 API 独立演进。
查询复杂时使用 Projection 或专用查询模型
如果 Public API 和 Private API 需要的字段差异很大,就不必每次都加载完整的 JPA Entity。可以改用 projection、JPQL、Criteria API 或专用查询 Repository。
public interface BillableUserProjection {
UUID getUserId();
String getBillingAccountId();
String getCurrency();
Instant getEffectiveFrom();
Instant getEffectiveTo();
}
public interface UserReadRepository {
Page<UserSummary> search(UserSearchCriteria criteria, Pageable pageable);
List<BillableUser> findEffectiveBillableUsers(
Instant effectiveAt,
UUID afterId,
int limit
);
}
需要注意:
- 不要把 JPA Entity 直接作为 HTTP 响应返回;
- 不要让 Jackson 序列化懒加载关联;
- 不要把数据库字段结构当成跨服务契约;
- 查询代码重复不一定是问题,业务规则重复往往更危险;
- 针对不同用例编写不同查询,通常比维护一个万能查询更简单。
服务间通信方案
初期阶段:同步 REST API
如果 billing engine 只在启动结算任务时读取配置,而且数据规模可控,可以先使用同步 REST:
- billing engine 携带结算时间和客户标识,请求相应配置。
- Microservice 1 返回该时点有效的完整配置。
- billing engine 保存响应快照。
- 后续计算只使用这份快照,不再重复读取实时配置。
请求示例:
GET /internal/v1/billing-configurations/6e2acb5d-7e76-4d15-a1fb-1a27a1929417?effectiveAt=2026-08-01T00:00:00Z
Authorization: Bearer <service-token>
响应可以包含契约版本和配置版本:
{
"schemaVersion": "1",
"configurationVersion": 42,
"customerId": "6e2acb5d-7e76-4d15-a1fb-1a27a1929417",
"effectiveAt": "2026-08-01T00:00:00Z",
"currency": "CNY",
"billingPeriod": {
"type": "MONTHLY",
"timezone": "Asia/Shanghai"
},
"charges": [
{
"chargeCode": "API_REQUEST",
"unit": "REQUEST",
"unitPrice": "0.0150"
}
]
}
金额应使用 BigDecimal,或以字符串形式传输,不能用二进制浮点数表示。时间需要明确时区和生效边界。
数据量增加:增量接口
如果必须批量拉取数据,应提供游标分页和增量条件:
GET /internal/v1/billing-configurations/changes?updatedAfter=2026-08-01T00:00:00Z&cursor=eyJpZCI6...
响应示例:
{
"items": [],
"nextCursor": "eyJpZCI6...",
"hasMore": true
}
不要使用没有分页的 getAllUsers()。这类接口容易造成内存峰值、响应超时和数据库长事务,失败重试时还会给系统带来重复压力。与 offset pagination 相比,基于稳定字段的 cursor pagination 更适合大批量同步。
更高可用性:事件驱动同步
如果 billing engine 需要频繁读取配置,或者计费不能因 Microservice 1 暂时不可用而停止,可以让 Microservice 1 发布配置变更事件:
{
"eventId": "194148d8-5f24-4338-91d2-f28032ea8d52",
"eventType": "BillingProductChanged",
"eventVersion": 1,
"aggregateId": "PRODUCT-1001",
"aggregateVersion": 17,
"occurredAt": "2026-08-29T10:15:30Z",
"data": {
"productId": "PRODUCT-1001",
"effectiveFrom": "2026-09-01T00:00:00Z"
}
}
billing engine 消费事件并维护自己的本地只读模型。在这种模式下:
- REST API 负责首次全量同步和故障补偿;
- 事件负责持续增量更新;
- 消费者根据
eventId实现幂等; - 系统根据聚合版本检测乱序或遗漏;
- 生产端可以使用 Transactional Outbox,避免数据库提交成功但事件发送失败。
事件驱动也会增加消息中间件、幂等、重放、监控和最终一致性方面的复杂度。系统规模较小时,没有必要过早引入。
配置快照对 billing 尤其重要
计费结果必须能够复现。假设产品价格在结算进行到一半时发生变化,如果 billing engine 每次都读取实时数据,同一账期可能计算出不同的结果。
建议为每次结算保存以下内容:
billingRunId;customerId;billingPeriod;configurationVersion;- 实际使用的价格、规则和税务参数;
- 快照生成时间;
- 计算引擎版本。
billing engine 应按照”账期内生效的配置”计算,而不是使用”当前最新配置”。配置最好包含 effectiveFrom、effectiveTo 或明确的版本号。
安全与部署边界
即使 Public API 和 Private API 位于同一个 Spring Boot 应用中,也可以按不同路径实施不同的安全策略:
@Bean
@Order(1)
SecurityFilterChain internalApiSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher("/internal/**")
.authorizeHttpRequests(auth -> auth
.anyRequest().hasAuthority("SCOPE_billing-config.read"))
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.build();
}
@Bean
@Order(2)
SecurityFilterChain publicApiSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.build();
}
生产环境还可以配合使用:
- OAuth 2.0 Client Credentials;
- mTLS;
- API Gateway 或内部负载均衡器;
- 网络策略,将
/internal/**的访问来源限制在服务网络内; - 独立的限流、日志和审计规则。
仅靠路径名、内网地址或自定义请求头,无法实现可靠的服务身份认证。
接口兼容性与故障处理
Internal API 也是正式的跨服务契约,应满足以下要求:
- 使用
/internal/v1/...或同等的版本策略; - 优先增加可选字段,避免改变已有字段的含义;
- 使用 OpenAPI 描述请求和响应;
- 为消费者运行契约测试;
- 设置连接超时、读取超时和并发上限;
- 只对幂等请求进行有限重试,并使用指数退避和随机抖动;
- 配置熔断和指标监控;
- 返回明确的错误码,不要暴露数据库异常。
例如:
{
"code": "BILLING_CONFIGURATION_NOT_FOUND",
"message": "No effective billing configuration was found",
"customerId": "6e2acb5d-7e76-4d15-a1fb-1a27a1929417",
"effectiveAt": "2026-08-01T00:00:00Z"
}
实施顺序
可以按以下顺序落地:
- 在 Microservice 1 中分别设置
/api/v1和/internal/v1Controller。 - 将 Repository 调用和事务逻辑移入 Application Service。
- 分别为 Public API 和 Internal API 定义 DTO。
- 让 Internal API 面向 billing configuration,而不是面向数据库表。
- 为所有批量接口增加分页、游标或增量同步能力。
- 为配置增加生效时间和版本号。
- 让 billing engine 在每次结算时保存配置快照。
- 加入服务身份认证、超时、有限重试和契约测试。
- 当同步调用开始影响可用性或吞吐量时,再引入事件和本地只读模型。
最终原则是:接口根据消费者分开设计,业务能力在服务内部复用,数据库模型不跨服务传播,计费输入需要版本化且能够复现。这样既能避免两个 Controller 复制大量代码,也不会把前端接口和 billing engine 的生命周期绑在一起。
备注:内容仅供参考。
