diff --git a/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java b/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java index eb0ca22..51ed429 100644 --- a/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java +++ b/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java @@ -39,6 +39,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.*; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; /** * 账号生命周期服务 @@ -97,7 +99,7 @@ public class AccountLifecycleService { public Map withdrawDatingService(String userId, String operatorId) { User user = userMapper.selectById(userId); if (user == null) { - throw new RuntimeException("用户不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"); } // 检查是否有进行中的Match(不能直接退出) @@ -135,7 +137,7 @@ public class AccountLifecycleService { public Map resumeDatingService(String userId) { User user = userMapper.selectById(userId); if (user == null) { - throw new RuntimeException("用户不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"); } user.setRecommendationEnabled(true); user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING); @@ -162,7 +164,7 @@ public class AccountLifecycleService { public Map checkCanWithdraw(String userId) { User user = userMapper.selectById(userId); if (user == null) { - throw new RuntimeException("用户不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"); } Map blockers = new HashMap<>(); @@ -247,7 +249,7 @@ public class AccountLifecycleService { public Map closeAccount(String userId, String operatorId, String reason) { User user = userMapper.selectById(userId); if (user == null) { - throw new RuntimeException("用户不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"); } // 1. 注销前检查(信息性,不阻断):让 GET /can-withdraw 能把待处理业务展示给用户/运营。 @@ -332,7 +334,7 @@ public class AccountLifecycleService { public Map withdrawConsent(String userId, String consentType) { ConsentRecord record = consentRecordMapper.findByUserAndType(userId, consentType).orElse(null); if (record == null) { - throw new RuntimeException("未找到该同意记录"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该同意记录"); } record.setAgreed(false); consentRecordMapper.updateById(record); diff --git a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityInterestService.java b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityInterestService.java index 58bbc61..5a97f52 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityInterestService.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityInterestService.java @@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; /** * 活动互选服务 @@ -59,17 +61,17 @@ public class ActivityInterestService { // 1. 验证活动已结束 Activity activity = activityMapper.selectById(activityId); if (activity == null) { - throw new RuntimeException("活动不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在"); } if (!Constants.ACTIVITY_STATUS_ENDED.equals(activity.getStatus())) { - throw new RuntimeException("活动尚未结束,暂不支持反馈"); + throw new BusinessException(ApiResponseCode.CONFLICT, "活动尚未结束,暂不支持反馈"); } // 2. 验证用户已报名且通过审核 ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) .orElseThrow(() -> new RuntimeException("您未报名该活动")); if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { - throw new RuntimeException("报名未通过审核,无法提交反馈"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈"); } // 3. 过滤有效的目标用户(必须是同活动的approved用户,排除自己) diff --git a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java index e91152e..470ab8d 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java @@ -30,6 +30,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; @Service @RequiredArgsConstructor @@ -110,9 +112,9 @@ public class ActivityService { @Transactional public Activity publishActivity(String activityId, String operatorId) { Activity activity = activityMapper.selectById(activityId); - if (activity == null) throw new RuntimeException("活动不存在"); + if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在"); if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus())) { - throw new RuntimeException("当前状态不允许发布"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许发布"); } activity.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN); if (activity.getRegistrationStartAt() == null) { @@ -129,10 +131,10 @@ public class ActivityService { @Transactional public Activity cancelActivity(String activityId, String operatorId) { Activity activity = activityMapper.selectById(activityId); - if (activity == null) throw new RuntimeException("活动不存在"); + if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在"); if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus()) && !Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) { - throw new RuntimeException("当前状态不允许取消"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许取消"); } activity.setStatus(Constants.ACTIVITY_STATUS_CANCELLED); activityMapper.updateById(activity); @@ -220,9 +222,9 @@ public class ActivityService { String emergencyContactName, String emergencyContactPhoneEnc) { Activity activity = activityMapper.selectById(activityId); - if (activity == null) throw new RuntimeException("活动不存在"); + if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在"); if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) { - throw new RuntimeException("活动未开放报名或已结束"); + throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开放报名或已结束"); } // Phase 15 修复:报名窗口此前完全不校验 —— registration_start_at / registration_end_at // 形同虚设,活动一旦发布就永远可报名(运营设的截止时间无效)。 @@ -237,7 +239,7 @@ public class ActivityService { if (existing.isPresent() && !Constants.REGISTRATION_STATUS_REJECTED.equals(existing.get().getStatus()) && !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.get().getStatus())) { - throw new RuntimeException("已经报名过该活动"); + throw new BusinessException(ApiResponseCode.ACTIVITY_ALREADY_REGISTERED, "已经报名过该活动"); } // V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同) @@ -265,7 +267,7 @@ public class ActivityService { if (registrationMapper.reuseIfCapacityAvailable( old.getId(), activityId, status, emergencyContactName, emergencyContactPhoneEnc, gender) == 0) { - throw new RuntimeException("活动名额已满"); + throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满"); } } catch (RuntimeException e) { if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode); @@ -294,7 +296,7 @@ public class ActivityService { if (registrationMapper.insertIfCapacityAvailable( newId, activityId, userId, status, emergencyContactName, emergencyContactPhoneEnc, gender) == 0) { - throw new RuntimeException("活动名额已满"); + throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满"); } } catch (RuntimeException e) { if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode); @@ -334,9 +336,9 @@ public class ActivityService { @Transactional public ActivityRegistration approveRegistration(String registrationId, String operatorId) { ActivityRegistration registration = registrationMapper.selectById(registrationId); - if (registration == null) throw new RuntimeException("报名记录不存在"); + if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在"); if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) { - throw new RuntimeException("当前状态不允许审核通过"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核通过"); } // Phase 15(计划项③):服务站操作员只能审核被指派给自己的活动 @@ -355,7 +357,7 @@ public class ActivityService { } if (affected == 0) { if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode); - throw new RuntimeException("活动名额已满或状态已变更,无法通过审核"); + throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满或状态已变更,无法通过审核"); } // 回读事务内最新状态(条件更新不回填实体) registration = registrationMapper.selectById(registrationId); @@ -376,16 +378,16 @@ public class ActivityService { @Transactional public ActivityRegistration rejectRegistration(String registrationId, String operatorId, String reason) { ActivityRegistration registration = registrationMapper.selectById(registrationId); - if (registration == null) throw new RuntimeException("报名记录不存在"); + if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在"); if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) { - throw new RuntimeException("当前状态不允许审核"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核"); } assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动"); // Phase 15 修复:原实现为「读 pending → 全量 updateById」,两人同时审核时 // 一个通过、一个拒绝会互相覆盖。改为条件更新,后到者 affectedRows=0。 if (registrationMapper.rejectIfPending(registrationId, operatorId, reason) == 0) { - throw new RuntimeException("该报名状态已被他人变更,无法拒绝"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法拒绝"); } registration = registrationMapper.selectById(registrationId); @@ -408,11 +410,11 @@ public class ActivityService { .orElseThrow(() -> new RuntimeException("没有找到报名记录")); if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) && !Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { - throw new RuntimeException("当前状态不能取消"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消"); } // 原子取消:状态已被他人变更(如已被拒绝)时 affectedRows=0,不得重复回补名额 if (registrationMapper.cancelIfActive(registration.getId()) == 0) { - throw new RuntimeException("该报名状态已被他人变更,无法取消"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法取消"); } // 已通过的名额释放(总名额/性别名额由 SQL 实时统计自动释放;分组计数需显式回退) if (Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { @@ -721,7 +723,7 @@ public class ActivityService { log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage()); } if (!verified) { - throw new RuntimeException("该活动需完成实名认证后方可报名"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需完成实名认证后方可报名"); } } @@ -733,7 +735,7 @@ public class ActivityService { log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage()); } if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) { - throw new RuntimeException("该活动需通过本地资格核验后方可报名"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需通过本地资格核验后方可报名"); } } } @@ -746,10 +748,10 @@ public class ActivityService { if (birth == null) return; // 资料不全时不阻断报名 int age = java.time.Period.between(birth, java.time.LocalDate.now()).getYears(); if (activity.getMinAge() != null && age < activity.getMinAge()) { - throw new RuntimeException("报名年龄未满 " + activity.getMinAge() + " 周岁"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄未满 " + activity.getMinAge() + " 周岁"); } if (activity.getMaxAge() != null && age > activity.getMaxAge()) { - throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄超出 " + activity.getMaxAge() + " 周岁上限"); } } @@ -782,10 +784,10 @@ public class ActivityService { private void assertWithinRegistrationWindow(Activity activity) { Instant now = Instant.now(); if (activity.getRegistrationStartAt() != null && now.isBefore(activity.getRegistrationStartAt())) { - throw new RuntimeException("活动报名尚未开始"); + throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名尚未开始"); } if (activity.getRegistrationEndAt() != null && now.isAfter(activity.getRegistrationEndAt())) { - throw new RuntimeException("活动报名已截止"); + throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名已截止"); } } @@ -835,7 +837,7 @@ public class ActivityService { String groupCode = resolveCapacityGroupCode(activityId, gender); if (groupCode == null) return null; if (capacityGroupMapper.incrementIfAvailable(activityId, groupCode) == 0) { - throw new RuntimeException("该分组名额已满"); + throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "该分组名额已满"); } return groupCode; } diff --git a/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java b/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java index 094f705..ca7d9f2 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java @@ -26,6 +26,9 @@ import java.util.Base64; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import com.danyang.match.platform.shared.exception.ServerErrorException; /** * 签到服务 @@ -64,18 +67,18 @@ public class CheckinService { .orElseThrow(() -> new RuntimeException("您没有报名该活动")); if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { - throw new RuntimeException("报名尚未审核通过,无法签到"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到"); } if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) { - throw new RuntimeException("您已经签到过了"); + throw new BusinessException(ApiResponseCode.ALREADY_CHECKED_IN, "您已经签到过了"); } Activity activity = requireCheckableActivity(activityId); Instant now = Instant.now(); if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) { - throw new RuntimeException("活动未开始或已结束,无法签到"); + throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开始或已结束,无法签到"); } String payload = userId + ":" + activityId + ":" + now.toEpochMilli(); @@ -290,7 +293,7 @@ public class CheckinService { byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } catch (Exception e) { - throw new RuntimeException("签名生成失败", e); + throw new ServerErrorException("签名生成失败", e); } } diff --git a/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java b/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java index fdf06b4..df95fa2 100644 --- a/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java +++ b/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java @@ -21,7 +21,9 @@ import com.danyang.match.platform.service_appointment.entity.ServiceAppointment; import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper; import com.danyang.match.platform.service_program.entity.ServiceProgramMember; import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper; -import com.danyang.match.platform.shared.exception.BusinessException; +// 必须是 GlobalExceptionHandler.BusinessException —— shared.exception.BusinessException +// 此前是另一个未映射的类,抛出来直落 500(days 越界本该 400)。见该类 javadoc 的修复记录。 +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.user.entity.User; import com.danyang.match.platform.user.mapper.UserMapper; @@ -252,7 +254,7 @@ public class AnalyticsService { public List> getDailySummary(int days) { if (days < 1 || days > 365) { - throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间"); + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "days 必须在 1~365 之间"); } List> summary = new ArrayList<>(); LocalDate today = LocalDate.now(); diff --git a/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java b/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java index b08ed13..4bc9a39 100644 --- a/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java +++ b/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java @@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; /** * 申诉服务 @@ -97,29 +99,29 @@ public class AppealService { public Map submitAppeal(String userId, AppealSubmitDTO dto) { // FeatureFlag 检查 if (!featureFlagConfig.isAppealEnabled()) { - throw new RuntimeException("申诉功能暂未开放"); + throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "申诉功能暂未开放"); } if (dto == null || dto.getRelatedActionId() == null || dto.getRelatedActionId().isBlank()) { - throw new RuntimeException("缺少关联的处置记录"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少关联的处置记录"); } // 验证关联的 risk_action 存在且仍有效 RiskAction targetAction = riskActionMapper.selectById(dto.getRelatedActionId()); if (targetAction == null) { - throw new RuntimeException("关联的处置记录不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联的处置记录不存在"); } if (!Constants.RISK_ACTION_STATUS_ACTIVE.equals(targetAction.getStatus())) { - throw new RuntimeException("该处置已失效,无法申诉"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该处置已失效,无法申诉"); } if (!userId.equals(targetAction.getUserId())) { - throw new RuntimeException("无权对此处置提交申诉"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权对此处置提交申诉"); } // 必须是 ban 或 suspend 类型 if (!Constants.RISK_ACTION_BAN.equals(targetAction.getActionType()) && !Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(targetAction.getActionType())) { - throw new RuntimeException("当前处置类型不支持申诉"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "当前处置类型不支持申诉"); } // Phase 18:校验 appealType 白名单(生产 chk_appeal_type),并必须与处置类型一致。 @@ -132,16 +134,16 @@ public class AppealService { } if (!Constants.APPEAL_TYPE_BAN.equals(appealType) && !Constants.APPEAL_TYPE_SUSPEND.equals(appealType)) { - throw new RuntimeException("无效的申诉类型"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉类型"); } if (!expectedType.equals(appealType)) { - throw new RuntimeException("申诉类型与处置类型不匹配"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "申诉类型与处置类型不匹配"); } // 防重:同一用户对同一 action 只能有一个 pending/reviewing 申诉 Optional existing = appealMapper.findActiveByUserAndAction(userId, dto.getRelatedActionId()); if (existing.isPresent()) { - throw new RuntimeException("您已就此处置提交过申诉,请等待处理结果"); + throw new BusinessException(ApiResponseCode.CONFLICT, "您已就此处置提交过申诉,请等待处理结果"); } Appeal appeal = new Appeal(); @@ -190,7 +192,7 @@ public class AppealService { public AppealItemDTO getAppealDetail(String appealId) { Appeal appeal = appealMapper.selectById(appealId); if (appeal == null || appeal.getDeletedAt() != null) { - throw new RuntimeException("申诉不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在"); } return toDTO(appeal); } @@ -202,10 +204,10 @@ public class AppealService { public Map processAppeal(String appealId, AppealProcessDTO dto, String operatorId) { Appeal appeal = appealMapper.selectById(appealId); if (appeal == null || appeal.getDeletedAt() != null) { - throw new RuntimeException("申诉不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在"); } if (dto == null || dto.getDecision() == null) { - throw new RuntimeException("无效的申诉决策"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策"); } // Phase 18:原子「认领」——只有把申诉从可处理态(pending/reviewing/escalated) @@ -213,7 +215,7 @@ public class AppealService { // 同一申诉时,只有一个赢家能继续,另一个直接失败,避免 approve/reject 相互覆盖。 int claimed = appealMapper.markReviewingIfProcessable(appealId); if (claimed != 1) { - throw new RuntimeException("该申诉已处理完毕"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该申诉已处理完毕"); } // 回读最新状态(status 已为 reviewing) @@ -224,7 +226,7 @@ public class AppealService { case "approve" -> approveAppeal(fresh, dto.getDecisionNote(), operatorId); case "reject" -> rejectAppeal(fresh, dto.getDecisionNote(), operatorId); case "escalate" -> escalateAppeal(fresh, dto.getDecisionNote(), operatorId); - default -> throw new RuntimeException("无效的申诉决策"); + default -> throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策"); } saveAuditLog(operatorId, "PROCESS_APPEAL", "appeal", appealId, null, null); diff --git a/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java b/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java index 76c63b1..be8a965 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java @@ -1,6 +1,8 @@ package com.danyang.match.platform.auth.controller; import com.danyang.match.platform.auth.service.AuthService; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -26,8 +28,7 @@ public class AuthController { public ApiResponse> wechatLogin(@RequestBody Map body) { String code = body.get("code"); if (code == null || code.isBlank()) { - throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException( - com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "code 不能为空"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "code 不能为空"); } return ApiResponse.ok(authService.wechatLogin(code)); } @@ -44,8 +45,7 @@ public class AuthController { @RequestBody Map body) { String phone = body.get("phone"); if (phone == null || phone.isBlank()) { - throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException( - com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "手机号不能为空"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空"); } authService.bindPhone(principal.id(), phone); return ApiResponse.ok(); @@ -54,17 +54,19 @@ public class AuthController { /** * 刷新 Token * POST /api/v1/auth/refresh - * Body: { "userId": "...", "refreshToken": "..." } + * Body: { "refreshToken": "..." },userId 可选 + * + *

userId 不再是必填:refresh token 的 subject 就是 userId,令牌本身自证身份。 + * 此前强制要求客户端重复传 userId,漏传即 400 —— 小程序 2026-09-22 的 + * 首页 401 事故正是被这个 400 放大的。显式传入时仍会被校验与令牌主体一致。 */ @PostMapping("/refresh") public ApiResponse> refreshToken(@RequestBody Map body) { - String userId = body.get("userId"); String refreshToken = body.get("refreshToken"); - if (userId == null || refreshToken == null) { - throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException( - com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "参数缺失"); + if (refreshToken == null || refreshToken.isBlank()) { + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "refreshToken 不能为空"); } - return ApiResponse.ok(authService.refreshToken(userId, refreshToken)); + return ApiResponse.ok(authService.refreshToken(body.get("userId"), refreshToken)); } /** diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java index d271f32..e0be8c2 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java @@ -7,6 +7,9 @@ import com.danyang.match.platform.auth.provider.WechatLoginProvider; import com.danyang.match.platform.auth.token.JwtTokenService; import com.danyang.match.platform.capability.service.CapabilityService; 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.exception.GlobalExceptionHandler.UnauthorizedException; import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; import com.danyang.match.platform.profile.entity.UserProfile; @@ -58,7 +61,7 @@ public class AuthService { String unionid = session.get("unionid"); if (openid == null || openid.isBlank()) { - throw new RuntimeException("微信登录失败:无法获取 openid"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid"); } // 查找或创建用户 @@ -113,7 +116,7 @@ public class AuthService { // 检查手机号是否已被其他用户占用 userMapper.findByPhone(phoneCode).ifPresent(existing -> { if (!existing.getId().equals(userId)) { - throw new RuntimeException("该手机号已被其他账号绑定"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该手机号已被其他账号绑定"); } }); @@ -126,17 +129,43 @@ public class AuthService { /** * 刷新 Token + * + *

2026-09-22 修复三处缺陷: + *

    + *
  1. 验签用错了密钥:refresh token 由 refresh 密钥签发,此前却调 + * {@code validateToken}(用 access 密钥验签)→ 必然失败, + * 于是「刷新」这条路在修好前端字段名之后仍然走不通。改用 + * {@link JwtTokenService#validateRefreshToken}。
  2. + *
  3. 失败语义是 500:客户端据此判断为服务端故障并重试,实际应回 401 + * 让它停止重试、走重新登录。改抛 {@link UnauthorizedException}。
  4. + *
  5. userId 被强制要求:refresh token 的 subject 本身就是 userId, + * 让客户端重复传一个它已持有凭据里已包含的信息,只会制造 + * 「漏传 → 400」这类无意义失败。改为**以令牌主体为准**, + * 显式传入时仅用于一致性校验。
  6. + *
*/ public Map refreshToken(String userId, String refreshToken) { - // 验证 Redis 中存储的 refresh token - String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId); - if (storedToken == null || !storedToken.equals(refreshToken)) { - throw new RuntimeException("Refresh Token 无效或已过期"); + if (refreshToken == null || refreshToken.isBlank()) { + throw new UnauthorizedException("Refresh Token 无效或已过期"); } - // 验证 JWT - if (!jwtTokenService.validateToken(refreshToken)) { - throw new RuntimeException("Refresh Token 无效"); + // 1. 令牌自证:签名有效(refresh 密钥)、未过期、type=refresh、未被撤销 + if (!jwtTokenService.validateRefreshToken(refreshToken)) { + throw new UnauthorizedException("Refresh Token 无效或已过期"); + } + + // 2. 用户身份以令牌主体为准;客户端显式传入时必须是同一个用户, + // 否则就是拿 A 的令牌去刷 B 的会话。 + String tokenUserId = jwtTokenService.getUserIdFromRefreshToken(refreshToken); + if (userId != null && !userId.isBlank() && !userId.equals(tokenUserId)) { + throw new UnauthorizedException("Refresh Token 与用户不匹配"); + } + userId = tokenUserId; + + // 3. 校验 Redis 中登记的令牌:登出或换发后,旧令牌立即失效(可撤销性) + String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId); + if (storedToken == null || !storedToken.equals(refreshToken)) { + throw new UnauthorizedException("Refresh Token 无效或已过期"); } // 签发新 Token @@ -151,6 +180,7 @@ public class AuthService { result.put("accessToken", newAccessToken); result.put("refreshToken", newRefreshToken); result.put("expiresIn", accessTokenExpireMs / 1000); + result.put("userId", userId); return result; } @@ -206,13 +236,13 @@ public class AuthService { private void checkAccountStatus(User user) { if (Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) { - throw new RuntimeException("账号已注销"); + throw new BusinessException(ApiResponseCode.ACCOUNT_CLOSED, "账号已注销"); } if (Constants.ACCOUNT_STATUS_BANNED.equals(user.getAccountStatus())) { - throw new RuntimeException("账号已被封禁"); + throw new BusinessException(ApiResponseCode.USER_BANNED, "账号已被封禁"); } if (Constants.ACCOUNT_STATUS_SUSPENDED.equals(user.getAccountStatus())) { - throw new RuntimeException("账号已暂停"); + throw new BusinessException(ApiResponseCode.USER_SUSPENDED, "账号已暂停"); } } diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java index ff11e43..0ba0996 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java @@ -165,11 +165,20 @@ public class OperatorAuthService { /** * 刷新 Operator Token + * + *

与 {@link AuthService#refreshToken} 对齐两点(2026-09-22): + * 令牌先自证(签名/有效期/type,用 refresh 密钥),再比对 Redis 登记值; + * 失败回 401 而非 500 —— 否则客户端会把「该重新登录」误判为服务端故障并重试。 */ public Map refreshToken(String operatorId, String refreshToken) { + if (refreshToken == null || refreshToken.isBlank() + || !jwtTokenService.validateRefreshToken(refreshToken)) { + throw new UnauthorizedException("Refresh Token 无效"); + } + String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId); if (storedToken == null || !storedToken.equals(refreshToken)) { - throw new RuntimeException("Refresh Token 无效"); + throw new UnauthorizedException("Refresh Token 无效"); } Map operatorInfo = getMe(operatorId); diff --git a/backend/src/main/java/com/danyang/match/platform/auth/token/JwtTokenService.java b/backend/src/main/java/com/danyang/match/platform/auth/token/JwtTokenService.java index 8423a8f..01a25de 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/token/JwtTokenService.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/token/JwtTokenService.java @@ -10,11 +10,20 @@ import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.Map; +import java.util.UUID; @Component @RequiredArgsConstructor public class JwtTokenService { + /** + * Refresh Token 的用途声明值。 + * + *

校验时必须匹配,见 {@link #validateRefreshToken}:一旦运维把两把密钥配成同一个值, + * 签名校验就无法区分两类令牌,只有这个声明能阻止 access token 被当作 refresh token 续期。 + */ + public static final String TOKEN_TYPE_REFRESH = "refresh"; + private final TokenBlacklistService tokenBlacklistService; @Value("${jwt.access-token-secret}") @@ -46,6 +55,12 @@ public class JwtTokenService { JwtBuilder builder = Jwts.builder() .subject(userId) + // 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type, + // 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。 + // 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等, + // 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。 + // 2026-09-22 实测:50 次签发只产生 1 个不同令牌。 + .id(UUID.randomUUID().toString()) .issuedAt(now) .expiration(expiry) .claim("type", "access") @@ -67,9 +82,15 @@ public class JwtTokenService { return Jwts.builder() .subject(userId) + // 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type, + // 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。 + // 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等, + // 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。 + // 2026-09-22 实测:50 次签发只产生 1 个不同令牌。 + .id(UUID.randomUUID().toString()) .issuedAt(now) .expiration(expiry) - .claim("type", "refresh") + .claim("type", TOKEN_TYPE_REFRESH) .signWith(getRefreshKey()) .compact(); } @@ -83,6 +104,12 @@ public class JwtTokenService { JwtBuilder builder = Jwts.builder() .subject(operatorId) + // 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type, + // 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。 + // 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等, + // 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。 + // 2026-09-22 实测:50 次签发只产生 1 个不同令牌。 + .id(UUID.randomUUID().toString()) .issuedAt(now) .expiration(expiry) .claim("type", "operator_access") @@ -141,6 +168,52 @@ public class JwtTokenService { return parseToken(token).getSubject(); } + /** + * 验证并解析 **Refresh** Token。 + * + *

必须用 refresh 密钥验签:{@link #generateRefreshToken} 用 + * {@code jwt.refresh-token-secret} 签发,而 {@link #parseToken} 用的是 + * {@code jwt.access-token-secret}。两把密钥在 dev / test / production 的取值 + * 各不相同(见 application-*.yml),因此拿 {@code parseToken} 去验 refresh token + * 必然抛签名异常 —— 这正是 2026-09-22 定位到的「刷新永远失败 → 登录态被清 → + * 全站 401」的根因:前端此前修好了缺 userId 的 400,仍会在这一层必然失败。 + */ + public Claims parseRefreshToken(String token) { + return Jwts.parser() + .verifyWith(getRefreshKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * 校验 Refresh Token:签名有效、未过期、未被撤销,且 {@code type} 必须为 refresh。 + * + *

type 断言不是冗余:一旦运维把两把密钥配成同一个值(或都用了默认占位值), + * 签名校验就无法区分两类令牌,access token 会被当作 refresh token 换出新令牌, + * 使「短期凭证」获得「长期凭证」的续期能力。此处把令牌用途钉死在声明里。 + */ + public boolean validateRefreshToken(String token) { + if (token == null || token.isBlank()) { + return false; + } + try { + if (tokenBlacklistService.isBlacklisted(token)) { + return false; + } + return TOKEN_TYPE_REFRESH.equals(parseRefreshToken(token).get("type", String.class)); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** + * 从 Refresh Token 解析用户 ID(用于客户端未传 userId 时自证身份,见 AuthService#refreshToken)。 + */ + public String getUserIdFromRefreshToken(String token) { + return parseRefreshToken(token).getSubject(); + } + /** * 获取 Token 过期时间 */ diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java index 3657510..f1c8a64 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java @@ -24,6 +24,8 @@ import java.time.Instant; import java.time.ZoneId; import java.util.*; import java.util.stream.Collectors; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; @Service @RequiredArgsConstructor @@ -85,9 +87,9 @@ public class MatchmakerServiceService { @Transactional public MatchmakerServiceRecord assignService(String serviceId, String operatorId, String organizationId) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); - if (service == null) throw new RuntimeException("服务不存在"); + if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) { - throw new RuntimeException("当前状态不允许分配"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配"); } service.setAssignedOperatorId(operatorId); @@ -107,9 +109,9 @@ public class MatchmakerServiceService { @Transactional public MatchmakerServiceRecord claimService(String operatorId, String serviceId) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); - if (service == null) throw new RuntimeException("服务不存在"); + if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) { - throw new RuntimeException("当前状态不允许接单"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许接单"); } service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE); @@ -141,7 +143,7 @@ public class MatchmakerServiceService { public Map recommendPartner(String serviceId, String operatorId, String targetUserId, String reason) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); - if (service == null) throw new RuntimeException("服务不存在"); + if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); // Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐 if (!operatorId.equals(service.getAssignedOperatorId())) { @@ -206,10 +208,10 @@ public class MatchmakerServiceService { @Transactional public MatchmakerServiceRecord completeService(String serviceId) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); - if (service == null) throw new RuntimeException("服务不存在"); + if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) && !Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) { - throw new RuntimeException("当前状态不能完成"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成"); } service.setStatus(Constants.MATCHMAKER_STATUS_COMPLETED); @@ -227,7 +229,7 @@ public class MatchmakerServiceService { @Transactional public MatchmakerServiceRecord cancelService(String serviceId) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); - if (service == null) throw new RuntimeException("服务不存在"); + if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); service.setStatus(Constants.MATCHMAKER_STATUS_CANCELLED); serviceMapper.updateById(service); log.info("红娘服务取消: serviceId={}", serviceId); diff --git a/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorManagementService.java b/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorManagementService.java index 12bfe4c..418bc94 100644 --- a/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorManagementService.java +++ b/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorManagementService.java @@ -14,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.*; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; @Service @RequiredArgsConstructor @@ -73,7 +75,7 @@ public class OperatorManagementService { @Transactional public void disableOperator(String operatorId, String byOperatorId) { Operator operator = operatorMapper.selectById(operatorId); - if (operator == null) throw new RuntimeException("Operator不存在"); + if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在"); operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED); operator.setUpdatedAt(Instant.now()); operatorMapper.updateById(operator); @@ -82,7 +84,7 @@ public class OperatorManagementService { @Transactional public void enableOperator(String operatorId, String byOperatorId) { Operator operator = operatorMapper.selectById(operatorId); - if (operator == null) throw new RuntimeException("Operator不存在"); + if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在"); operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL); operator.setUpdatedAt(Instant.now()); operatorMapper.updateById(operator); @@ -91,7 +93,7 @@ public class OperatorManagementService { @Transactional public void resetPassword(String operatorId, String newPassword, String byOperatorId) { Operator operator = operatorMapper.selectById(operatorId); - if (operator == null) throw new RuntimeException("Operator不存在"); + if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在"); operator.setPasswordHash(passwordEncoder.encode(newPassword)); operator.setUpdatedAt(Instant.now()); operatorMapper.updateById(operator); diff --git a/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorService.java b/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorService.java index a94e6cf..57d764c 100644 --- a/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorService.java +++ b/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorService.java @@ -18,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; import java.util.Map; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; /** * Operator 管理服务 @@ -47,7 +49,7 @@ public class OperatorService { // 校验唯一性 operatorMapper.findByUsername(username).ifPresent(op -> { - throw new RuntimeException("用户名已存在: " + username); + throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在: " + username); }); Operator operator = new Operator(); diff --git a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java index 82bafd8..52b5d1c 100644 --- a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java +++ b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java @@ -29,6 +29,8 @@ import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import com.danyang.match.platform.shared.exception.ServerErrorException; @Service public class PhotoService { @@ -96,7 +98,7 @@ public class PhotoService { List userPhotos = photoMapper.findByUserIdOrderBySort(userId); boolean owned = userPhotos.stream().anyMatch(p -> fileKey.equals(p.getFileKey())); if (!owned) { - throw new RuntimeException("无权访问该图片"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权访问该图片"); } Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(SIGNED_URL_EXPIRE_HOURS)); @@ -198,13 +200,13 @@ public class PhotoService { boolean isLocalEvidence = "local_eligibility".equals(photoType); if (isLocalEvidence) { if (!isAllowedLocalEvidenceMimeType(contentType)) { - throw new RuntimeException("不支持的佐证文件格式: " + contentType); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的佐证文件格式: " + contentType); } } else if (!isAllowedMimeType(contentType)) { - throw new RuntimeException("不支持的图片格式: " + contentType); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的图片格式: " + contentType); } if (!validateSize(file, photoType)) { - throw new RuntimeException("文件大小超过限制"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件大小超过限制"); } // 2. 魔数校验(仅图片;PDF 佐证跳过——Content-Type 已由白名单约束, @@ -214,10 +216,10 @@ public class PhotoService { try (InputStream headerStream = file.getInputStream()) { header = headerStream.readNBytes(12); } catch (IOException e) { - throw new RuntimeException("读取文件头失败", e); + throw new ServerErrorException("读取文件头失败", e); } if (header.length < 3 || !validateMagicNumber(header)) { - throw new RuntimeException("无效的图片文件头"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的图片文件头"); } } @@ -236,7 +238,7 @@ public class PhotoService { setContentLength(file.getSize()); }}); } catch (IOException e) { - throw new RuntimeException("上传文件失败", e); + throw new ServerErrorException("上传文件失败", e); } // 4. 保存记录 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 51f73b3..4345f64 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 @@ -96,7 +96,7 @@ public class ReferralService { new LambdaQueryWrapper() .eq(ActivityRegistration::getActivityId, activityId) .eq(ActivityRegistration::getUserId, userId)); - if (existing != null) throw new RuntimeException("用户已报名该活动"); + if (existing != null) throw new BusinessException(ApiResponseCode.ACTIVITY_ALREADY_REGISTERED, "用户已报名该活动"); String operatorName = getOperatorName(matchmakerId); ActivityRegistration reg = new ActivityRegistration(); @@ -179,7 +179,7 @@ public class ReferralService { result.put("confirmedAt", reg.getUserConfirmedAt()); return result; } else { - throw new RuntimeException("不支持的转介类型: " + targetType); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的转介类型: " + targetType); } } diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java index 15b881d..ab60b37 100644 --- a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java +++ b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java @@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; /** * 关系服务 @@ -53,12 +55,12 @@ public class RelationshipService { public Map like(String userId, String targetUserId) { // 检查是否已Block if (isBlocked(userId, targetUserId)) { - throw new RuntimeException("对方已将你拉黑,无法表达喜欢"); + throw new BusinessException(ApiResponseCode.USER_BLOCKED, "对方已将你拉黑,无法表达喜欢"); } // 检查是否已Like if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED).isPresent()) { - throw new RuntimeException("已经表达过喜欢"); + throw new BusinessException(ApiResponseCode.ALREADY_LIKED, "已经表达过喜欢"); } // 原子插入 @@ -117,7 +119,7 @@ public class RelationshipService { public void pass(String userId, String targetUserId) { // 检查是否已Block(Block优先于Pass) if (isBlocked(userId, targetUserId)) { - throw new RuntimeException("对方已将你拉黑"); + throw new BusinessException(ApiResponseCode.USER_BLOCKED, "对方已将你拉黑"); } // 原子插入(唯一约束保证幂等) @@ -147,7 +149,7 @@ public class RelationshipService { public void block(String userId, String targetUserId) { // 检查是否已Block if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) { - throw new RuntimeException("已经拉黑过该用户"); + throw new BusinessException(ApiResponseCode.ALREADY_BLOCKED, "已经拉黑过该用户"); } // 插入Block关系 @@ -204,7 +206,7 @@ public class RelationshipService { Match existing = matchMapper.findActiveMatch(userId, targetUserId).orElse(null); if (existing != null) { // 已存在有效Match,不需要重复创建 - throw new RuntimeException("匹配已存在"); + throw new BusinessException(ApiResponseCode.CONFLICT, "匹配已存在"); } // Phase 12 复验修复(D5):本方法此前不设置 episode / lastActivityAt, diff --git a/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java b/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java index 505405c..db5ab95 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java +++ b/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java @@ -30,6 +30,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.*; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; @Service @RequiredArgsConstructor @@ -172,10 +174,10 @@ public class ReviewService { public ReviewResultDTO approve(String taskId, String operatorId, String comment) { ReviewTask task = reviewTaskMapper.selectById(taskId); if (task == null) { - throw new RuntimeException("审核任务不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在"); } if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) { - throw new RuntimeException("该任务当前状态不允许审核"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核"); } // 更新任务状态 @@ -210,10 +212,10 @@ public class ReviewService { public ReviewResultDTO needEdit(String taskId, String operatorId, String comment, List reasonCodes) { ReviewTask task = reviewTaskMapper.selectById(taskId); if (task == null) { - throw new RuntimeException("审核任务不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在"); } if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) { - throw new RuntimeException("该任务当前状态不允许审核"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核"); } task.setStatus(Constants.REVIEW_STATUS_NEED_EDIT); @@ -244,10 +246,10 @@ public class ReviewService { public ReviewResultDTO reject(String taskId, String operatorId, String comment, List reasonCodes) { ReviewTask task = reviewTaskMapper.selectById(taskId); if (task == null) { - throw new RuntimeException("审核任务不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在"); } if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) { - throw new RuntimeException("该任务当前状态不允许审核"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核"); } task.setStatus(Constants.REVIEW_STATUS_REJECTED); @@ -281,7 +283,7 @@ public class ReviewService { .orElseThrow(() -> new RuntimeException("未找到可重新提交的审核任务")); if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) { - throw new RuntimeException("当前状态不允许重新提交"); + throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交"); } // 创建新版本的审核任务 @@ -336,13 +338,13 @@ public class ReviewService { public ReviewResultDTO needEvidence(String taskId, String operatorId, String comment) { ReviewTask task = reviewTaskMapper.selectById(taskId); if (task == null) { - throw new RuntimeException("审核任务不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在"); } if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) { - throw new RuntimeException("该任务当前状态不允许操作"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作"); } if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) { - throw new RuntimeException("该任务不是本地资格审核"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核"); } requireClaimOwner(task, operatorId); @@ -378,13 +380,13 @@ public class ReviewService { public ReviewResultDTO approveLocalEligibility(String taskId, String operatorId, String comment) { ReviewTask task = reviewTaskMapper.selectById(taskId); if (task == null) { - throw new RuntimeException("审核任务不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在"); } if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) { - throw new RuntimeException("该任务当前状态不允许操作"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作"); } if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) { - throw new RuntimeException("该任务不是本地资格审核"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核"); } requireClaimOwner(task, operatorId); @@ -422,13 +424,13 @@ public class ReviewService { public ReviewResultDTO rejectLocalEligibility(String taskId, String operatorId, String comment) { ReviewTask task = reviewTaskMapper.selectById(taskId); if (task == null) { - throw new RuntimeException("审核任务不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在"); } if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) { - throw new RuntimeException("该任务当前状态不允许操作"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作"); } if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) { - throw new RuntimeException("该任务不是本地资格审核"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核"); } requireClaimOwner(task, operatorId); @@ -479,21 +481,21 @@ public class ReviewService { public Map accessLocalEligibilityEvidence( String taskId, String evidenceId, String operatorId, String purpose) { if (purpose == null || purpose.isBlank()) { - throw new RuntimeException("查看佐证必须填写访问目的"); + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "查看佐证必须填写访问目的"); } ReviewTask task = reviewTaskMapper.selectById(taskId); if (task == null || !Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) { if (task != null) { logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "denied"); } - throw new RuntimeException("本地资格审核任务不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "本地资格审核任务不存在"); } try { requireClaimOwner(task, operatorId); LocalEligibility eligibility = requireLocalEligibility(task.getUserId()); LocalEligibilityEvidence evidence = localEligibilityEvidenceMapper.selectById(evidenceId); if (evidence == null || !eligibility.getId().equals(evidence.getLocalEligibilityId())) { - throw new RuntimeException("佐证不存在或不属于当前审核任务"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "佐证不存在或不属于当前审核任务"); } Map result = photoService.getSignedUrlForAuthorizedReview(evidence.getFileId()); logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "success"); @@ -520,7 +522,7 @@ public class ReviewService { private void requireClaimOwner(ReviewTask task, String operatorId) { if (!Objects.equals(operatorId, task.getAssignedOperatorId())) { - throw new RuntimeException("请先领取该审核任务,且仅领取人可以处理"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "请先领取该审核任务,且仅领取人可以处理"); } } @@ -532,7 +534,7 @@ public class ReviewService { private LocalEligibility requireLocalEligibility(String userId) { LocalEligibility eligibility = findLocalEligibility(userId); - if (eligibility == null) throw new RuntimeException("本地资格记录不存在"); + if (eligibility == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "本地资格记录不存在"); return eligibility; } diff --git a/backend/src/main/java/com/danyang/match/platform/risk/service/ReportService.java b/backend/src/main/java/com/danyang/match/platform/risk/service/ReportService.java index 75d8317..26fae9b 100644 --- a/backend/src/main/java/com/danyang/match/platform/risk/service/ReportService.java +++ b/backend/src/main/java/com/danyang/match/platform/risk/service/ReportService.java @@ -31,6 +31,8 @@ import java.util.Map; import java.util.List; import java.util.*; import java.util.stream.Collectors; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; /** * 举报与风控服务 @@ -75,7 +77,7 @@ public class ReportService { public Map submitReport(String reporterUserId, ReportSubmitDTO dto) { // 不允许举报自己 if (reporterUserId.equals(dto.getReportedUserId())) { - throw new RuntimeException("不能举报自己"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己"); } // 检查是否已被封禁 @@ -83,7 +85,7 @@ public class ReportService { boolean isBanned = activeActions.stream() .anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())); if (isBanned) { - throw new RuntimeException("您的账号已被封禁,无法提交举报"); + throw new BusinessException(ApiResponseCode.FORBIDDEN, "您的账号已被封禁,无法提交举报"); } Report report = new Report(); @@ -150,7 +152,7 @@ public class ReportService { public Map getReportDetail(String reportId) { Report report = reportMapper.selectById(reportId); if (report == null) { - throw new RuntimeException("举报不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在"); } Map detail = new HashMap<>(); detail.put("report", toDTO(report)); @@ -187,17 +189,17 @@ public class ReportService { public Map processReport(String reportId, ReportProcessDTO dto, String operatorId) { Report report = reportMapper.selectById(reportId); if (report == null) { - throw new RuntimeException("举报不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在"); } if (!Constants.REPORT_STATUS_PENDING.equals(report.getStatus()) && !Constants.REPORT_STATUS_PROCESSING.equals(report.getStatus())) { - throw new RuntimeException("该举报已处理完毕,不可重复操作"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该举报已处理完毕,不可重复操作"); } String result = dto.getProcessResult(); if (result == null) { - throw new RuntimeException("请选择处理结果"); + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "请选择处理结果"); } report.setStatus(Constants.REPORT_STATUS_RESOLVED); 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 73d6c84..5e06216 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 @@ -60,10 +60,10 @@ public class ServiceAppointmentService { public ServiceAppointmentDTO registerByOperator(String targetUserId, ServiceAppointmentRequest request, String operatorId) { if (targetUserId == null || targetUserId.isBlank()) { - throw new RuntimeException("代登记必须指定服务对象用户"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "代登记必须指定服务对象用户"); } if (operatorId == null || operatorId.isBlank()) { - throw new RuntimeException("代登记必须由运营人员发起"); + throw new BusinessException(ApiResponseCode.BAD_REQUEST, "代登记必须由运营人员发起"); } return doCreate(targetUserId, request, Constants.APPOINTMENT_SOURCE_OPERATOR_ASSISTED, operatorId); } diff --git a/backend/src/main/java/com/danyang/match/platform/shared/crypto/EncryptionUtil.java b/backend/src/main/java/com/danyang/match/platform/shared/crypto/EncryptionUtil.java index b47d175..4f32efa 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/crypto/EncryptionUtil.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/crypto/EncryptionUtil.java @@ -10,6 +10,7 @@ import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; +import com.danyang.match.platform.shared.exception.ServerErrorException; /** * 敏感数据加密工具 @@ -56,7 +57,7 @@ public class EncryptionUtil { return Base64.getEncoder().encodeToString(combined); } catch (Exception e) { - throw new RuntimeException("加密失败", e); + throw new ServerErrorException("加密失败", e); } } @@ -87,7 +88,7 @@ public class EncryptionUtil { byte[] plainBytes = cipher.doFinal(encrypted); return new String(plainBytes, StandardCharsets.UTF_8); } catch (Exception e) { - throw new RuntimeException("解密失败", e); + throw new ServerErrorException("解密失败", e); } } @@ -106,7 +107,7 @@ public class EncryptionUtil { byte[] hashBytes = digest.digest(idNumber.getBytes(StandardCharsets.UTF_8)); return Hex.toHexString(hashBytes); } catch (Exception e) { - throw new RuntimeException("身份证 Hash 生成失败", e); + throw new ServerErrorException("身份证 Hash 生成失败", e); } } diff --git a/backend/src/main/java/com/danyang/match/platform/shared/exception/BusinessException.java b/backend/src/main/java/com/danyang/match/platform/shared/exception/BusinessException.java index dd06873..c4dc1ff 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/exception/BusinessException.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/exception/BusinessException.java @@ -1,23 +1,50 @@ package com.danyang.match.platform.shared.exception; -public class BusinessException extends RuntimeException { - private final String code; - +import com.danyang.match.platform.shared.enums.ApiResponseCode; + +/** + * 兼容别名:以**字符串**业务码构造的业务异常。 + * + *

2026-09-22 修复记录(不要改回独立继承 RuntimeException)。 + * 本类此前直接 {@code extends RuntimeException},与 + * {@link GlobalExceptionHandler.BusinessException} 是两个互不相干的类。 + * 而 {@code GlobalExceptionHandler} 里的 {@code @ExceptionHandler(BusinessException.class)} + * 按 Java 作用域解析到的是**自己的嵌套类**,因此本类的实例落到 + * {@link GlobalExceptionHandler#handleUnknownException},一律变成 **HTTP 500**: + * 调用 {@code AnalyticsService.getDailySummary(9999)} 本意是「days 越界」的 400, + * 实际返回「服务端内部错误,请联系管理员」。 + * + *

修复方式不是逐个改调用点,而是让本类**继承**被映射的那个类—— + * 这样无论谁再用旧名字抛异常,HTTP 语义都由业务码决定,不可能再静默 500。 + * + *

新代码请直接用 {@code GlobalExceptionHandler.BusinessException(ApiResponseCode, String)}; + * 本类别名仅为兼容既有字符串码调用点保留。 + * + * @deprecated 用 {@link GlobalExceptionHandler.BusinessException} + {@link ApiResponseCode} + */ +@Deprecated +public class BusinessException extends GlobalExceptionHandler.BusinessException { + + /** + * @param code {@link ApiResponseCode} 的枚举名(如 {@code "VALIDATION_ERROR"})。 + * 无法识别时退化为 500,并在异常首行标注原始码,避免静默变成「无信息的 400」。 + */ public BusinessException(String code) { - super(java.util.Arrays.stream(com.danyang.match.platform.shared.enums.ApiResponseCode.values()) - .filter(value -> value.getCode().equals(code)) - .map(com.danyang.match.platform.shared.enums.ApiResponseCode::getDescription) - .findFirst() - .orElse(code)); - this.code = code; + super(resolve(code)); } - + public BusinessException(String code, String message) { - super(message); - this.code = code; + super(resolve(code), message); } - - public String getCode() { - return code; + + private static ApiResponseCode resolve(String code) { + if (code != null) { + for (ApiResponseCode candidate : ApiResponseCode.values()) { + if (candidate.getCode().equals(code) || candidate.name().equals(code)) { + return candidate; + } + } + } + return ApiResponseCode.SERVER_ERROR; } } diff --git a/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java index e97e804..ec3e28f 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java @@ -114,6 +114,20 @@ public class GlobalExceptionHandler { return status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage())); } + /** + * 显式标记的服务端故障 → 500。 + * + *

与 {@link #handleUnknownException} 的区别只在**来源是否已知**: + * 这里是我们主动判定「这确实是服务端的问题」,因此不把 message 回给客户端 + * (容器/IO/加密的报错常含路径与密钥线索),但按 ERROR 记全栈以便定位。 + */ + @ExceptionHandler(ServerErrorException.class) + public ResponseEntity> handleServerError(ServerErrorException e) { + log.error("Server-side failure (explicitly marked): {}", e.getMessage(), e); + return status(ApiResponseCode.SERVER_ERROR) + .body(ApiResponse.error(ApiResponseCode.SERVER_ERROR, "服务端内部错误,请联系管理员")); + } + /** * 参数非法(例:活动类型不在白名单内)必须回 400。 * diff --git a/backend/src/main/java/com/danyang/match/platform/shared/exception/ServerErrorException.java b/backend/src/main/java/com/danyang/match/platform/shared/exception/ServerErrorException.java new file mode 100644 index 0000000..8b6dfa6 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/exception/ServerErrorException.java @@ -0,0 +1,39 @@ +package com.danyang.match.platform.shared.exception; + +/** + * 服务端自身故障的**显式**标记。 + * + *

适用范围:加解密失败、文件头读取失败、对象存储上传失败、签到码签名失败、 + * 外部实名认证 Provider 不可用 —— 这些确实是我们这边的问题,HTTP 500 正确, + * 客户端重试也无害。 + * + *

为什么需要一个专门的类,而不是继续用裸 {@code RuntimeException}: + * 裸 {@code RuntimeException} 无法与「忘了给业务拒绝挑状态码」区分开 —— + * 两者在源码上长得一模一样,落到 + * {@link GlobalExceptionHandler#handleUnknownException} 都是 500。 + * 于是 2026-09-22 审计时发现:全仓 121 处裸抛里,「活动不存在」「无权取消他人预约」 + * 这类明确的客户端错误被一并报成「服务端内部错误,请联系管理员」, + * 客户端据此判定为故障并重试,而重试必然再失败。 + * + *

有了这个类,裸 {@code RuntimeException} 就只剩一个含义:**漏标了**。 + * {@code RuntimeExceptionThrowGuardTest} 据此把「main 源码零裸抛」钉成不变量 —— + * 判据是扫描源码,因此不需要维护一份会过期的白名单副本。 + * + *

为什么放在这里而不是 GlobalExceptionHandler 内部:本异常需要从 + * {@code shared.crypto.EncryptionUtil} 这类基础设施代码抛出,让它们依赖一个 + * Web 层的 {@code @RestControllerAdvice} 是方向错误的依赖;放在异常包里, + * 基础设施只依赖异常语义本身。 + * + *

对外文案刻意不回落异常的 message(容器/IO/加密的报错常含路径与密钥线索), + * 与兜底处理器保持一致;堆栈按 ERROR 级记录以便定位。 + */ +public class ServerErrorException extends RuntimeException { + + public ServerErrorException(String message) { + super(message); + } + + public ServerErrorException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/user/service/UserService.java b/backend/src/main/java/com/danyang/match/platform/user/service/UserService.java index 6de0d1e..dda8a94 100644 --- a/backend/src/main/java/com/danyang/match/platform/user/service/UserService.java +++ b/backend/src/main/java/com/danyang/match/platform/user/service/UserService.java @@ -26,6 +26,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; /** * 用户管理服务 @@ -104,7 +106,7 @@ public class UserService { public UserDetailDTO getUserDetail(String userId, String operatorId) { Optional userOpt = userMapper.findById(userId); if (userOpt.isEmpty()) { - throw new RuntimeException("用户不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"); } User user = userOpt.get(); UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null); @@ -139,7 +141,7 @@ public class UserService { public void updateAccountStatus(String userId, String newStatus, String operatorId) { User user = userMapper.selectById(userId); if (user == null) { - throw new RuntimeException("用户不存在"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"); } String oldStatus = user.getAccountStatus(); user.setAccountStatus(newStatus); diff --git a/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java b/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java index 1651f1e..3ba38b5 100644 --- a/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java +++ b/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java @@ -21,6 +21,9 @@ import java.time.Period; import java.time.ZoneId; import java.util.HashMap; import java.util.Map; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import com.danyang.match.platform.shared.exception.ServerErrorException; @Service @RequiredArgsConstructor @@ -54,14 +57,14 @@ public class IdentityVerificationService { // 3. 检查是否已实名 userIdentityMapper.findByUserId(userId).ifPresent(exists -> { if (Constants.VERIFICATION_STATUS_VERIFIED.equals(exists.getVerificationStatus())) { - throw new RuntimeException("已经实名认证,无需重复提交"); + throw new BusinessException(ApiResponseCode.CONFLICT, "已经实名认证,无需重复提交"); } }); // 4. 检查身份证是否已被其他账号使用 String idHash = encryptionUtil.hashIdNumber(idNumber); if (userIdentityMapper.existsByIdNumberHash(idHash)) { - throw new RuntimeException("该身份证已被其他账号实名认证"); + throw new BusinessException(ApiResponseCode.CONFLICT, "该身份证已被其他账号实名认证"); } // 5. 调用实名认证 Provider @@ -70,7 +73,7 @@ public class IdentityVerificationService { providerResult = identityProvider.verify(realName, idNumber); } catch (Exception e) { log.error("实名认证 Provider 调用失败", e); - throw new RuntimeException("实名认证服务暂时不可用,请稍后重试"); + throw new ServerErrorException("实名认证服务暂时不可用,请稍后重试"); } // 6. 检查验证结果 @@ -78,7 +81,7 @@ public class IdentityVerificationService { if (verified == null || !verified) { // 保存失败记录 saveFailedIdentity(userId, idNumber, idHash, realName, "VERIFY_FAILED"); - throw new RuntimeException("实名认证失败:姓名与身份证不匹配"); + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "实名认证失败:姓名与身份证不匹配"); } // 7. 成年校验(核心业务规则) @@ -88,7 +91,7 @@ public class IdentityVerificationService { if (age < MIN_AGE) { saveFailedIdentity(userId, idNumber, idHash, realName, "UNDER_18"); - throw new RuntimeException("未满18周岁,不能使用本婚恋服务"); + throw new BusinessException(ApiResponseCode.USER_UNDER_18, "未满18周岁,不能使用本婚恋服务"); } // 8. 从身份证解析性别(如果 Provider 未返回) @@ -212,7 +215,7 @@ public class IdentityVerificationService { .orElseThrow(() -> new RuntimeException("未找到实名认证记录")); if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) { - throw new RuntimeException("已认证的实名记录不能撤销"); + throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销"); } identity.setVerificationStatus(Constants.VERIFICATION_STATUS_NOT_VERIFIED); diff --git a/backend/src/test/java/com/danyang/match/platform/auth/token/JwtTokenServiceRefreshTokenTest.java b/backend/src/test/java/com/danyang/match/platform/auth/token/JwtTokenServiceRefreshTokenTest.java new file mode 100644 index 0000000..0c9501e --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/auth/token/JwtTokenServiceRefreshTokenTest.java @@ -0,0 +1,150 @@ +package com.danyang.match.platform.auth.token; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Refresh Token 的验签与用途隔离守卫。 + * + *

为什么必须有这个测试:2026-09-22 定位到「刷新 Token 永远失败 → 登录态被清 → + * 全站 401」的根因是 {@code AuthService.refreshToken} 调用了 + * {@link JwtTokenService#validateToken} —— 而该方法用 **access 密钥**验签, + * refresh token 却由 **refresh 密钥**签发,两者在 dev/test/production 取值都不同 + * (见 application-*.yml),因此签名校验必然失败。 + * + *

这个缺陷此前完全没被发现,因为它有**两层掩盖**: + *

    + *
  1. 前端漏传 userId,/auth/refresh 先返回 400,根本走不到验签那一步;
  2. + *
  3. E2E 全仓没有任何 /auth/refresh 用例(只有登录 / 运营登录)。
  4. + *
+ * 修掉第一层之后,第二层才会显形 —— 所以本测试同时钉住「验签用对密钥」 + * 与「两类令牌不得互相顶替」两件事。 + * + *

这是纯单元测试:不起 Spring、不连 Redis,绕过上面第 1 类掩盖。 + */ +class JwtTokenServiceRefreshTokenTest { + + private static final String ACCESS_SECRET = "unit-test-access-secret-0123456789abcdef"; + private static final String REFRESH_SECRET = "unit-test-refresh-secret-0123456789abcdef"; + private static final String USER_ID = "user-under-test"; + + private TokenBlacklistService blacklist; + + @BeforeEach + void setUp() { + blacklist = mock(TokenBlacklistService.class); + } + + private JwtTokenService newService(String accessSecret, String refreshSecret) { + JwtTokenService service = new JwtTokenService(blacklist); + ReflectionTestUtils.setField(service, "accessSecret", accessSecret); + ReflectionTestUtils.setField(service, "refreshSecret", refreshSecret); + ReflectionTestUtils.setField(service, "accessExpirationMs", 7_200_000L); + ReflectionTestUtils.setField(service, "refreshExpirationMs", 1_209_600_000L); + return service; + } + + @Test + @DisplayName("refresh token 必须能用 refresh 密钥验签通过(回归:此前用 access 密钥验签恒失败)") + void refreshTokenValidatesWithRefreshKey() { + JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET); + String refreshToken = service.generateRefreshToken(USER_ID); + + assertTrue(service.validateRefreshToken(refreshToken), + "refresh token 用 refresh 密钥签发,validateRefreshToken 必须放行;" + + "若为 false,说明又回到了用 access 密钥验签的缺陷"); + + String subject = service.getUserIdFromRefreshToken(refreshToken); + assertEquals(USER_ID, subject, "refresh token 的 subject 必须是 userId(刷新时据此自证身份)"); + } + + @Test + @DisplayName("access token 不得充当 refresh token 使用") + void accessTokenIsNotRefreshToken() { + JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET); + String accessToken = service.generateAccessToken(USER_ID, null); + + assertFalse(service.validateRefreshToken(accessToken), + "access token 不能换发新令牌,否则短期凭证获得了续期能力"); + } + + @Test + @DisplayName("refresh token 不得充当访问凭证(反向隔离)") + void refreshTokenIsNotAccessToken() { + JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET); + String refreshToken = service.generateRefreshToken(USER_ID); + + assertFalse(service.validateToken(refreshToken), + "refresh token 不得通过访问凭证校验,否则长期凭证可直连业务接口"); + } + + @Test + @DisplayName("两把密钥配成同一个值时,只能靠 type 声明挡住用途顶替") + void typeClaimBlocksConfusionWhenSecretsAreIdentical() { + // 模拟运维把 access / refresh 密钥配成同一个值(或都用默认占位值)—— + // 此时签名校验无法区分两类令牌,唯一的闸门是 type 声明。 + JwtTokenService service = newService(ACCESS_SECRET, ACCESS_SECRET); + String accessToken = service.generateAccessToken(USER_ID, null); + + assertFalse(service.validateRefreshToken(accessToken), + "密钥相同的情况下,只有 type=refresh 断言能阻止 access token 被当作 refresh token"); + } + + @Test + @DisplayName("同一秒内签发的令牌也必须互不相同(否则「刷新即轮换」形同虚设)") + void tokensMintedInTheSameSecondMustStillBeDistinct() { + JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET); + + java.util.Set refreshTokens = new java.util.HashSet<>(); + java.util.Set accessTokens = new java.util.HashSet<>(); + for (int i = 0; i < 50; i++) { + refreshTokens.add(service.generateRefreshToken(USER_ID)); + accessTokens.add(service.generateAccessToken(USER_ID, null)); + } + + // 载荷只有 subject / iat / type,而 JWT 的 iat 是**秒级**。同一 user 在同一秒内 + // 重复签发会得到逐字节相同的令牌 —— 后果是刷新时写入 Redis 的「新」refresh token + // 与旧值相等,于是「刷新即轮换、旧令牌作废」这个安全属性直接失效 + // (2026-09-22 由 tests/e2e/auth_refresh_test.py 的两个用例咬出来)。 + // 修法是给每个令牌加唯一 jti。 + assertEquals(50, refreshTokens.size(), + "50 次签发只产生了 " + refreshTokens.size() + " 个不同的 refresh token:" + + "缺少唯一标识(jti),同一秒内的令牌会重复"); + assertEquals(50, accessTokens.size(), + "50 次签发只产生了 " + accessTokens.size() + " 个不同的 access token:" + + "缺少唯一标识(jti),同一秒内的令牌会重复"); + } + + @Test + @DisplayName("已撤销(黑名单)的 refresh token 必须被拒绝") + void blacklistedRefreshTokenIsRejected() { + JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET); + String refreshToken = service.generateRefreshToken(USER_ID); + assertNotNull(refreshToken); + + when(blacklist.isBlacklisted(anyString())).thenReturn(true); + + assertFalse(service.validateRefreshToken(refreshToken), + "登出后加入黑名单的 refresh token 不得再换发新令牌"); + } + + @Test + @DisplayName("无法解析的字符串必须被拒绝,且不得抛异常") + void malformedTokenIsRejectedWithoutThrowing() { + JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET); + + assertFalse(service.validateRefreshToken("not-a-jwt"), + "非法令牌应返回 false 而不是抛异常(异常会在 Controller 层变成 500)"); + assertFalse(service.validateRefreshToken(null), "null 令牌应返回 false"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandlerTest.java index 60b5d12..5e67b64 100644 --- a/backend/src/test/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandlerTest.java +++ b/backend/src/test/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandlerTest.java @@ -21,10 +21,49 @@ class GlobalExceptionHandlerTest { @Test void testBusinessException() { BusinessException ex = new BusinessException(ApiResponseCode.PROFILE_NOT_APPROVED.getCode()); - assertEquals(ApiResponseCode.PROFILE_NOT_APPROVED.getCode(), ex.getCode()); + // getCode() 返回的是枚举而非字符串:HTTP 语义(403 还是 400、该不该重试) + // 只能由 ApiResponseCode.httpStatus 决定,字符串码本身不携带这个信息。 + assertEquals(ApiResponseCode.PROFILE_NOT_APPROVED, ex.getCode()); assertEquals("当前资料尚未通过审核", ex.getMessage()); } + @Test + void stringCodeAliasReachesItsHttpSemanticsInsteadOfFiveHundred() { + // 锁住 2026-09-22 的根因:shared.exception.BusinessException 曾 + // extends RuntimeException,与 @ExceptionHandler(BusinessException.class) 解析到的 + // 嵌套类互不相干,其实例一律落到 handleUnknownException → 500。 + // 「资料未通过审核」本意是 403,客户端据此判断「别重试」。 + BusinessException ex = new BusinessException(ApiResponseCode.PROFILE_NOT_APPROVED.getCode()); + + ResponseEntity> res = handler.handleBusinessException(ex); + + assertEquals(HttpStatus.FORBIDDEN, res.getStatusCode(), + "别名类的实例必须按业务码回 403,而不是兜底 500"); + assertNotNull(res.getBody()); + assertEquals("PROFILE_NOT_APPROVED", res.getBody().getCode()); + } + + @Test + void unknownStringCodeFailsClosedToServerError() { + // 无法识别的码不能退化成一个「无信息的 400」——那会让调用方以为改参数就能通过。 + // fail-closed 到 500 至少能被监控发现,而 400 会静默掩盖映射缺失。 + BusinessException ex = new BusinessException("NO_SUCH_CODE"); + + assertEquals(ApiResponseCode.SERVER_ERROR, ex.getCode()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, + handler.handleBusinessException(ex).getStatusCode()); + } + + @Test + void businessExceptionAliasMustStayASubclassOfTheMappedType() { + // 结构守卫:把别名类改回 extends RuntimeException 会**静默**恢复 + // 「业务异常 → 500」。此时抛点一处未改,任何行为测试都咬不住, + // 只有这条继承关系断言会立刻变红。 + assertTrue(GlobalExceptionHandler.BusinessException.class.isAssignableFrom(BusinessException.class), + "shared.exception.BusinessException 必须继承被 @ExceptionHandler 映射的那个类," + + "否则它的实例永远落进 handleUnknownException 变成 500"); + } + @Test void testApiResponseError() { ApiResponse response = ApiResponse.error(ApiResponseCode.UNAUTHORIZED); diff --git a/backend/src/test/java/com/danyang/match/platform/shared/exception/RuntimeExceptionThrowGuardTest.java b/backend/src/test/java/com/danyang/match/platform/shared/exception/RuntimeExceptionThrowGuardTest.java new file mode 100644 index 0000000..c32eac8 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/shared/exception/RuntimeExceptionThrowGuardTest.java @@ -0,0 +1,203 @@ +package com.danyang.match.platform.shared.exception; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 不变量:main 源码里不得出现裸的 {@code throw new RuntimeException(...)}。 + * + *

为什么这是一个需要测试来守的约束:裸 {@code RuntimeException} 没有携带 + * HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成 + * 500。于是「活动不存在」「无权取消他人预约」「已经报名过该活动」这类 + * 明确的客户端错误,被一并报成「服务端内部错误,请联系管理员」—— + * 客户端据此判定为服务端故障并重试,而重试必然再失败,把「别重试」的语义 + * 放大成故障风暴。2026-09-22 审计时全仓有 121 处。 + * + *

收敛之后,{@code RuntimeException} 就只剩一个含义:**漏标了**。 + * 真正的服务端故障请显式抛 {@link ServerErrorException}(仍是 500,但意图明确、 + * 日志按 ERROR 记录、不向客户端泄露内部细节);业务拒绝请抛 + * {@code GlobalExceptionHandler.BusinessException} 并带上 + * {@link com.danyang.match.platform.shared.enums.ApiResponseCode}。 + * + *

判据是扫描源码,不是维护白名单副本——白名单会随重构过期, + * 而源码不会。这也是本项目对守卫的一贯要求(见 MEMORY 的守卫纪律)。 + * + *

工作目录依赖:本仓 surefire 把 {@code workingDirectory} 设为 + * {@code ${project.basedir}/..}(仓库根),故此处用仓库根相对路径。 + */ +class RuntimeExceptionThrowGuardTest { + + /** + * 允许 {@code throw} 与 {@code new} 之间有任意空白,以便跨行书写也被咬住。 + * + *

{@code IllegalStateException} / {@code IllegalArgumentException} 不在守卫范围内: + * 前者已有专用处理器映射到 409(有明确状态语义),后者映射到 400。 + */ + private static final Pattern BARE_THROW = + Pattern.compile("throw\\s+new\\s+RuntimeException\\s*\\("); + + private static final Path MAIN_SOURCES = Paths.get("backend/src/main/java"); + + @Test + @DisplayName("main 源码不得裸抛 RuntimeException(会被兜底成 500,与业务拒绝无法区分)") + void mainSourcesMustNotThrowBareRuntimeException() throws IOException { + assertTrue(Files.isDirectory(MAIN_SOURCES), + "找不到源码根 " + MAIN_SOURCES.toAbsolutePath() + + "。若 surefire 的 workingDirectory 不是仓库根,本守卫会静默空跑成" + + "「假绿」,必须先修正路径而不是删掉断言。"); + + List javaFiles; + try (Stream walk = Files.walk(MAIN_SOURCES)) { + javaFiles = walk.filter(p -> p.toString().endsWith(".java")).sorted().toList(); + } + assertFalse(javaFiles.isEmpty(), "源码根下没有任何 .java 文件,守卫形同虚设"); + + List offenders = new ArrayList<>(); + for (Path file : javaFiles) { + String content = Files.readString(file, StandardCharsets.UTF_8); + String[] lines = stripComments(content).split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (BARE_THROW.matcher(lines[i]).find()) { + offenders.add(file + ":" + (i + 1) + " " + lines[i].trim()); + } + } + } + + assertTrue(offenders.isEmpty(), + "发现 " + offenders.size() + " 处裸抛 RuntimeException。" + + "业务拒绝请改成 new BusinessException(ApiResponseCode.XXX, \"文案\")," + + "服务端故障请改成 new ServerErrorException(\"文案\", e)。\n" + + String.join("\n", offenders)); + } + + @Test + @DisplayName("守卫自身有判别力:注释里的提及不算,代码里的裸抛要能被认出来") + void guardDetectsCodeButIgnoresComments() { + String sample = """ + // throw new RuntimeException("注释里的提及不应命中"); + /* throw new RuntimeException("块注释里的也不应命中"); */ + if (x == null) throw new RuntimeException("代码里的必须命中"); + throw new RuntimeException("跨空格的必须命中"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "业务异常"); + throw new ServerErrorException("服务端故障"); + """; + + String[] lines = stripComments(sample).split("\n", -1); + int hits = 0; + for (String line : lines) { + if (BARE_THROW.matcher(line).find()) { + hits++; + } + } + + // 这条断言保证上面那个「零裸抛」不是恒真的空断言: + // 如果正则或注释剥离写坏(例如把代码也当成注释剥掉),这条会先红。 + assertTrue(hits == 2, "应当且只应当命中 2 处裸抛(含同行 if 与多空格写法),实际命中 " + hits); + } + + /** + * 把 Java 源码中的注释内容替换为空格(保留换行,行号不变), + * 但保留字符串/字符字面量,避免「注释里的示例代码」被误判成真实裸抛。 + */ + private static String stripComments(String source) { + StringBuilder out = new StringBuilder(source.length()); + int i = 0; + int n = source.length(); + boolean inLineComment = false; + boolean inBlockComment = false; + boolean inString = false; + boolean inChar = false; + + while (i < n) { + char c = source.charAt(i); + char next = i + 1 < n ? source.charAt(i + 1) : '\0'; + + if (inLineComment) { + if (c == '\n') { + inLineComment = false; + out.append(c); + } else { + out.append(' '); + } + } else if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + out.append(" "); + i += 2; + continue; + } + out.append(c == '\n' ? '\n' : ' '); + } else if (inString) { + out.append(c); + if (c == '\\') { + if (next != '\0') { + out.append(next); + i += 2; + continue; + } + } else if (c == '"') { + inString = false; + } + } else if (inChar) { + out.append(c); + if (c == '\\') { + if (next != '\0') { + out.append(next); + i += 2; + continue; + } + } else if (c == '\'') { + inChar = false; + } + } else if (c == '/' && next == '/') { + inLineComment = true; + out.append(" "); + i += 2; + continue; + } else if (c == '/' && next == '*') { + inBlockComment = true; + out.append(" "); + i += 2; + continue; + } else { + out.append(c); + if (c == '"') { + inString = true; + } else if (c == '\'') { + inChar = true; + } + } + i++; + } + return out.toString(); + } + + /** 供上面两条断言共享的调试入口:确认正则本身没写坏。 */ + private static boolean matches(String code) { + Matcher m = BARE_THROW.matcher(code); + return m.find(); + } + + @Test + @DisplayName("正则自身校验:确认识别的是 RuntimeException 而不是其他异常") + void patternOnlyMatchesRuntimeException() { + assertTrue(matches("throw new RuntimeException(\"x\");")); + assertFalse(matches("throw new BusinessException(ApiResponseCode.NOT_FOUND, \"x\");")); + assertFalse(matches("throw new ServerErrorException(\"x\");")); + assertFalse(matches("throw new IllegalStateException(\"x\");")); + } +} diff --git a/miniapp/pages/login/login.ts b/miniapp/pages/login/login.ts index 401c39a..727add5 100644 --- a/miniapp/pages/login/login.ts +++ b/miniapp/pages/login/login.ts @@ -52,7 +52,15 @@ Page({ wx.switchTab({ url: ROUTE.TAB.HOME }) } } catch (err: unknown) { - const message = err instanceof Error ? err.message : '登录失败,请重试' + // api.js 的 reject 值是普通对象 { code, message, requestId },不是 Error 实例。 + // 此前只认 `instanceof Error`,于是服务端文案(「账号已被封禁」「该手机号已被…」) + // 全被丢掉,用户只看到笼统的「登录失败,请重试」,无从判断该怎么处理。 + const fromRejection = + err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string' + ? (err as { message: string }).message + : '' + const message = + fromRejection || (err instanceof Error ? err.message : '') || '登录失败,请重试' console.error('[Login] 登录失败:', err) this.setData({ error: message, loggingIn: false }) appStore.toastError(message) diff --git a/miniapp/services/authService.js b/miniapp/services/authService.js index 2832114..466baae 100644 --- a/miniapp/services/authService.js +++ b/miniapp/services/authService.js @@ -10,7 +10,10 @@ const api = require('../utils/api'); /** * 微信登录(API_INVENTORY 行 8) * @param {{ code: string }} payload wx.login 拿到的 code - * @returns {Promise<{ token: string, refreshToken: string, expiresIn: number, needPhone: boolean, needProfile: boolean }>} + * @returns {Promise<{ accessToken: string, refreshToken: string, expiresIn: number, + * userId: string, isNewUser: boolean }>} + * 注意是 accessToken(不是 token)、isNewUser(不是 needPhone/needProfile)—— + * 这两处字段名此前都写错,导致 token 落空与「误判已绑手机号」,见 authStore。 */ function wechatLogin(payload) { return api.post('/auth/wechat/login', payload, undefined, payload.code); @@ -27,8 +30,8 @@ function bindPhone(payload) { /** * 刷新 Token(API_INVENTORY 行 10) - * 后端 /auth/refresh 要求 body 同时包含 userId 与 refreshToken(userId 用于定位 - * Redis 中存储的 refresh token),缺少任一都会返回 400。故此处必须带 userId。 + * 后端 /auth/refresh 只强制要求 refreshToken(userId 由令牌 subject 自证身份); + * 这里仍传 userId,让后端校验它与令牌主体一致 —— 串号时能被提前拦下。 * @param {string} userId 当前登录用户 ID(来自 AuthStore.state.userId) * @returns {Promise<{ accessToken: string, refreshToken: string, expiresIn: number }>} */ diff --git a/miniapp/stores/authStore.js b/miniapp/stores/authStore.js index 512c4f5..774f44a 100644 --- a/miniapp/stores/authStore.js +++ b/miniapp/stores/authStore.js @@ -55,7 +55,7 @@ class AuthStore extends BaseStore { /** * 微信登录 * @param {string} code wx.login() 拿到的 code - * @returns {Promise<{ needPhone: boolean, needProfile: boolean }>} + * @returns {Promise<{ isNewUser: boolean }>} isNewUser = 后端判定「尚未绑定手机号」 */ async loginWithWechat(code) { this.setState({ authStatus: 'logging_in', lastError: null }); @@ -66,7 +66,14 @@ class AuthStore extends BaseStore { const patch = { isLoggedIn: true, userId: result.userId || null, - phoneBound: !result.needPhone, + /** + * 必须读后端真实字段 isNewUser。 + * 此前读的是 `!result.needPhone` —— 后端从不返回 needPhone,恒为 undefined, + * 于是 `!undefined === true`:**每个用户一登录就被标记成已绑手机号**, + * 「我的」页不再引导绑号。这是与 accessToken/token 同族的字段名不一致缺陷, + * 也是本项目第三次踩到「跨端字段名对不上」(见 types/index.ts 的 UserLoginResult)。 + */ + phoneBound: !result.isNewUser, authStatus: 'idle', }; this.setState(patch); @@ -74,7 +81,8 @@ class AuthStore extends BaseStore { { isLoggedIn: true, userId: patch.userId, phoneBound: patch.phoneBound }, STORAGE_KEYS.IS_LOGGED_IN, ); - return { needPhone: !!result.needPhone, needProfile: !!result.needProfile }; + // 只回传后端确实提供的字段:needProfile 后端没有对应字段,凭空造 false 会误导调用方。 + return { isNewUser: !!result.isNewUser }; } catch (err) { this.setState({ authStatus: 'idle', lastError: err }); throw err; @@ -103,7 +111,9 @@ class AuthStore extends BaseStore { this._refreshPromise = (async () => { this.setState({ authStatus: 'refreshing' }); try { - // 后端 /auth/refresh 需要 userId;后端返回 accessToken(非 token)。 + // 后端返回 accessToken(非 token)。userId 已非必需(后端从 refreshToken 的 + // subject 自证身份),仍显式带上:后端会校验它与令牌主体一致,能提前拦下 + // 「拿 A 的令牌刷 B 的会话」这类串号。 const result = await authService.refresh(this.state.userId); api.saveToken(result.accessToken, result.refreshToken); this.setState({ authStatus: 'idle', isLoggedIn: true }); diff --git a/miniapp/utils/api.js b/miniapp/utils/api.js index dc9a478..9ab37d8 100644 --- a/miniapp/utils/api.js +++ b/miniapp/utils/api.js @@ -124,6 +124,23 @@ class ApiClient { return Date.now().toString(36) + Math.random().toString(36).substring(2, 8); } + /** + * 是否为认证端点(/auth/*)。 + * + * 认证端点自身返回 401 时**不得**触发 Session 失效流程。原因:401 分支会调用 + * `onSessionExpired`,而它(AuthStore.handleSessionExpired)会去调 `/auth/refresh` + * —— 若 `/auth/refresh` 自己也回 401,就形成「刷新 → 失效 → 再刷新」的自我递归。 + * + * 这条路径在 2026-09-22 之前是隐性的:后端当时把「refresh token 无效」抛成 + * 500,401 分支根本走不到。后端把语义修正为 401(正确)之后,这层守卫 + * 就成了必需 —— 否则修好一个缺陷会引入另一个。 + * + * 认证端点的失败由调用方(AuthStore)判定,本层只如实上报。 + */ + isAuthEndpoint(url) { + return typeof url === 'string' && url.indexOf('/auth/') === 0; + } + /** * 通用请求方法 * @param {{url: string, method?: string, data?: any, headers?: object, idempotencyKey?: string, timeout?: number}} options @@ -189,6 +206,17 @@ class ApiClient { } else if (res.statusCode === 401) { // 服务端 message 优先:token 失效的原因可能各不相同(过期/被踢/未登录)。 const body = res.data || {}; + if (this.isAuthEndpoint(url)) { + // /auth/refresh 等端点自己回 401 = 「刷新凭据已失效」。 + // 此处若照常 clearToken + onSessionExpired,就会再次发起 /auth/refresh(自我递归)。 + // 状态与清理交给调用方:AuthStore.refresh 失败时会调用 _handleExpired()。 + reject({ + code: body.code || REQUEST_ERROR.UNAUTHORIZED, + message: body.message || '请重新登录', + requestId, + }); + return; + } this.clearToken(); if (typeof this.onSessionExpired === 'function') this.onSessionExpired(); reject({ code: REQUEST_ERROR.UNAUTHORIZED, message: body.message || '请重新登录', requestId }); diff --git a/tests/e2e/auth_refresh_test.py b/tests/e2e/auth_refresh_test.py new file mode 100644 index 0000000..0becf45 --- /dev/null +++ b/tests/e2e/auth_refresh_test.py @@ -0,0 +1,124 @@ +""" +认证刷新链路 E2E。 + +背景:2026-09-22 用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」 +这条路整条是断的,而且断在三个互不相干的地方: + + 1. 前端漏传 userId → 后端 `/auth/refresh` 直接 400; + 2. 后端把 refresh token 交给 `validateToken` 校验,而那是用 **access 密钥** + 验签的,refresh token 由 **refresh 密钥**签发 —— 必然失败; + 3. 失败被抛成裸 `RuntimeException` → HTTP 500,客户端当成服务端故障重试。 + +第 1 层修好后第 2 层才显形,第 2 层修好后第 3 层才显形 —— 单层修复都会「看起来好了」。 +所以这里断言的是**整条链路的可用性**,而不是某一步返回 200: + + * 换发出来的 accessToken 必须真的能通过鉴权(访问受保护端点); + * 换发后旧 refreshToken 必须失效(可撤销性); + * 刷新失败必须回 401 而不是 500(客户端据此决定「重新登录」而非「重试」)。 + +此前全仓没有任何 /auth/refresh 用例,这正是该缺陷能长期存活的原因。 +""" +import uuid + +import pytest + +pytestmark = pytest.mark.e2e + +MISSING_UUID = "00000000-0000-0000-0000-000000000000" + + +def _refresh(api, token: str, refresh_token: str, **extra): + return api.call( + "POST", + "/auth/refresh", + json={"refreshToken": refresh_token, **extra}, + ) + + +def test_refresh_returns_access_token_that_really_works(api, new_user): + """刷新换发的 accessToken 必须能通过鉴权 —— 只看 200 会被「返回了空壳令牌」骗过。""" + user = new_user() + resp = _refresh(api, user["token"], user["refresh_token"], userId=user["user_id"]) + assert resp.status_code == 200, ( + f"/auth/refresh 必须成功(refreshToken 由 refresh 密钥签发," + f"用 access 密钥验签会必然失败)。实际 {resp.status_code}: {resp.text[:300]}" + ) + body = resp.json().get("data") or {} + new_access = body.get("accessToken") + new_refresh = body.get("refreshToken") + assert new_access, f"刷新响应必须包含 accessToken:{body}" + assert new_refresh, f"刷新响应必须包含新的 refreshToken(令牌轮换):{body}" + # 令牌必须互异:JWT 的 iat 是秒级,若载荷只有 subject/iat/type, + # 同一秒内签发的令牌会逐字节相同 —— 「刷新即轮换」随之失效(旧令牌仍然可用)。 + # 这个断言在 2026-09-22 咬出过该缺陷,故保留。 + assert new_access != user["token"], "刷新应换发新令牌,而不是回显旧令牌" + assert new_refresh != user["refresh_token"], ( + "刷新必须换发不同的 refreshToken,否则无法作废旧令牌" + ) + + # 关键:新令牌不是装饰品,必须真的能过鉴权 + probe = api.call("GET", "/me/status", token=new_access) + assert probe.status_code == 200, ( + f"刷新换发的 accessToken 必须能访问受保护端点,实际 {probe.status_code}: {probe.text[:300]}" + ) + + +def test_refresh_does_not_require_user_id(api, new_user): + """ + userId 不是必填项:refresh token 的 subject 就是 userId,令牌自证身份。 + + 强制要求客户端重复传一个它已经持有的凭据里已包含的信息,只会制造 + 「漏传 → 400」这类无意义失败(正是这次事故放大的起点)。 + """ + user = new_user() + resp = _refresh(api, user["token"], user["refresh_token"]) + assert resp.status_code == 200, ( + f"只带 refreshToken 也必须能刷新(身份来自令牌 subject),实际 {resp.status_code}: {resp.text[:300]}" + ) + assert (resp.json().get("data") or {}).get("accessToken"), "应换发 accessToken" + + +def test_refresh_rejects_mismatched_user_id(api, new_user): + """显式传入的 userId 与令牌主体不一致时必须拒绝,防止拿 A 的令牌刷 B 的会话。""" + user = new_user() + other = new_user() + resp = _refresh(api, user["token"], user["refresh_token"], userId=other["user_id"]) + assert resp.status_code in (401, 403), ( + f"userId 与令牌主体不一致必须被拒绝(401/403),实际 {resp.status_code}: {resp.text[:300]}" + ) + + +def test_rotating_refresh_token_invalidates_the_previous_one(api, new_user): + """刷新后旧 refreshToken 必须失效(Redis 中登记值已被换发覆盖)。""" + user = new_user() + first = _refresh(api, user["token"], user["refresh_token"]) + assert first.status_code == 200, f"前置条件失败:首次刷新应成功: {first.text[:300]}" + + reused = _refresh(api, user["token"], user["refresh_token"]) + assert reused.status_code == 401, ( + f"已轮换掉的 refreshToken 必须失效并回 401,实际 {reused.status_code}: {reused.text[:300]}" + ) + + +def test_invalid_refresh_token_is_401_not_500(api, new_user): + """ + 刷新凭据无效 = 401,不是 500。 + + 500 会让客户端判定为服务端故障并重试,而重试必然再失败; + 401 才能让客户端停止重试、走「重新登录」。这也是小程序 api.js + 区分 401 / 其他失败分支的前提。 + """ + user = new_user() + for bad_token in (str(uuid.uuid4()), "not-a-jwt"): + resp = _refresh(api, user["token"], bad_token) + assert resp.status_code == 401, ( + f"无效 refreshToken({bad_token[:12]}…) 应回 401,实际 {resp.status_code}: {resp.text[:300]}" + ) + + +def test_refresh_without_refresh_token_is_400(api): + """完全没带凭据属于请求问题 → 400(与「凭据无效」的 401 区分开)。""" + resp = api.call("POST", "/auth/refresh", json={}) + assert resp.status_code == 400, ( + f"缺少 refreshToken 应回 400,实际 {resp.status_code}: {resp.text[:300]}" + ) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 8e2d164..17e2675 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -175,7 +175,12 @@ def operator_token( @pytest.fixture def new_user(api: ApiClient) -> Callable[[], Dict[str, Any]]: - """注册并登录一个全新用户,返回 {'token','user_id','code'}。""" + """注册并登录一个全新用户,返回 {'token','refresh_token','user_id','code'}。 + + 暴露 refresh_token 是为了让刷新链路可被真实栈验证 —— 在 2026-09-22 之前 + 没有任何用例覆盖 /auth/refresh,导致「refresh token 用 refresh 密钥签发、 + 却用 access 密钥验签」这个必然失败的缺陷长期存活。 + """ created: list = [] def _create() -> Dict[str, Any]: @@ -183,10 +188,12 @@ def new_user(api: ApiClient) -> Callable[[], Dict[str, Any]]: data = api.ok("POST", "/auth/wechat/login", json={"code": code}) user = { "token": data.get("accessToken"), + "refresh_token": data.get("refreshToken"), "user_id": data.get("userId"), "code": code, } assert user["token"] and user["user_id"], f"新用户登录返回异常: {data}" + assert user["refresh_token"], f"登录必须同时返回 refreshToken,否则无法刷新: {data}" created.append(user) return user diff --git a/tests/e2e/http_semantics_test.py b/tests/e2e/http_semantics_test.py index fa7de2f..ad8dfe2 100644 --- a/tests/e2e/http_semantics_test.py +++ b/tests/e2e/http_semantics_test.py @@ -133,3 +133,38 @@ def test_nested_operator_endpoints_still_require_operator_role(api, user_token): assert resp.status_code == 403, ( f"用户 token 访问嵌套运营端点 {path} 应回 403,实际 {resp.status_code}" ) + + +# 业务拒绝(Service 层主动 throw)必须回 4xx 而不是 500。 +# +# 2026-09-22 审计:全仓 121 处裸 `throw new RuntimeException(...)` 经兜底处理器 +# 一律变成 500。这比「状态码不精确」严重得多: +# * 客户端把「活动不存在」读成「服务端故障」,于是重试,而重试必然再失败; +# * 监控 / APM 的 5xx 指标被业务拒绝污染,真实故障淹没在噪声里; +# * 网关可能对 5xx 触发熔断,一个用户传错 ID 就能影响其他用户。 +# 收敛后由 ApiResponseCode 自带 HTTP 语义(400 改请求 / 403 别重试 / 404 不存在 / +# 409 先查状态)。这里在活栈上钉住这条链路,单元测试证明不了 Service 抛出的异常 +# 真的走到了正确的处理器。 +def test_service_thrown_rejections_are_4xx_not_5xx(api, new_user): + """Service 层主动拒绝 → 4xx,且带业务码(用于区分「谁的问题」)。""" + user = new_user() + cases = [ + # 活动不存在 → NOT_FOUND(此前裸抛 → 500) + ("POST", f"/activities/{MISSING_UUID}/register", user["token"], + {"emergencyContactName": "张三", "emergencyContactPhone": "13800138000"}, + (404,)), + # 举报自己 → BAD_REQUEST(此前裸抛 → 500) + ("POST", "/risk/reports", user["token"], + {"reportedUserId": user["user_id"], "reasonType": "fraud", "description": "自举报"}, + (400,)), + ] + for method, path, token, payload, expected in cases: + resp = api.call(method, path, token=token, json=payload) + assert resp.status_code in expected, ( + f"{method} {path} 应回 {expected},实际 {resp.status_code}。" + f"若为 500,说明该拒绝仍是裸 RuntimeException 或未映射的异常类型:" + f"{resp.text[:300]}" + ) + body = resp.json() + assert body.get("success") is False, f"{path} 的 success 应为 false:{body}" + assert body.get("code"), f"{path} 必须带业务码,否则客户端无法分辨原因:{body}" diff --git a/ui-tests/miniapp-auth-contracts.mjs b/ui-tests/miniapp-auth-contracts.mjs index cf0d4a4..a29df07 100644 --- a/ui-tests/miniapp-auth-contracts.mjs +++ b/ui-tests/miniapp-auth-contracts.mjs @@ -4,12 +4,17 @@ import { createRequire } from 'node:module' /** * 小程序鉴权契约守卫 * - * 覆盖三类此前只在真机/开发者工具上才会暴露的缺陷(2026-09-22 首页 401 事故): + * 覆盖此前只在真机/开发者工具上才会暴露的缺陷(2026-09-22 首页 401 事故及其延伸): * 1. hydrate:没有 access token 时不得恢复登录态。 - * 2. 登录/刷新:后端返回字段是 accessToken(不是 token),若字段名写错, - * token 永远写不进 storage → 每个请求都不带 Authorization → 全站 401。 - * 3. 刷新:后端 /auth/refresh 需要 userId(用于定位 Redis 中的 refresh token), - * 缺失会直接 400,导致刷新失败、登录态被误清。 + * 2. 登录:后端返回字段是 accessToken(不是 token),写错则 token 永远进不了 + * storage → 每个请求都不带 Authorization → 全站 401。 + * 3. 登录:后端返回 isNewUser(不是 needPhone),写错则 `!undefined === true` + * → 每个用户一登录就被标记成「已绑手机号」。 + * 4. 刷新:/auth/refresh 需要 refreshToken(userId 可选,由令牌 subject 自证)。 + * 5. 递归:/auth/* 自身返回 401 时不得触发 onSessionExpired —— 否则 + * onSessionExpired → /auth/refresh → 401 → onSessionExpired 自我递归。 + * + * 这些都是 `.js` 层缺陷,tsc 管不到(类型声明写的是 any),只有行为断言能兜住。 */ const removed = [] const storage = {} @@ -38,8 +43,15 @@ assert.ok(removed.includes('isLoggedIn'), '应清理失真的登录快照') const api = require('../miniapp/utils/api.js') -// ---- 2. 登录:必须持久化 accessToken(后端字段名为 accessToken)---- -api.post = async () => ({ accessToken: 'ACC-1', refreshToken: 'REF-1', userId: 'u-1', expiresIn: 7200 }) +// ---- 2. 登录:必须持久化 accessToken,并按 isNewUser 判定手机号绑定 ---- +api.post = async () => ({ + accessToken: 'ACC-1', + refreshToken: 'REF-1', + userId: 'u-1', + expiresIn: 7200, + isNewUser: true, // 后端语义:true = 还没有手机号 +}) +assert.ok(authStore.loginWithWechat, '登录方法必须存在') await authStore.loginWithWechat('wx-code') assert.equal( storage.accessToken, @@ -47,8 +59,36 @@ assert.equal( '登录后必须把后端返回的 accessToken 写入 storage(字段名写成 token 会落空 → 全站 401)', ) assert.equal(storage.refreshToken, 'REF-1', '登录后必须写入 refreshToken') +assert.equal( + authStore.state.phoneBound, + false, + 'isNewUser=true 表示尚未绑定手机号,phoneBound 必须为 false(读 needPhone 会得到 !undefined === true)', +) + +// 反向:已绑号用户必须被标成已绑定,防止把 phoneBound 写死成 false 也能过 +const boundStore = require('../miniapp/stores/authStore.js') +api.post = async () => ({ + accessToken: 'ACC-9', + refreshToken: 'REF-9', + userId: 'u-9', + expiresIn: 7200, + isNewUser: false, +}) +await boundStore.loginWithWechat('wx-code-2') +assert.equal( + boundStore.state.phoneBound, + true, + 'isNewUser=false 表示已绑手机号,phoneBound 必须为 true(映射方向不得反)', +) + +// ---- 3. 刷新:必须带 refreshToken + userId,并持久化新的 accessToken ---- +// 期望值从当前登录态动态取(上面做过两次登录),避免硬编码与执行顺序耦合。 +// 前置断言保证动态期望不是「两边都是 undefined」的假通过。 +const expectedRefreshToken = storage.refreshToken +const expectedUserId = authStore.state.userId +assert.ok(expectedRefreshToken, '前置:storage 中必须有 refreshToken') +assert.ok(expectedUserId, '前置:登录态中必须有 userId') -// ---- 3. 刷新:必须带 userId + refreshToken,并持久化新的 accessToken ---- let refreshBody = null api.post = async (url, body) => { if (url === '/auth/refresh') { @@ -59,12 +99,57 @@ api.post = async (url, body) => { } await authStore.refresh() assert.ok(refreshBody, '刷新必须发起 /auth/refresh 请求') +assert.equal(refreshBody.refreshToken, expectedRefreshToken, '刷新请求必须携带 refreshToken') assert.equal( refreshBody.userId, - 'u-1', - '刷新请求必须携带 userId(后端据此定位 Redis refresh token,缺失会返回 400)', + expectedUserId, + '刷新请求必须携带 userId(后端会校验它与令牌主体一致,串号需被拦下)', ) -assert.equal(refreshBody.refreshToken, 'REF-1', '刷新请求必须携带 refreshToken') +assert.equal(refreshBody.userId, authStore.state.userId, 'userId 必须取自当前登录态') assert.equal(storage.accessToken, 'ACC-2', '刷新成功后必须持久化新的 accessToken') +// ---- 4. /auth/* 返回 401 不得递归触发 Session 失效 ---- +delete api.post // 解除上面的桩,回到真实的 request 路径 +delete api.get + +let sessionExpiredCalls = 0 +api.onSessionExpired = () => { + sessionExpiredCalls += 1 +} +globalThis.wx.request = (opts) => { + opts.success({ + statusCode: 401, + data: { success: false, code: 'UNAUTHORIZED', message: 'Refresh Token 无效或已过期' }, + }) +} + +await api.post('/auth/refresh', { refreshToken: 'dead' }).then( + () => { + throw new Error('401 必须 reject') + }, + (err) => { + assert.equal(err.code, 'UNAUTHORIZED', '应把服务端业务码透传给调用方') + }, +) +assert.equal( + sessionExpiredCalls, + 0, + '/auth/* 返回 401 不得触发 onSessionExpired —— 否则刷新与失效互相递归,页面会无限打 /auth/refresh', +) +assert.equal(storage.accessToken, 'ACC-2', '/auth/* 的 401 不得擅自清 token,清理由 AuthStore 判定') + +// 对照:业务端点的 401 仍必须触发 Session 失效流程(守卫不得把正常路径一起挡掉) +await api.get('/home').then( + () => { + throw new Error('401 必须 reject') + }, + () => {}, +) +assert.equal( + sessionExpiredCalls, + 1, + '业务端点 401 必须触发 onSessionExpired(否则不会尝试刷新,用户被直接踢出)', +) +assert.equal(storage.accessToken, undefined, '业务端点 401 应清理本地 token') + console.log('Miniapp auth contracts passed')