fix(auth): 修复刷新令牌链路三层断点,并把 121 处裸异常收敛为携带 HTTP 语义的异常

用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」这条路整条是断的,
且断在三个互不相干的地方 —— 每修好一层,下一层才显形:

1) 前端漏传 userId → /auth/refresh 直接 400(上一笔 63c18d8 已修)
2) 后端把 refresh token 交给 validateToken 校验,而那是用 access 密钥验签的,
   refresh token 由 refresh 密钥签发 → 必然失败。这是本次的核心修复。
3) 失败被抛成裸 RuntimeException → HTTP 500,客户端当作服务端故障重试。
4) 换发出来的新 refresh token 与旧 token **逐字节相同** —— JWT 的 iat 只有秒级精度、
   且未设 jti,同一用户同一秒内签发的令牌完全一致,「刷新即轮换、旧令牌作废」
   这条安全属性形同不存在(Redis 比对旧值自然相等)。三处签发补 UUID jti。

第 2 层此前被两层掩盖:前端漏传 userId 使请求先 400 走不到验签;且全仓
没有任何 /auth/refresh 用例。第 4 层则只在「同一秒内签发两次」时才暴露,
是新增 E2E 用例(换发后立刻用旧令牌再刷一次)把它逼出来的。

同时修复审计中发现的同族问题:
- AnalyticsService 用的是 shared.exception.BusinessException,而全局处理器只映射
  嵌套类 GlobalExceptionHandler.BusinessException —— 该异常无任何 handler,直落 500。
  现让旧类继承被映射的类,从结构上杜绝「未映射的业务异常静默 500」。
- 小程序 authStore 读 result.needPhone,后端返回 isNewUser → `!undefined === true`,
  每个用户一登录就被标记成已绑手机号。同族的第三个字段名不一致缺陷。
- 小程序 api.js 的 401 分支会无条件触发 onSessionExpired→refresh;后端把刷新失败
  语义修正为 401 后,这条路径会自我递归。补 /auth/* 守卫。
- 登录页只认 instanceof Error,把服务端文案(如「账号已被封禁」)丢了。

后端异常收敛(121 处 → 0):
- 业务拒绝改抛 BusinessException + ApiResponseCode(404 不存在 / 403 无权与状态不允许 /
  409 冲突重复 / 400 参数非法),文案一字未改,避免测试与前端文案契约漂移
- 真正的服务端故障(加解密、文件头、上传、签名、实名 Provider)改为显式
  ServerErrorException:仍是 500,但意图明确、ERROR 记全栈、不向客户端泄露内部细节
- 用带显式映射表的脚本执行(fail-closed,遇未知文案即中止),不用启发式规则

守卫(先有判别力再谈全绿):
- 新增 JwtTokenServiceRefreshTokenTest:钉住「refresh 用 refresh 密钥验签」与
  「两类令牌不得互相顶替」(含两把密钥被配成同值时的 type 声明兜底),
  以及「同一秒内签发的两个令牌必须逐字节不同」
- 新增 RuntimeExceptionThrowGuardTest:扫源码断言 main 零裸抛(无白名单副本)
- GlobalExceptionHandlerTest 补三条:别名的实例必须按其业务码回 403 而非 500、
  无法识别的字符串码 fail-closed 到 500(不得退化成无信息的 400)、
  以及继承关系守卫(把别名类改回 RuntimeException 会静默恢复该缺陷,只有它能咬住)
- 新增 tests/e2e/auth_refresh_test.py:唯一覆盖 /auth/refresh 的用例,
  断言换发的 accessToken 真能过鉴权、旧令牌轮换失效、无效凭据 401 而非 500
- http_semantics_test.py 补「Service 层拒绝必须是 4xx 而非 5xx」
- miniapp-auth-contracts.mjs 补 isNewUser 映射(双向)与 /auth/* 401 不递归

验证:
- 定向后端测试 9/9
- 全量后端测试 113 个测试类、0 失败、BUILD SUCCESS
- 本机真实栈 E2E 72 passed(含 7 个新增刷新用例)
- miniapp tsc 0 错误、UI 契约门控 18/18
- 负向注入两处精准变红(RuntimeExceptionThrowGuardTest 与
  JwtTokenServiceRefreshTokenTest 各 1 例),BUILD FAILURE,
  注入前后工作区 SHA256 逐字节一致(零污染)
This commit is contained in:
WorkBuddy
2026-09-22 21:41:30 +08:00
parent 2162612892
commit 825fd41ee4
36 changed files with 1097 additions and 178 deletions
@@ -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<String, Object> 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<String, Object> 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<String, Object> checkCanWithdraw(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
Map<String, Object> blockers = new HashMap<>();
@@ -247,7 +249,7 @@ public class AccountLifecycleService {
public Map<String, Object> 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<String, Object> 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);
@@ -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用户,排除自己)
@@ -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;
}
@@ -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);
}
}
@@ -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<Map<String, Object>> 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<Map<String, Object>> summary = new ArrayList<>();
LocalDate today = LocalDate.now();
@@ -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<String, Object> 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<Appeal> 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<String, Object> 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);
@@ -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<Map<String, Object>> wechatLogin(@RequestBody Map<String, String> 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<String, String> 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 可选
*
* <p>userId 不再是必填:refresh token 的 subject 就是 userId,令牌本身自证身份。
* 此前强制要求客户端重复传 userId,漏传即 400 —— 小程序 2026-09-22 的
* 首页 401 事故正是被这个 400 放大的。显式传入时仍会被校验与令牌主体一致。
*/
@PostMapping("/refresh")
public ApiResponse<Map<String, Object>> refreshToken(@RequestBody Map<String, String> 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));
}
/**
@@ -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
*
* <p><b>2026-09-22 修复三处缺陷</b>:
* <ol>
* <li>验签用错了密钥:refresh token 由 refresh 密钥签发,此前却调
* {@code validateToken}(用 access 密钥验签)→ 必然失败,
* 于是「刷新」这条路在修好前端字段名之后仍然走不通。改用
* {@link JwtTokenService#validateRefreshToken}。</li>
* <li>失败语义是 500:客户端据此判断为服务端故障并重试,实际应回 401
* 让它停止重试、走重新登录。改抛 {@link UnauthorizedException}。</li>
* <li>userId 被强制要求:refresh token 的 subject 本身就是 userId,
* 让客户端重复传一个它已持有凭据里已包含的信息,只会制造
* 「漏传 → 400」这类无意义失败。改为**以令牌主体为准**,
* 显式传入时仅用于一致性校验。</li>
* </ol>
*/
public Map<String, Object> 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, "账号已暂停");
}
}
@@ -165,11 +165,20 @@ public class OperatorAuthService {
/**
* 刷新 Operator Token
*
* <p>与 {@link AuthService#refreshToken} 对齐两点(2026-09-22):
* 令牌先自证(签名/有效期/type,用 refresh 密钥),再比对 Redis 登记值;
* 失败回 401 而非 500 —— 否则客户端会把「该重新登录」误判为服务端故障并重试。
*/
public Map<String, Object> 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<String, Object> operatorInfo = getMe(operatorId);
@@ -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 的用途声明值。
*
* <p>校验时必须匹配,见 {@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。
*
* <p><b>必须用 refresh 密钥验签</b>:{@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。
*
* <p>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 过期时间
*/
@@ -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<String, Object> 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);
@@ -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);
@@ -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();
@@ -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<UserPhoto> 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. 保存记录
@@ -96,7 +96,7 @@ public class ReferralService {
new LambdaQueryWrapper<ActivityRegistration>()
.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);
}
}
@@ -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<String, Object> 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,
@@ -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<String> 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<String> 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<String, Object> 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<String, Object> 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;
}
@@ -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<String, Object> 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<String, Object> getReportDetail(String reportId) {
Report report = reportMapper.selectById(reportId);
if (report == null) {
throw new RuntimeException("举报不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
@@ -187,17 +189,17 @@ public class ReportService {
public Map<String, Object> 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);
@@ -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);
}
@@ -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);
}
}
@@ -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;
/**
* 兼容别名:以**字符串**业务码构造的业务异常。
*
* <p><b>2026-09-22 修复记录(不要改回独立继承 RuntimeException)。</b>
* 本类此前直接 {@code extends RuntimeException},与
* {@link GlobalExceptionHandler.BusinessException} 是两个互不相干的类。
* 而 {@code GlobalExceptionHandler} 里的 {@code @ExceptionHandler(BusinessException.class)}
* 按 Java 作用域解析到的是**自己的嵌套类**,因此本类的实例落到
* {@link GlobalExceptionHandler#handleUnknownException},一律变成 **HTTP 500**:
* 调用 {@code AnalyticsService.getDailySummary(9999)} 本意是「days 越界」的 400,
* 实际返回「服务端内部错误,请联系管理员」。
*
* <p>修复方式不是逐个改调用点,而是让本类**继承**被映射的那个类——
* 这样无论谁再用旧名字抛异常,HTTP 语义都由业务码决定,不可能再静默 500。
*
* <p>新代码请直接用 {@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;
}
}
@@ -114,6 +114,20 @@ public class GlobalExceptionHandler {
return status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage()));
}
/**
* 显式标记的服务端故障 → 500。
*
* <p>与 {@link #handleUnknownException} 的区别只在**来源是否已知**:
* 这里是我们主动判定「这确实是服务端的问题」,因此不把 message 回给客户端
* (容器/IO/加密的报错常含路径与密钥线索),但按 ERROR 记全栈以便定位。
*/
@ExceptionHandler(ServerErrorException.class)
public ResponseEntity<ApiResponse<Void>> 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。
*
@@ -0,0 +1,39 @@
package com.danyang.match.platform.shared.exception;
/**
* 服务端自身故障的**显式**标记。
*
* <p>适用范围:加解密失败、文件头读取失败、对象存储上传失败、签到码签名失败、
* 外部实名认证 Provider 不可用 —— 这些确实是我们这边的问题,HTTP 500 正确,
* 客户端重试也无害。
*
* <p><b>为什么需要一个专门的类,而不是继续用裸 {@code RuntimeException}:</b>
* 裸 {@code RuntimeException} 无法与「忘了给业务拒绝挑状态码」区分开 ——
* 两者在源码上长得一模一样,落到
* {@link GlobalExceptionHandler#handleUnknownException} 都是 500。
* 于是 2026-09-22 审计时发现:全仓 121 处裸抛里,「活动不存在」「无权取消他人预约」
* 这类明确的客户端错误被一并报成「服务端内部错误,请联系管理员」,
* 客户端据此判定为故障并重试,而重试必然再失败。
*
* <p>有了这个类,裸 {@code RuntimeException} 就只剩一个含义:**漏标了**。
* {@code RuntimeExceptionThrowGuardTest} 据此把「main 源码零裸抛」钉成不变量 ——
* 判据是扫描源码,因此不需要维护一份会过期的白名单副本。
*
* <p><b>为什么放在这里而不是 GlobalExceptionHandler 内部</b>:本异常需要从
* {@code shared.crypto.EncryptionUtil} 这类基础设施代码抛出,让它们依赖一个
* Web 层的 {@code @RestControllerAdvice} 是方向错误的依赖;放在异常包里,
* 基础设施只依赖异常语义本身。
*
* <p>对外文案刻意不回落异常的 message(容器/IO/加密的报错常含路径与密钥线索),
* 与兜底处理器保持一致;堆栈按 ERROR 级记录以便定位。
*/
public class ServerErrorException extends RuntimeException {
public ServerErrorException(String message) {
super(message);
}
public ServerErrorException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -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<User> 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);
@@ -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);
@@ -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 的验签与用途隔离守卫。
*
* <p><b>为什么必须有这个测试</b>:2026-09-22 定位到「刷新 Token 永远失败 → 登录态被清 →
* 全站 401」的根因是 {@code AuthService.refreshToken} 调用了
* {@link JwtTokenService#validateToken} —— 而该方法用 **access 密钥**验签,
* refresh token 却由 **refresh 密钥**签发,两者在 dev/test/production 取值都不同
* (见 application-*.yml),因此签名校验必然失败。
*
* <p>这个缺陷此前完全没被发现,因为它有**两层掩盖**:
* <ol>
* <li>前端漏传 userId,/auth/refresh 先返回 400,根本走不到验签那一步;</li>
* <li>E2E 全仓没有任何 /auth/refresh 用例(只有登录 / 运营登录)。</li>
* </ol>
* 修掉第一层之后,第二层才会显形 —— 所以本测试同时钉住「验签用对密钥」
* 与「两类令牌不得互相顶替」两件事。
*
* <p>这是纯单元测试:不起 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<String> refreshTokens = new java.util.HashSet<>();
java.util.Set<String> 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");
}
}
@@ -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<ApiResponse<Void>> 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<Void> response = ApiResponse.error(ApiResponseCode.UNAUTHORIZED);
@@ -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(...)}。
*
* <p><b>为什么这是一个需要测试来守的约束</b>:裸 {@code RuntimeException} 没有携带
* HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成
* <b>500</b>。于是「活动不存在」「无权取消他人预约」「已经报名过该活动」这类
* 明确的客户端错误,被一并报成「服务端内部错误,请联系管理员」——
* 客户端据此判定为服务端故障并重试,而重试必然再失败,把「别重试」的语义
* 放大成故障风暴。2026-09-22 审计时全仓有 <b>121 处</b>。
*
* <p>收敛之后,{@code RuntimeException} 就只剩一个含义:**漏标了**。
* 真正的服务端故障请显式抛 {@link ServerErrorException}(仍是 500,但意图明确、
* 日志按 ERROR 记录、不向客户端泄露内部细节);业务拒绝请抛
* {@code GlobalExceptionHandler.BusinessException} 并带上
* {@link com.danyang.match.platform.shared.enums.ApiResponseCode}。
*
* <p><b>判据是扫描源码,不是维护白名单副本</b>——白名单会随重构过期,
* 而源码不会。这也是本项目对守卫的一贯要求(见 MEMORY 的守卫纪律)。
*
* <p>工作目录依赖:本仓 surefire 把 {@code workingDirectory} 设为
* {@code ${project.basedir}/..}(仓库根),故此处用仓库根相对路径。
*/
class RuntimeExceptionThrowGuardTest {
/**
* 允许 {@code throw} 与 {@code new} 之间有任意空白,以便跨行书写也被咬住。
*
* <p>{@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<Path> javaFiles;
try (Stream<Path> walk = Files.walk(MAIN_SOURCES)) {
javaFiles = walk.filter(p -> p.toString().endsWith(".java")).sorted().toList();
}
assertFalse(javaFiles.isEmpty(), "源码根下没有任何 .java 文件,守卫形同虚设");
List<String> 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\");"));
}
}