fix(service-appointment): 状态冲突/越权改为 4xx,修并发下 500 与弱断言漏判

CI E2E (live stack) 在 main@63c18d8 上失败:test_appointment_concurrent_safety
并发「用户取消」与「运营确认」得到 [200, 500]。根因是 ServiceAppointmentService
四处状态/权限守卫抛裸 RuntimeException,GlobalExceptionHandler 无对应映射,
只能兜底成 HTTP 500 —— 「预约不存在」「状态不允许」「越权」被报成服务端故障。

改动:
- ServiceAppointmentService:四处守卫改用 BusinessException + ApiResponseCode
  (不存在→404 NOT_FOUND,越权→403 FORBIDDEN,状态冲突→409 REDUNDANT_OPERATION)。
- ReferralService.confirmReferral:同一家族的四条守卫同样收敛(预约/报名的
  不存在、越权、非转介类型)。
- tests/e2e/service_appointment_test.py:把 3 处 `!= 200` 的弱断言收紧为 4xx。
  弱断言会把 500 当成「越权被拒」放行 —— 这正是该缺陷长期未被发现的原因,
  此前只有并发用例的 `< 500` 断言咬了它一口。
- ServiceAppointmentOwnerGuardTest:新增「终态不可再确认/取消」「预约不存在」
  用例,并把越权断言从 RuntimeException 收紧到具体业务码(403)。

验证:
- 定向后端测试 35 passed(ServiceAppointment* / MatchmakerReferralPhase13Test)。
- 本机真实栈(重建 backend 镜像):service_appointment_test.py 8/8、全量 E2E 65 passed。
- 负向注入(把守卫改回裸 RuntimeException):2 例精准变红,工作区哈希前后一致。
This commit is contained in:
WorkBuddy
2026-09-22 19:36:59 +08:00
parent 63c18d89c2
commit 2162612892
4 changed files with 124 additions and 20 deletions
@@ -8,6 +8,8 @@ import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -126,12 +128,26 @@ public class ReferralService {
return result;
}
/**
* 用户确认转介(预约 / 活动报名)。
*
* <p>拒绝路径统一走 {@link BusinessException}(带语义化业务码 → 4xx),
* 不再用裸 {@code RuntimeException}:后者在 {@code GlobalExceptionHandler}
* 只会命中兜底分支变成 HTTP 500,「无权操作」被报成服务端故障。
*/
@Transactional
public Map<String, Object> confirmReferral(String userId, String targetId, String targetType) {
if ("service_station".equals(targetType)) {
ServiceAppointment appt = appointmentMapper.selectById(targetId);
if (appt == null || !userId.equals(appt.getUserId())) throw new RuntimeException("预约不存在或无权操作");
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(appt.getSource())) throw new RuntimeException("该预约不是转介类型");
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (!userId.equals(appt.getUserId())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权确认他人的转介预约");
}
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(appt.getSource())) {
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "该预约不是转介类型");
}
appt.setUserConfirmedAt(Instant.now());
appt.setStatus(Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION);
appt.setUpdatedAt(Instant.now());
@@ -144,8 +160,15 @@ public class ReferralService {
return result;
} else if ("activity".equals(targetType)) {
ActivityRegistration reg = activityRegistrationMapper.selectById(targetId);
if (reg == null || !userId.equals(reg.getUserId())) throw new RuntimeException("报名不存在或无权操作");
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(reg.getSource())) throw new RuntimeException("该报名不是转介类型");
if (reg == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名不存在");
}
if (!userId.equals(reg.getUserId())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权确认他人的转介报名");
}
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(reg.getSource())) {
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "该报名不是转介类型");
}
reg.setUserConfirmedAt(Instant.now());
reg.setUpdatedAt(Instant.now());
activityRegistrationMapper.updateById(reg);
@@ -8,6 +8,8 @@ import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentM
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -134,12 +136,21 @@ public class ServiceAppointmentService {
/**
* 确认预约(工作人员)
*
* <p>状态闸门用 {@link BusinessException} + 语义化业务码,**不用裸 RuntimeException**:
* 后者落进 {@code GlobalExceptionHandler#handleUnknownException} 变成 HTTP 500。
* E2E {@code test_appointment_concurrent_safety} 让「用户取消」与「运营确认」并发,
* 落败的一侧必然读到已变更的状态 —— 那是**正常的并发状态冲突**,
* 却被旧代码报成 5xx,使调用方(含监控)把「别重试」误读为服务端故障。
*/
@Transactional
public ServiceAppointmentDTO confirmAppointment(String appointmentId, String operatorId) {
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
if (appt == null || !"submitted".equals(appt.getStatus())) {
throw new RuntimeException("预约不存在或状态不正确");
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (!"submitted".equals(appt.getStatus())) {
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许确认");
}
assertOperatorScope(appointmentId, operatorId);
appt.setStatus(Constants.APPOINTMENT_STATUS_CONFIRMED);
@@ -162,8 +173,11 @@ public class ServiceAppointmentService {
@Transactional
public ServiceAppointmentDTO completeAppointment(String appointmentId, String operatorId) {
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
if (appt == null || !"confirmed".equals(appt.getStatus())) {
throw new RuntimeException("预约状态不正确");
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (!"confirmed".equals(appt.getStatus())) {
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许完成");
}
assertOperatorScope(appointmentId, operatorId);
appt.setStatus(Constants.APPOINTMENT_STATUS_COMPLETED);
@@ -178,8 +192,11 @@ public class ServiceAppointmentService {
@Transactional
public ServiceAppointmentDTO cancelAppointment(String appointmentId, String userId, String reason) {
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
if (appt == null || !userId.equals(appt.getUserId())) {
throw new RuntimeException("预约不存在或无权操作");
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (!userId.equals(appt.getUserId())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权操作该预约");
}
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
appt.setCancelReason(reason);
@@ -191,9 +208,12 @@ public class ServiceAppointmentService {
@Transactional
public ServiceAppointmentDTO cancelAppointmentByOperator(String appointmentId, String operatorId, String reason) {
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
if (appt == null || Constants.APPOINTMENT_STATUS_COMPLETED.equals(appt.getStatus())
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (Constants.APPOINTMENT_STATUS_COMPLETED.equals(appt.getStatus())
|| Constants.APPOINTMENT_STATUS_CANCELLED.equals(appt.getStatus())) {
throw new RuntimeException("预约不存在或状态不允许取消");
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许取消");
}
assertOperatorScope(appointmentId, operatorId);
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);