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