diff --git a/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java b/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java index 0a4985a..51f73b3 100644 --- a/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java +++ b/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java @@ -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; } + /** + * 用户确认转介(预约 / 活动报名)。 + * + *
拒绝路径统一走 {@link BusinessException}(带语义化业务码 → 4xx),
+ * 不再用裸 {@code RuntimeException}:后者在 {@code GlobalExceptionHandler}
+ * 只会命中兜底分支变成 HTTP 500,「无权操作」被报成服务端故障。
+ */
@Transactional
public Map 状态闸门用 {@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);
diff --git a/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentOwnerGuardTest.java b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentOwnerGuardTest.java
index 43b016d..8978f9c 100644
--- a/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentOwnerGuardTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentOwnerGuardTest.java
@@ -8,6 +8,8 @@ import com.danyang.match.platform.service_appointment.service.ServiceAppointment
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.shared.constant.Constants;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -174,13 +176,65 @@ class ServiceAppointmentOwnerGuardTest {
@Test
void userCannotCancelOthersAppointment() {
ServiceAppointment a = insert(USER_B, Constants.APPOINTMENT_STATUS_SUBMITTED);
- assertThrows(RuntimeException.class,
+ // 必须断言**具体业务码**,不能只断言 RuntimeException:
+ // 裸 RuntimeException 在 GlobalExceptionHandler 里会兜底成 HTTP 500,
+ // 于是「越权被拒」在调用方与监控眼里变成服务端故障(E2E 曾因此漏判)。
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
() -> appointmentService.cancelAppointment(a.getId(), USER_A, "越权尝试"),
"取消他人预约必须被拒绝");
+ assertEquals(ApiResponseCode.FORBIDDEN, ex.getCode(), "越权取消必须是 403 而非 500");
assertEquals(Constants.APPOINTMENT_STATUS_SUBMITTED,
appointmentMapper.selectById(a.getId()).getStatus(), "越权取消不得改变状态");
}
+ @Test
+ void cancellingUnknownAppointmentIsNotFound() {
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
+ () -> appointmentService.cancelAppointment(
+ UUID.randomUUID().toString(), USER_A, "不存在的预约"));
+ assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode(), "预约不存在必须是 404 而非 500");
+ }
+
+ /**
+ * 并发取消/确认的落败方必然读到已变更状态 —— 那是**正常的状态冲突**,必须是 409。
+ *
+ * E2E {@code test_appointment_concurrent_safety} 断言「并发不得出现 5xx」,
+ * CI 上确实咬出过一次 `[200, 500]`:确认请求读到 cancelled 后抛裸 RuntimeException。
+ * 此处把该路径在单测层锁死,避免只有并发时序才暴露。
+ */
+ @Test
+ void confirmOnTerminalStatusIsConflictNotServerError() {
+ ServiceAppointment cancelled = insert(USER_A, Constants.APPOINTMENT_STATUS_CANCELLED);
+
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
+ () -> appointmentService.confirmAppointment(cancelled.getId(), OPERATOR),
+ "已取消的预约不得再被确认");
+
+ assertEquals(ApiResponseCode.REDUNDANT_OPERATION, ex.getCode(),
+ "状态冲突必须是 409(可判定、不可重试),不能是 500");
+ assertEquals(Constants.APPOINTMENT_STATUS_CANCELLED,
+ appointmentMapper.selectById(cancelled.getId()).getStatus(),
+ "被拒绝的确认不得改动状态");
+ }
+
+ @Test
+ void operatorCancelOnCompletedStatusIsConflictNotServerError() {
+ ServiceAppointment completed = insert(USER_A, Constants.APPOINTMENT_STATUS_COMPLETED);
+
+ GlobalExceptionHandler.BusinessException ex = assertThrows(
+ GlobalExceptionHandler.BusinessException.class,
+ () -> appointmentService.cancelAppointmentByOperator(completed.getId(), OPERATOR, "试图取消"),
+ "已完成预约不得被运营取消");
+
+ assertEquals(ApiResponseCode.REDUNDANT_OPERATION, ex.getCode(), "状态冲突必须是 409 而非 500");
+ assertEquals(Constants.APPOINTMENT_STATUS_COMPLETED,
+ appointmentMapper.selectById(completed.getId()).getStatus(),
+ "被拒绝的取消不得改动状态");
+ }
+
@Test
void userListOnlyReturnsOwnAppointments() {
insert(USER_A, Constants.APPOINTMENT_STATUS_SUBMITTED);
diff --git a/tests/e2e/service_appointment_test.py b/tests/e2e/service_appointment_test.py
index a052b48..4971489 100644
--- a/tests/e2e/service_appointment_test.py
+++ b/tests/e2e/service_appointment_test.py
@@ -296,22 +296,27 @@ def test_cancel_before_confirmation(api, new_user, station_id):
def test_owner_scope_on_detail_and_cancel(api, new_user, station_id):
- """归属隔离(V1.1 计划第 488 行):详情与取消只能操作本人预约。"""
+ """归属隔离(V1.1 计划第 488 行):详情与取消只能操作本人预约。
+
+ 断言必须是 **4xx**,不能只写 `!= 200`:后者会把 500 当成「越权被拒」放行。
+ 此类服务端错误曾长期被这两处弱断言掩盖(`cancelAppointment` 抛裸
+ RuntimeException → 兜底 500),只有并发用例的 `< 500` 断言才咬了它一口。
+ """
owner = new_user()
stranger = new_user()
created = _create(api, owner["token"], station_id)
detail = api.call("GET", f"/service-appointments/{created['id']}", token=stranger["token"])
- assert detail.status_code != 200, (
- f"他人不得读取我的预约详情(实际 HTTP {detail.status_code})"
+ assert 400 <= detail.status_code < 500, (
+ f"他人读取我的预约详情应被 4xx 拒绝(实际 HTTP {detail.status_code})"
)
cancel = api.call(
"POST", f"/service-appointments/{created['id']}/cancel",
token=stranger["token"], json={"reason": "越权取消"},
)
- assert cancel.status_code != 200, (
- f"他人不得取消我的预约(实际 HTTP {cancel.status_code})"
+ assert 400 <= cancel.status_code < 500, (
+ f"他人取消我的预约应被 4xx 拒绝(实际 HTTP {cancel.status_code})"
)
still = _by_id(api, owner["token"], created["id"])
@@ -325,6 +330,8 @@ def test_operator_cannot_cancel_completed(api, new_user, operator_token, station
这是 `cancelAppointmentByOperator` 明确实现的规则(拒绝 completed / cancelled),
与用户侧不校验状态形成对照 —— 仅断言**已实现**的那一侧。
+
+ 断言 4xx 而非 `!= 200`:状态冲突应当是 409,不是 500。
"""
user = new_user()
op_token = operator_token(STATION_OPERATOR)
@@ -338,8 +345,8 @@ def test_operator_cannot_cancel_completed(api, new_user, operator_token, station
"POST", f"/service-appointments/operator/{created['id']}/cancel",
token=op_token, json={"reason": "试图取消已完成预约"},
)
- assert resp.status_code != 200, (
- f"已完成预约不得被运营取消(实际 HTTP {resp.status_code}: {resp.text[:200]})"
+ assert 400 <= resp.status_code < 500, (
+ f"已完成预约不得被运营取消,且必须是 4xx(实际 HTTP {resp.status_code}: {resp.text[:200]})"
)
final = _by_id(api, user["token"], created["id"])