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 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); diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceAppointmentService.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceAppointmentService.java index 3d6ef6b..73d6c84 100644 --- a/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceAppointmentService.java +++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/service/ServiceAppointmentService.java @@ -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 { /** * 确认预约(工作人员) + * + *

状态闸门用 {@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"])