PAYATHON 2026

如何设计 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:

  1. billing engine 携带结算时间和客户标识,请求相应配置。
  2. Microservice 1 返回该时点有效的完整配置。
  3. billing engine 保存响应快照。
  4. 后续计算只使用这份快照,不再重复读取实时配置。

请求示例:

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"
}

实施顺序

可以按以下顺序落地:

  1. 在 Microservice 1 中分别设置 /api/v1 和 /internal/v1 Controller。
  2. 将 Repository 调用和事务逻辑移入 Application Service。
  3. 分别为 Public API 和 Internal API 定义 DTO。
  4. 让 Internal API 面向 billing configuration,而不是面向数据库表。
  5. 为所有批量接口增加分页、游标或增量同步能力。
  6. 为配置增加生效时间和版本号。
  7. 让 billing engine 在每次结算时保存配置快照。
  8. 加入服务身份认证、超时、有限重试和契约测试。
  9. 当同步调用开始影响可用性或吞吐量时,再引入事件和本地只读模型。

最终原则是:接口根据消费者分开设计,业务能力在服务内部复用,数据库模型不跨服务传播,计费输入需要版本化且能够复现。这样既能避免两个 Controller 复制大量代码,也不会把前端接口和 billing engine 的生命周期绑在一起。

备注:内容仅供参考。