如何设计通过 DTO 的 type 字段决定 endpoint 行为的请求处理?
当前各支付类型的必填字段已经不同,建议保留各自的专用 DTO,把公共日志创建逻辑放到 service 中复用。支付逻辑仍然可以通过 type 分派,类型少时使用 switch 完全合理。复用日志处理逻辑,也可以继续使用不同的请求 DTO。
一次支付流程中,加载所需实体并完成业务校验后,支付处理和日志关联应使用同一批对象,避免为了记录日志再次查询。
DTO 应当表达每种支付的有效输入
通用 DTO 容易接受业务上无效的字段组合,例如:
{
"type": "TYPE_X",
"message": "支付",
"price": 100,
"itemId": 12
}
这个请求带了 TYPE_Y 使用的字段,却缺少 TYPE_X 必需的 userId 和 couponId。支付类型越多,通用 DTO 和条件校验就会越复杂。
专用 DTO 可以直接声明每种类型的必填字段。下面使用 Java 17 的 record 和 jakarta.validation 注解,普通 Java 类也可以采用相同设计。
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record PaymentXRequest(
@NotNull Long userId,
@NotNull Long couponId,
@NotBlank String message,
@NotNull @DecimalMin("0.00") BigDecimal price
) {
}
public record PaymentYRequest(
@NotNull Long itemId,
@NotBlank String message,
@NotNull @DecimalMin("0.00") BigDecimal price
) {
}
这里允许金额为零。如果业务要求金额必须大于零,可以使用 @Positive。
@NotNull 只能检查值是否为 null,不能检查字符串是否为空白,因此 message 更适合使用 @NotBlank。用户是否存在、优惠券是否可用、操作者是否有权限,仍需通过业务校验来判断。
金额建议使用 BigDecimal,并明确币种、小数位数和舍入规则。DTO 与持久化实体的金额类型应保持一致,避免中途转回 Double。
endpoint 可以拆分,也可以保留一个
如果前端能明确选择支付类型,为每种类型设置单独的 endpoint 最容易实现:
@RestController
@RequestMapping("/payments")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
@PostMapping("/x")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void payX(@Valid @RequestBody PaymentXRequest request) {
paymentService.processX(request);
}
@PostMapping("/y")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void payY(@Valid @RequestBody PaymentYRequest request) {
paymentService.processY(request);
}
}
URL 已经确定了支付类型,DTO 中可以省去 type,避免 URL 与请求体中的类型冲突。
约束注解只有在校验机制执行时才会生效。这里通过 controller 参数上的 @Valid 触发请求体校验,项目还需要配置 Bean Validation 实现。在其他 Java 代码中直接调用 service 时,DTO 上的注解不会自动触发同样的校验。
如果必须保留 POST /payments,也可以使用各类型的专用 DTO。以 Jackson 为例,可以显式配置类型映射:
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(
value = PaymentXRequest.class,
name = "TYPE_X"
),
@JsonSubTypes.Type(
value = PaymentYRequest.class,
name = "TYPE_Y"
)
})
public interface PaymentRequest {
}
让两个 DTO 分别 implements PaymentRequest,controller 接收 PaymentRequest:
@PostMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
public void pay(@Valid @RequestBody PaymentRequest request) {
paymentService.process(request);
}
Jackson 根据 type 创建具体 DTO,再对该对象执行校验。在这种配置下,type 是反序列化的判别字段,各 DTO 无须再定义一个可修改的同名属性。
service 仍需将请求分派到对应的处理方法。类型少时,集中使用 instanceof 即可;类型增多、处理器需要独立维护时,再考虑处理器注册表或策略模式。对于缺失或未知的类型,应拒绝处理,不要静默选择默认支付类型。
加载一次实体,支付处理和日志共同使用
原代码中的具体支付方法已经查询了实体,但返回类型是 void,外层拿不到这些对象,只能再次通过 ID 查询。调整方法边界后,就可以在整个流程中复用已加载的对象。
可以在事务入口加载实体,再传给具体的支付处理器:
@Service
@RequiredArgsConstructor
public class PaymentService {
private final UserRepository userRepository;
private final CouponRepository couponRepository;
private final PaymentLogRepository logRepository;
private final CurrentUserService currentUserService;
private final PaymentXProcessor paymentXProcessor;
@Transactional
public void processX(PaymentXRequest request) {
User manager = currentUserService.requirePaymentAdmin();
User user = userRepository.findById(request.userId())
.orElseThrow(() ->
new EntityNotFoundException("User not found"));
Coupon coupon = couponRepository.findById(request.couponId())
.orElseThrow(() ->
new EntityNotFoundException("Coupon not found"));
paymentXProcessor.process(user, coupon, request.price());
PaymentLog log = newLog(
manager,
PaymentLogType.TYPE_X,
request.price(),
request.message()
);
log.setUser(user);
log.setCoupon(coupon);
logRepository.save(log);
}
private PaymentLog newLog(
User manager,
PaymentLogType type,
BigDecimal price,
String message
) {
PaymentLog log = new PaymentLog();
log.setManager(manager);
log.setType(type);
log.setPrice(price);
log.setMessage(message);
log.setDate(LocalDateTime.now());
return log;
}
}
示例省略了 import,并假设 PaymentLog.price 已改为 BigDecimal。CurrentUserService 和 PaymentXProcessor 是应用自行定义的组件:前者解析当前登录用户并校验权限,后者负责原来的具体支付逻辑。
processY() 可以采用相同结构:加载 InventoryItem,完成业务处理后调用 log.setItem(item),公共日志字段仍由 newLog() 创建。
如果实体查询必须留在具体支付方法内部,可以让该方法返回处理结果,包含日志需要的实体和实际金额。外层直接读取结果,无须再次查询。
同一持久化上下文中,重复调用 findById() 可能命中一级缓存,未必每次都会额外执行 SQL。不过,重复加载和检查仍会增加代码分支,让人难以看出当前流程中已经有了这些对象。
getReferenceById() 适合只需建立关联、无须立即读取对象的场景。它不保证在调用时就验证记录是否存在,因此不能直接替代这里需要及时报告“用户或优惠券不存在”的查询。
事务应覆盖业务修改和成功日志
如果支付处理只修改同一个事务资源中的数据库数据,可以把事务边界放在公开的 service 入口,让业务修改和成功日志一起提交:
- 支付处理失败时,不留下成功日志。
- 日志持久化失败时,业务修改也回滚。
内部处理需要参与同一个事务,并继续向外抛出异常。如果捕获失败后仍正常返回,外层可能继续写入成功日志。
事务配置还需注意以下几点:
- Spring 常规事务配置默认对
RuntimeException和Error回滚;受检异常需要按业务配置回滚规则。 - 不同包中存在同名的
AccessDeniedException。使用 Spring Security 的异常时,应明确导入org.springframework.security.access.AccessDeniedException。 - 常规代理模式下,同一个对象内部调用另一个带
@Transactional的方法,不会再次经过事务代理。把事务放在由 controller 通过 Spring Bean 调用的公开 service 方法上,调用关系更清楚。
如果支付处理会调用外部支付平台,本地数据库事务无法撤销已经成功的外部扣款。此时还需要管理支付状态、处理幂等,并在失败后重试或补偿。
失败尝试日志需要单独处理。与支付业务处于同一事务中的日志会一起回滚,无法满足“无论成功失败都保留”的审计要求。
PaymentLog 的可空关联可以保留
日志表可以通过 type 区分记录,并允许 item、coupon、user 中的部分关联为空。请求 DTO 的结构不必与日志表完全一致。
每种类型的字段组合必须有效。例如,TYPE_X 如果要求关联用户和优惠券,业务层就应保证二者存在;必要时,可以再用数据库 CHECK 约束保护这类组合规则。
原代码还有几处需要先修正:
TYPE_Z检查了userId,却用它查询itemRepository并设置item,与字段说明不一致。应先确定该类型实际需要哪些关联。- 保存日志前,必须设置所有必填字段,包括
manager、type和price。 managerId不应直接决定日志中的操作者。通常应从服务端认证上下文获取当前用户;如果允许代操作,还需要校验相应授权。- 如果金额由商品或优惠规则决定,应使用服务端计算结果记录支付日志。
@DecimalMin只能验证数值范围,不能证明客户端金额正确。
枚举映射也需要核对。columnDefinition = "payment_log_type" 本身不能完整说明 Java 枚举如何持久化。使用普通字符串列时,可以显式采用 @Enumerated(EnumType.STRING);使用 PostgreSQL 原生枚举类型时,则需要根据实际 JPA 实现配置映射。
如果暂时保留通用 DTO,可以通过类级自定义校验约束集中检查 type 与字段组合,同时明确如何处理该类型不接受的多余字段。Validation Groups 可以表达不同的约束集合,但不会仅凭请求中的 type 自动选择校验组。当前各类型的请求字段已经明显不同,使用专用 DTO 更容易阅读和维护。
备注:内容仅供参考。
