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:
+7
-5
@@ -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);
|
||||
|
||||
+5
-3
@@ -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用户,排除自己)
|
||||
|
||||
+26
-24
@@ -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;
|
||||
}
|
||||
|
||||
+7
-4
@@ -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();
|
||||
|
||||
+16
-14
@@ -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);
|
||||
|
||||
+12
-10
@@ -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, "账号已暂停");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -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 过期时间
|
||||
*/
|
||||
|
||||
+10
-8
@@ -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);
|
||||
|
||||
+5
-3
@@ -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);
|
||||
|
||||
+3
-1
@@ -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. 保存记录
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-5
@@ -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,
|
||||
|
||||
+23
-21
@@ -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);
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-15
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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。
|
||||
*
|
||||
|
||||
+39
@@ -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);
|
||||
|
||||
+9
-6
@@ -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);
|
||||
|
||||
+150
@@ -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");
|
||||
}
|
||||
}
|
||||
+40
-1
@@ -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);
|
||||
|
||||
+203
@@ -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\");"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user