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

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

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

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

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

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

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

验证:
- 定向后端测试 9/9
- 全量后端测试 113 个测试类、0 失败、BUILD SUCCESS
- 本机真实栈 E2E 72 passed(含 7 个新增刷新用例)
- miniapp tsc 0 错误、UI 契约门控 18/18
- 负向注入两处精准变红(RuntimeExceptionThrowGuardTest 与
  JwtTokenServiceRefreshTokenTest 各 1 例),BUILD FAILURE,
  注入前后工作区 SHA256 逐字节一致(零污染)
This commit is contained in:
WorkBuddy
2026-09-22 21:41:30 +08:00
parent 2162612892
commit 825fd41ee4
36 changed files with 1097 additions and 178 deletions
@@ -39,6 +39,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 账号生命周期服务
@@ -97,7 +99,7 @@ public class AccountLifecycleService {
public Map<String, Object> withdrawDatingService(String userId, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
// 检查是否有进行中的Match(不能直接退出)
@@ -135,7 +137,7 @@ public class AccountLifecycleService {
public Map<String, Object> resumeDatingService(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
user.setRecommendationEnabled(true);
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
@@ -162,7 +164,7 @@ public class AccountLifecycleService {
public Map<String, Object> checkCanWithdraw(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
Map<String, Object> blockers = new HashMap<>();
@@ -247,7 +249,7 @@ public class AccountLifecycleService {
public Map<String, Object> closeAccount(String userId, String operatorId, String reason) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
// 1. 注销前检查(信息性,不阻断):让 GET /can-withdraw 能把待处理业务展示给用户/运营。
@@ -332,7 +334,7 @@ public class AccountLifecycleService {
public Map<String, Object> withdrawConsent(String userId, String consentType) {
ConsentRecord record = consentRecordMapper.findByUserAndType(userId, consentType).orElse(null);
if (record == null) {
throw new RuntimeException("未找到该同意记录");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该同意记录");
}
record.setAgreed(false);
consentRecordMapper.updateById(record);
@@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 活动互选服务
@@ -59,17 +61,17 @@ public class ActivityInterestService {
// 1. 验证活动已结束
Activity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new RuntimeException("活动不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
}
if (!Constants.ACTIVITY_STATUS_ENDED.equals(activity.getStatus())) {
throw new RuntimeException("活动尚未结束,暂不支持反馈");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动尚未结束,暂不支持反馈");
}
// 2. 验证用户已报名且通过审核
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名未通过审核,无法提交反馈");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
}
// 3. 过滤有效的目标用户(必须是同活动的approved用户,排除自己)
@@ -30,6 +30,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
@Service
@RequiredArgsConstructor
@@ -110,9 +112,9 @@ public class ActivityService {
@Transactional
public Activity publishActivity(String activityId, String operatorId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus())) {
throw new RuntimeException("当前状态不允许发布");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许发布");
}
activity.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN);
if (activity.getRegistrationStartAt() == null) {
@@ -129,10 +131,10 @@ public class ActivityService {
@Transactional
public Activity cancelActivity(String activityId, String operatorId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus()) &&
!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("当前状态不允许取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许取消");
}
activity.setStatus(Constants.ACTIVITY_STATUS_CANCELLED);
activityMapper.updateById(activity);
@@ -220,9 +222,9 @@ public class ActivityService {
String emergencyContactName,
String emergencyContactPhoneEnc) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("活动未开放报名或已结束");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开放报名或已结束");
}
// Phase 15 修复:报名窗口此前完全不校验 —— registration_start_at / registration_end_at
// 形同虚设,活动一旦发布就永远可报名(运营设的截止时间无效)。
@@ -237,7 +239,7 @@ public class ActivityService {
if (existing.isPresent()
&& !Constants.REGISTRATION_STATUS_REJECTED.equals(existing.get().getStatus())
&& !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.get().getStatus())) {
throw new RuntimeException("已经报名过该活动");
throw new BusinessException(ApiResponseCode.ACTIVITY_ALREADY_REGISTERED, "已经报名过该活动");
}
// V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同)
@@ -265,7 +267,7 @@ public class ActivityService {
if (registrationMapper.reuseIfCapacityAvailable(
old.getId(), activityId, status,
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
throw new RuntimeException("活动名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满");
}
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
@@ -294,7 +296,7 @@ public class ActivityService {
if (registrationMapper.insertIfCapacityAvailable(
newId, activityId, userId, status,
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
throw new RuntimeException("活动名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满");
}
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
@@ -334,9 +336,9 @@ public class ActivityService {
@Transactional
public ActivityRegistration approveRegistration(String registrationId, String operatorId) {
ActivityRegistration registration = registrationMapper.selectById(registrationId);
if (registration == null) throw new RuntimeException("报名记录不存在");
if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在");
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核通过");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核通过");
}
// Phase 15(计划项③):服务站操作员只能审核被指派给自己的活动
@@ -355,7 +357,7 @@ public class ActivityService {
}
if (affected == 0) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
throw new RuntimeException("活动名额已满或状态已变更,无法通过审核");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满或状态已变更,无法通过审核");
}
// 回读事务内最新状态(条件更新不回填实体)
registration = registrationMapper.selectById(registrationId);
@@ -376,16 +378,16 @@ public class ActivityService {
@Transactional
public ActivityRegistration rejectRegistration(String registrationId, String operatorId, String reason) {
ActivityRegistration registration = registrationMapper.selectById(registrationId);
if (registration == null) throw new RuntimeException("报名记录不存在");
if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在");
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核");
}
assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
// Phase 15 修复:原实现为「读 pending → 全量 updateById」,两人同时审核时
// 一个通过、一个拒绝会互相覆盖。改为条件更新,后到者 affectedRows=0。
if (registrationMapper.rejectIfPending(registrationId, operatorId, reason) == 0) {
throw new RuntimeException("该报名状态已被他人变更,无法拒绝");
throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法拒绝");
}
registration = registrationMapper.selectById(registrationId);
@@ -408,11 +410,11 @@ public class ActivityService {
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不能取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
}
// 原子取消:状态已被他人变更(如已被拒绝)时 affectedRows=0,不得重复回补名额
if (registrationMapper.cancelIfActive(registration.getId()) == 0) {
throw new RuntimeException("该报名状态已被他人变更,无法取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法取消");
}
// 已通过的名额释放(总名额/性别名额由 SQL 实时统计自动释放;分组计数需显式回退)
if (Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
@@ -721,7 +723,7 @@ public class ActivityService {
log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage());
}
if (!verified) {
throw new RuntimeException("该活动需完成实名认证后方可报名");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需完成实名认证后方可报名");
}
}
@@ -733,7 +735,7 @@ public class ActivityService {
log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage());
}
if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) {
throw new RuntimeException("该活动需通过本地资格核验后方可报名");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需通过本地资格核验后方可报名");
}
}
}
@@ -746,10 +748,10 @@ public class ActivityService {
if (birth == null) return; // 资料不全时不阻断报名
int age = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
if (activity.getMinAge() != null && age < activity.getMinAge()) {
throw new RuntimeException("报名年龄未满 " + activity.getMinAge() + " 周岁");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄未满 " + activity.getMinAge() + " 周岁");
}
if (activity.getMaxAge() != null && age > activity.getMaxAge()) {
throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
}
}
@@ -782,10 +784,10 @@ public class ActivityService {
private void assertWithinRegistrationWindow(Activity activity) {
Instant now = Instant.now();
if (activity.getRegistrationStartAt() != null && now.isBefore(activity.getRegistrationStartAt())) {
throw new RuntimeException("活动报名尚未开始");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名尚未开始");
}
if (activity.getRegistrationEndAt() != null && now.isAfter(activity.getRegistrationEndAt())) {
throw new RuntimeException("活动报名已截止");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名已截止");
}
}
@@ -835,7 +837,7 @@ public class ActivityService {
String groupCode = resolveCapacityGroupCode(activityId, gender);
if (groupCode == null) return null;
if (capacityGroupMapper.incrementIfAvailable(activityId, groupCode) == 0) {
throw new RuntimeException("该分组名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "该分组名额已满");
}
return groupCode;
}
@@ -26,6 +26,9 @@ import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
/**
* 签到服务
@@ -64,18 +67,18 @@ public class CheckinService {
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名尚未审核通过,无法签到");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
}
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
throw new RuntimeException("您已经签到过了");
throw new BusinessException(ApiResponseCode.ALREADY_CHECKED_IN, "您已经签到过了");
}
Activity activity = requireCheckableActivity(activityId);
Instant now = Instant.now();
if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) {
throw new RuntimeException("活动未开始或已结束,无法签到");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开始或已结束,无法签到");
}
String payload = userId + ":" + activityId + ":" + now.toEpochMilli();
@@ -290,7 +293,7 @@ public class CheckinService {
byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("签名生成失败", e);
throw new ServerErrorException("签名生成失败", e);
}
}
@@ -21,7 +21,9 @@ import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.shared.exception.BusinessException;
// 必须是 GlobalExceptionHandler.BusinessException —— shared.exception.BusinessException
// 此前是另一个未映射的类,抛出来直落 500(days 越界本该 400)。见该类 javadoc 的修复记录。
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
@@ -252,7 +254,7 @@ public class AnalyticsService {
public List<Map<String, Object>> getDailySummary(int days) {
if (days < 1 || days > 365) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "days 必须在 1~365 之间");
}
List<Map<String, Object>> summary = new ArrayList<>();
LocalDate today = LocalDate.now();
@@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 申诉服务
@@ -97,29 +99,29 @@ public class AppealService {
public Map<String, Object> submitAppeal(String userId, AppealSubmitDTO dto) {
// FeatureFlag 检查
if (!featureFlagConfig.isAppealEnabled()) {
throw new RuntimeException("申诉功能暂未开放");
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "申诉功能暂未开放");
}
if (dto == null || dto.getRelatedActionId() == null || dto.getRelatedActionId().isBlank()) {
throw new RuntimeException("缺少关联的处置记录");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少关联的处置记录");
}
// 验证关联的 risk_action 存在且仍有效
RiskAction targetAction = riskActionMapper.selectById(dto.getRelatedActionId());
if (targetAction == null) {
throw new RuntimeException("关联的处置记录不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联的处置记录不存在");
}
if (!Constants.RISK_ACTION_STATUS_ACTIVE.equals(targetAction.getStatus())) {
throw new RuntimeException("该处置已失效,无法申诉");
throw new BusinessException(ApiResponseCode.CONFLICT, "该处置已失效,无法申诉");
}
if (!userId.equals(targetAction.getUserId())) {
throw new RuntimeException("无权对此处置提交申诉");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权对此处置提交申诉");
}
// 必须是 ban 或 suspend 类型
if (!Constants.RISK_ACTION_BAN.equals(targetAction.getActionType()) &&
!Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(targetAction.getActionType())) {
throw new RuntimeException("当前处置类型不支持申诉");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "当前处置类型不支持申诉");
}
// Phase 18:校验 appealType 白名单(生产 chk_appeal_type),并必须与处置类型一致。
@@ -132,16 +134,16 @@ public class AppealService {
}
if (!Constants.APPEAL_TYPE_BAN.equals(appealType) &&
!Constants.APPEAL_TYPE_SUSPEND.equals(appealType)) {
throw new RuntimeException("无效的申诉类型");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉类型");
}
if (!expectedType.equals(appealType)) {
throw new RuntimeException("申诉类型与处置类型不匹配");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "申诉类型与处置类型不匹配");
}
// 防重:同一用户对同一 action 只能有一个 pending/reviewing 申诉
Optional<Appeal> existing = appealMapper.findActiveByUserAndAction(userId, dto.getRelatedActionId());
if (existing.isPresent()) {
throw new RuntimeException("您已就此处置提交过申诉,请等待处理结果");
throw new BusinessException(ApiResponseCode.CONFLICT, "您已就此处置提交过申诉,请等待处理结果");
}
Appeal appeal = new Appeal();
@@ -190,7 +192,7 @@ public class AppealService {
public AppealItemDTO getAppealDetail(String appealId) {
Appeal appeal = appealMapper.selectById(appealId);
if (appeal == null || appeal.getDeletedAt() != null) {
throw new RuntimeException("申诉不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在");
}
return toDTO(appeal);
}
@@ -202,10 +204,10 @@ public class AppealService {
public Map<String, Object> processAppeal(String appealId, AppealProcessDTO dto, String operatorId) {
Appeal appeal = appealMapper.selectById(appealId);
if (appeal == null || appeal.getDeletedAt() != null) {
throw new RuntimeException("申诉不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在");
}
if (dto == null || dto.getDecision() == null) {
throw new RuntimeException("无效的申诉决策");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策");
}
// Phase 18:原子「认领」——只有把申诉从可处理态(pending/reviewing/escalated)
@@ -213,7 +215,7 @@ public class AppealService {
// 同一申诉时,只有一个赢家能继续,另一个直接失败,避免 approve/reject 相互覆盖。
int claimed = appealMapper.markReviewingIfProcessable(appealId);
if (claimed != 1) {
throw new RuntimeException("该申诉已处理完毕");
throw new BusinessException(ApiResponseCode.CONFLICT, "该申诉已处理完毕");
}
// 回读最新状态(status 已为 reviewing)
@@ -224,7 +226,7 @@ public class AppealService {
case "approve" -> approveAppeal(fresh, dto.getDecisionNote(), operatorId);
case "reject" -> rejectAppeal(fresh, dto.getDecisionNote(), operatorId);
case "escalate" -> escalateAppeal(fresh, dto.getDecisionNote(), operatorId);
default -> throw new RuntimeException("无效的申诉决策");
default -> throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策");
}
saveAuditLog(operatorId, "PROCESS_APPEAL", "appeal", appealId, null, null);
@@ -1,6 +1,8 @@
package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.service.AuthService;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -26,8 +28,7 @@ public class AuthController {
public ApiResponse<Map<String, Object>> wechatLogin(@RequestBody Map<String, String> body) {
String code = body.get("code");
if (code == null || code.isBlank()) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "code 不能为空");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "code 不能为空");
}
return ApiResponse.ok(authService.wechatLogin(code));
}
@@ -44,8 +45,7 @@ public class AuthController {
@RequestBody Map<String, String> body) {
String phone = body.get("phone");
if (phone == null || phone.isBlank()) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "手机号不能为空");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
}
authService.bindPhone(principal.id(), phone);
return ApiResponse.ok();
@@ -54,17 +54,19 @@ public class AuthController {
/**
* 刷新 Token
* POST /api/v1/auth/refresh
* Body: { "userId": "...", "refreshToken": "..." }
* Body: { "refreshToken": "..." },userId 可选
*
* <p>userId 不再是必填:refresh token 的 subject 就是 userId,令牌本身自证身份。
* 此前强制要求客户端重复传 userId,漏传即 400 —— 小程序 2026-09-22 的
* 首页 401 事故正是被这个 400 放大的。显式传入时仍会被校验与令牌主体一致。
*/
@PostMapping("/refresh")
public ApiResponse<Map<String, Object>> refreshToken(@RequestBody Map<String, String> body) {
String userId = body.get("userId");
String refreshToken = body.get("refreshToken");
if (userId == null || refreshToken == null) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "参数缺失");
if (refreshToken == null || refreshToken.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "refreshToken 不能为空");
}
return ApiResponse.ok(authService.refreshToken(userId, refreshToken));
return ApiResponse.ok(authService.refreshToken(body.get("userId"), refreshToken));
}
/**
@@ -7,6 +7,9 @@ import com.danyang.match.platform.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.capability.service.CapabilityService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.UnauthorizedException;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
@@ -58,7 +61,7 @@ public class AuthService {
String unionid = session.get("unionid");
if (openid == null || openid.isBlank()) {
throw new RuntimeException("微信登录失败:无法获取 openid");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
}
// 查找或创建用户
@@ -113,7 +116,7 @@ public class AuthService {
// 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
if (!existing.getId().equals(userId)) {
throw new RuntimeException("该手机号已被其他账号绑定");
throw new BusinessException(ApiResponseCode.CONFLICT, "该手机号已被其他账号绑定");
}
});
@@ -126,17 +129,43 @@ public class AuthService {
/**
* 刷新 Token
*
* <p><b>2026-09-22 修复三处缺陷</b>:
* <ol>
* <li>验签用错了密钥:refresh token 由 refresh 密钥签发,此前却调
* {@code validateToken}(用 access 密钥验签)→ 必然失败,
* 于是「刷新」这条路在修好前端字段名之后仍然走不通。改用
* {@link JwtTokenService#validateRefreshToken}。</li>
* <li>失败语义是 500:客户端据此判断为服务端故障并重试,实际应回 401
* 让它停止重试、走重新登录。改抛 {@link UnauthorizedException}。</li>
* <li>userId 被强制要求:refresh token 的 subject 本身就是 userId,
* 让客户端重复传一个它已持有凭据里已包含的信息,只会制造
* 「漏传 → 400」这类无意义失败。改为**以令牌主体为准**,
* 显式传入时仅用于一致性校验。</li>
* </ol>
*/
public Map<String, Object> refreshToken(String userId, String refreshToken) {
// 验证 Redis 中存储的 refresh token
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new RuntimeException("Refresh Token 无效或已过期");
if (refreshToken == null || refreshToken.isBlank()) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 验证 JWT
if (!jwtTokenService.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token 无效");
// 1. 令牌自证:签名有效(refresh 密钥)、未过期、type=refresh、未被撤销
if (!jwtTokenService.validateRefreshToken(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 2. 用户身份以令牌主体为准;客户端显式传入时必须是同一个用户,
// 否则就是拿 A 的令牌去刷 B 的会话。
String tokenUserId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
if (userId != null && !userId.isBlank() && !userId.equals(tokenUserId)) {
throw new UnauthorizedException("Refresh Token 与用户不匹配");
}
userId = tokenUserId;
// 3. 校验 Redis 中登记的令牌:登出或换发后,旧令牌立即失效(可撤销性)
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 签发新 Token
@@ -151,6 +180,7 @@ public class AuthService {
result.put("accessToken", newAccessToken);
result.put("refreshToken", newRefreshToken);
result.put("expiresIn", accessTokenExpireMs / 1000);
result.put("userId", userId);
return result;
}
@@ -206,13 +236,13 @@ public class AuthService {
private void checkAccountStatus(User user) {
if (Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已注销");
throw new BusinessException(ApiResponseCode.ACCOUNT_CLOSED, "账号已注销");
}
if (Constants.ACCOUNT_STATUS_BANNED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已被封禁");
throw new BusinessException(ApiResponseCode.USER_BANNED, "账号已被封禁");
}
if (Constants.ACCOUNT_STATUS_SUSPENDED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已暂停");
throw new BusinessException(ApiResponseCode.USER_SUSPENDED, "账号已暂停");
}
}
@@ -165,11 +165,20 @@ public class OperatorAuthService {
/**
* 刷新 Operator Token
*
* <p>与 {@link AuthService#refreshToken} 对齐两点(2026-09-22):
* 令牌先自证(签名/有效期/type,用 refresh 密钥),再比对 Redis 登记值;
* 失败回 401 而非 500 —— 否则客户端会把「该重新登录」误判为服务端故障并重试。
*/
public Map<String, Object> refreshToken(String operatorId, String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()
|| !jwtTokenService.validateRefreshToken(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效");
}
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new RuntimeException("Refresh Token 无效");
throw new UnauthorizedException("Refresh Token 无效");
}
Map<String, Object> operatorInfo = getMe(operatorId);
@@ -10,11 +10,20 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class JwtTokenService {
/**
* Refresh Token 的用途声明值。
*
* <p>校验时必须匹配,见 {@link #validateRefreshToken}:一旦运维把两把密钥配成同一个值,
* 签名校验就无法区分两类令牌,只有这个声明能阻止 access token 被当作 refresh token 续期。
*/
public static final String TOKEN_TYPE_REFRESH = "refresh";
private final TokenBlacklistService tokenBlacklistService;
@Value("${jwt.access-token-secret}")
@@ -46,6 +55,12 @@ public class JwtTokenService {
JwtBuilder builder = Jwts.builder()
.subject(userId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "access")
@@ -67,9 +82,15 @@ public class JwtTokenService {
return Jwts.builder()
.subject(userId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "refresh")
.claim("type", TOKEN_TYPE_REFRESH)
.signWith(getRefreshKey())
.compact();
}
@@ -83,6 +104,12 @@ public class JwtTokenService {
JwtBuilder builder = Jwts.builder()
.subject(operatorId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "operator_access")
@@ -141,6 +168,52 @@ public class JwtTokenService {
return parseToken(token).getSubject();
}
/**
* 验证并解析 **Refresh** Token。
*
* <p><b>必须用 refresh 密钥验签</b>:{@link #generateRefreshToken} 用
* {@code jwt.refresh-token-secret} 签发,而 {@link #parseToken} 用的是
* {@code jwt.access-token-secret}。两把密钥在 dev / test / production 的取值
* 各不相同(见 application-*.yml),因此拿 {@code parseToken} 去验 refresh token
* 必然抛签名异常 —— 这正是 2026-09-22 定位到的「刷新永远失败 → 登录态被清 →
* 全站 401」的根因:前端此前修好了缺 userId 的 400,仍会在这一层必然失败。
*/
public Claims parseRefreshToken(String token) {
return Jwts.parser()
.verifyWith(getRefreshKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 校验 Refresh Token:签名有效、未过期、未被撤销,且 {@code type} 必须为 refresh。
*
* <p>type 断言不是冗余:一旦运维把两把密钥配成同一个值(或都用了默认占位值),
* 签名校验就无法区分两类令牌,access token 会被当作 refresh token 换出新令牌,
* 使「短期凭证」获得「长期凭证」的续期能力。此处把令牌用途钉死在声明里。
*/
public boolean validateRefreshToken(String token) {
if (token == null || token.isBlank()) {
return false;
}
try {
if (tokenBlacklistService.isBlacklisted(token)) {
return false;
}
return TOKEN_TYPE_REFRESH.equals(parseRefreshToken(token).get("type", String.class));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* 从 Refresh Token 解析用户 ID(用于客户端未传 userId 时自证身份,见 AuthService#refreshToken)。
*/
public String getUserIdFromRefreshToken(String token) {
return parseRefreshToken(token).getSubject();
}
/**
* 获取 Token 过期时间
*/
@@ -24,6 +24,8 @@ import java.time.Instant;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
@Service
@RequiredArgsConstructor
@@ -85,9 +87,9 @@ public class MatchmakerServiceService {
@Transactional
public MatchmakerServiceRecord assignService(String serviceId, String operatorId, String organizationId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new RuntimeException("当前状态不允许分配");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
}
service.setAssignedOperatorId(operatorId);
@@ -107,9 +109,9 @@ public class MatchmakerServiceService {
@Transactional
public MatchmakerServiceRecord claimService(String operatorId, String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
throw new RuntimeException("当前状态不允许接单");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许接单");
}
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
@@ -141,7 +143,7 @@ public class MatchmakerServiceService {
public Map<String, Object> recommendPartner(String serviceId, String operatorId,
String targetUserId, String reason) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐
if (!operatorId.equals(service.getAssignedOperatorId())) {
@@ -206,10 +208,10 @@ public class MatchmakerServiceService {
@Transactional
public MatchmakerServiceRecord completeService(String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
throw new RuntimeException("当前状态不能完成");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
}
service.setStatus(Constants.MATCHMAKER_STATUS_COMPLETED);
@@ -227,7 +229,7 @@ public class MatchmakerServiceService {
@Transactional
public MatchmakerServiceRecord cancelService(String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
service.setStatus(Constants.MATCHMAKER_STATUS_CANCELLED);
serviceMapper.updateById(service);
log.info("红娘服务取消: serviceId={}", serviceId);
@@ -14,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
@Service
@RequiredArgsConstructor
@@ -73,7 +75,7 @@ public class OperatorManagementService {
@Transactional
public void disableOperator(String operatorId, String byOperatorId) {
Operator operator = operatorMapper.selectById(operatorId);
if (operator == null) throw new RuntimeException("Operator不存在");
if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在");
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
operator.setUpdatedAt(Instant.now());
operatorMapper.updateById(operator);
@@ -82,7 +84,7 @@ public class OperatorManagementService {
@Transactional
public void enableOperator(String operatorId, String byOperatorId) {
Operator operator = operatorMapper.selectById(operatorId);
if (operator == null) throw new RuntimeException("Operator不存在");
if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在");
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
operator.setUpdatedAt(Instant.now());
operatorMapper.updateById(operator);
@@ -91,7 +93,7 @@ public class OperatorManagementService {
@Transactional
public void resetPassword(String operatorId, String newPassword, String byOperatorId) {
Operator operator = operatorMapper.selectById(operatorId);
if (operator == null) throw new RuntimeException("Operator不存在");
if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在");
operator.setPasswordHash(passwordEncoder.encode(newPassword));
operator.setUpdatedAt(Instant.now());
operatorMapper.updateById(operator);
@@ -18,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* Operator 管理服务
@@ -47,7 +49,7 @@ public class OperatorService {
// 校验唯一性
operatorMapper.findByUsername(username).ifPresent(op -> {
throw new RuntimeException("用户名已存在: " + username);
throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在: " + username);
});
Operator operator = new Operator();
@@ -29,6 +29,8 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
@Service
public class PhotoService {
@@ -96,7 +98,7 @@ public class PhotoService {
List<UserPhoto> userPhotos = photoMapper.findByUserIdOrderBySort(userId);
boolean owned = userPhotos.stream().anyMatch(p -> fileKey.equals(p.getFileKey()));
if (!owned) {
throw new RuntimeException("无权访问该图片");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权访问该图片");
}
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(SIGNED_URL_EXPIRE_HOURS));
@@ -198,13 +200,13 @@ public class PhotoService {
boolean isLocalEvidence = "local_eligibility".equals(photoType);
if (isLocalEvidence) {
if (!isAllowedLocalEvidenceMimeType(contentType)) {
throw new RuntimeException("不支持的佐证文件格式: " + contentType);
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的佐证文件格式: " + contentType);
}
} else if (!isAllowedMimeType(contentType)) {
throw new RuntimeException("不支持的图片格式: " + contentType);
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的图片格式: " + contentType);
}
if (!validateSize(file, photoType)) {
throw new RuntimeException("文件大小超过限制");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件大小超过限制");
}
// 2. 魔数校验(仅图片;PDF 佐证跳过——Content-Type 已由白名单约束,
@@ -214,10 +216,10 @@ public class PhotoService {
try (InputStream headerStream = file.getInputStream()) {
header = headerStream.readNBytes(12);
} catch (IOException e) {
throw new RuntimeException("读取文件头失败", e);
throw new ServerErrorException("读取文件头失败", e);
}
if (header.length < 3 || !validateMagicNumber(header)) {
throw new RuntimeException("无效的图片文件头");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的图片文件头");
}
}
@@ -236,7 +238,7 @@ public class PhotoService {
setContentLength(file.getSize());
}});
} catch (IOException e) {
throw new RuntimeException("上传文件失败", e);
throw new ServerErrorException("上传文件失败", e);
}
// 4. 保存记录
@@ -96,7 +96,7 @@ public class ReferralService {
new LambdaQueryWrapper<ActivityRegistration>()
.eq(ActivityRegistration::getActivityId, activityId)
.eq(ActivityRegistration::getUserId, userId));
if (existing != null) throw new RuntimeException("用户已报名该活动");
if (existing != null) throw new BusinessException(ApiResponseCode.ACTIVITY_ALREADY_REGISTERED, "用户已报名该活动");
String operatorName = getOperatorName(matchmakerId);
ActivityRegistration reg = new ActivityRegistration();
@@ -179,7 +179,7 @@ public class ReferralService {
result.put("confirmedAt", reg.getUserConfirmedAt());
return result;
} else {
throw new RuntimeException("不支持的转介类型: " + targetType);
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的转介类型: " + targetType);
}
}
@@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 关系服务
@@ -53,12 +55,12 @@ public class RelationshipService {
public Map<String, Object> like(String userId, String targetUserId) {
// 检查是否已Block
if (isBlocked(userId, targetUserId)) {
throw new RuntimeException("对方已将你拉黑,无法表达喜欢");
throw new BusinessException(ApiResponseCode.USER_BLOCKED, "对方已将你拉黑,无法表达喜欢");
}
// 检查是否已Like
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED).isPresent()) {
throw new RuntimeException("已经表达过喜欢");
throw new BusinessException(ApiResponseCode.ALREADY_LIKED, "已经表达过喜欢");
}
// 原子插入
@@ -117,7 +119,7 @@ public class RelationshipService {
public void pass(String userId, String targetUserId) {
// 检查是否已Block(Block优先于Pass)
if (isBlocked(userId, targetUserId)) {
throw new RuntimeException("对方已将你拉黑");
throw new BusinessException(ApiResponseCode.USER_BLOCKED, "对方已将你拉黑");
}
// 原子插入(唯一约束保证幂等)
@@ -147,7 +149,7 @@ public class RelationshipService {
public void block(String userId, String targetUserId) {
// 检查是否已Block
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) {
throw new RuntimeException("已经拉黑过该用户");
throw new BusinessException(ApiResponseCode.ALREADY_BLOCKED, "已经拉黑过该用户");
}
// 插入Block关系
@@ -204,7 +206,7 @@ public class RelationshipService {
Match existing = matchMapper.findActiveMatch(userId, targetUserId).orElse(null);
if (existing != null) {
// 已存在有效Match,不需要重复创建
throw new RuntimeException("匹配已存在");
throw new BusinessException(ApiResponseCode.CONFLICT, "匹配已存在");
}
// Phase 12 复验修复(D5):本方法此前不设置 episode / lastActivityAt,
@@ -30,6 +30,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
@Service
@RequiredArgsConstructor
@@ -172,10 +174,10 @@ public class ReviewService {
public ReviewResultDTO approve(String taskId, String operatorId, String comment) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核");
}
// 更新任务状态
@@ -210,10 +212,10 @@ public class ReviewService {
public ReviewResultDTO needEdit(String taskId, String operatorId, String comment, List<String> reasonCodes) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核");
}
task.setStatus(Constants.REVIEW_STATUS_NEED_EDIT);
@@ -244,10 +246,10 @@ public class ReviewService {
public ReviewResultDTO reject(String taskId, String operatorId, String comment, List<String> reasonCodes) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核");
}
task.setStatus(Constants.REVIEW_STATUS_REJECTED);
@@ -281,7 +283,7 @@ public class ReviewService {
.orElseThrow(() -> new RuntimeException("未找到可重新提交的审核任务"));
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
throw new RuntimeException("当前状态不允许重新提交");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
}
// 创建新版本的审核任务
@@ -336,13 +338,13 @@ public class ReviewService {
public ReviewResultDTO needEvidence(String taskId, String operatorId, String comment) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许操作");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作");
}
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
@@ -378,13 +380,13 @@ public class ReviewService {
public ReviewResultDTO approveLocalEligibility(String taskId, String operatorId, String comment) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许操作");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作");
}
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
@@ -422,13 +424,13 @@ public class ReviewService {
public ReviewResultDTO rejectLocalEligibility(String taskId, String operatorId, String comment) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许操作");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作");
}
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
@@ -479,21 +481,21 @@ public class ReviewService {
public Map<String, Object> accessLocalEligibilityEvidence(
String taskId, String evidenceId, String operatorId, String purpose) {
if (purpose == null || purpose.isBlank()) {
throw new RuntimeException("查看佐证必须填写访问目的");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "查看佐证必须填写访问目的");
}
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null || !Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
if (task != null) {
logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "denied");
}
throw new RuntimeException("本地资格审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "本地资格审核任务不存在");
}
try {
requireClaimOwner(task, operatorId);
LocalEligibility eligibility = requireLocalEligibility(task.getUserId());
LocalEligibilityEvidence evidence = localEligibilityEvidenceMapper.selectById(evidenceId);
if (evidence == null || !eligibility.getId().equals(evidence.getLocalEligibilityId())) {
throw new RuntimeException("佐证不存在或不属于当前审核任务");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "佐证不存在或不属于当前审核任务");
}
Map<String, Object> result = photoService.getSignedUrlForAuthorizedReview(evidence.getFileId());
logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "success");
@@ -520,7 +522,7 @@ public class ReviewService {
private void requireClaimOwner(ReviewTask task, String operatorId) {
if (!Objects.equals(operatorId, task.getAssignedOperatorId())) {
throw new RuntimeException("请先领取该审核任务,且仅领取人可以处理");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "请先领取该审核任务,且仅领取人可以处理");
}
}
@@ -532,7 +534,7 @@ public class ReviewService {
private LocalEligibility requireLocalEligibility(String userId) {
LocalEligibility eligibility = findLocalEligibility(userId);
if (eligibility == null) throw new RuntimeException("本地资格记录不存在");
if (eligibility == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "本地资格记录不存在");
return eligibility;
}
@@ -31,6 +31,8 @@ import java.util.Map;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 举报与风控服务
@@ -75,7 +77,7 @@ public class ReportService {
public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) {
// 不允许举报自己
if (reporterUserId.equals(dto.getReportedUserId())) {
throw new RuntimeException("不能举报自己");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己");
}
// 检查是否已被封禁
@@ -83,7 +85,7 @@ public class ReportService {
boolean isBanned = activeActions.stream()
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType()));
if (isBanned) {
throw new RuntimeException("您的账号已被封禁,无法提交举报");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "您的账号已被封禁,无法提交举报");
}
Report report = new Report();
@@ -150,7 +152,7 @@ public class ReportService {
public Map<String, Object> getReportDetail(String reportId) {
Report report = reportMapper.selectById(reportId);
if (report == null) {
throw new RuntimeException("举报不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
@@ -187,17 +189,17 @@ public class ReportService {
public Map<String, Object> processReport(String reportId, ReportProcessDTO dto, String operatorId) {
Report report = reportMapper.selectById(reportId);
if (report == null) {
throw new RuntimeException("举报不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
if (!Constants.REPORT_STATUS_PENDING.equals(report.getStatus()) &&
!Constants.REPORT_STATUS_PROCESSING.equals(report.getStatus())) {
throw new RuntimeException("该举报已处理完毕,不可重复操作");
throw new BusinessException(ApiResponseCode.CONFLICT, "该举报已处理完毕,不可重复操作");
}
String result = dto.getProcessResult();
if (result == null) {
throw new RuntimeException("请选择处理结果");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "请选择处理结果");
}
report.setStatus(Constants.REPORT_STATUS_RESOLVED);
@@ -60,10 +60,10 @@ public class ServiceAppointmentService {
public ServiceAppointmentDTO registerByOperator(String targetUserId, ServiceAppointmentRequest request,
String operatorId) {
if (targetUserId == null || targetUserId.isBlank()) {
throw new RuntimeException("代登记必须指定服务对象用户");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "代登记必须指定服务对象用户");
}
if (operatorId == null || operatorId.isBlank()) {
throw new RuntimeException("代登记必须由运营人员发起");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "代登记必须由运营人员发起");
}
return doCreate(targetUserId, request, Constants.APPOINTMENT_SOURCE_OPERATOR_ASSISTED, operatorId);
}
@@ -10,6 +10,7 @@ import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import com.danyang.match.platform.shared.exception.ServerErrorException;
/**
* 敏感数据加密工具
@@ -56,7 +57,7 @@ public class EncryptionUtil {
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new RuntimeException("加密失败", e);
throw new ServerErrorException("加密失败", e);
}
}
@@ -87,7 +88,7 @@ public class EncryptionUtil {
byte[] plainBytes = cipher.doFinal(encrypted);
return new String(plainBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("解密失败", e);
throw new ServerErrorException("解密失败", e);
}
}
@@ -106,7 +107,7 @@ public class EncryptionUtil {
byte[] hashBytes = digest.digest(idNumber.getBytes(StandardCharsets.UTF_8));
return Hex.toHexString(hashBytes);
} catch (Exception e) {
throw new RuntimeException("身份证 Hash 生成失败", e);
throw new ServerErrorException("身份证 Hash 生成失败", e);
}
}
@@ -1,23 +1,50 @@
package com.danyang.match.platform.shared.exception;
public class BusinessException extends RuntimeException {
private final String code;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
/**
* 兼容别名:以**字符串**业务码构造的业务异常。
*
* <p><b>2026-09-22 修复记录(不要改回独立继承 RuntimeException)。</b>
* 本类此前直接 {@code extends RuntimeException},与
* {@link GlobalExceptionHandler.BusinessException} 是两个互不相干的类。
* 而 {@code GlobalExceptionHandler} 里的 {@code @ExceptionHandler(BusinessException.class)}
* 按 Java 作用域解析到的是**自己的嵌套类**,因此本类的实例落到
* {@link GlobalExceptionHandler#handleUnknownException},一律变成 **HTTP 500**:
* 调用 {@code AnalyticsService.getDailySummary(9999)} 本意是「days 越界」的 400,
* 实际返回「服务端内部错误,请联系管理员」。
*
* <p>修复方式不是逐个改调用点,而是让本类**继承**被映射的那个类——
* 这样无论谁再用旧名字抛异常,HTTP 语义都由业务码决定,不可能再静默 500。
*
* <p>新代码请直接用 {@code GlobalExceptionHandler.BusinessException(ApiResponseCode, String)};
* 本类别名仅为兼容既有字符串码调用点保留。
*
* @deprecated 用 {@link GlobalExceptionHandler.BusinessException} + {@link ApiResponseCode}
*/
@Deprecated
public class BusinessException extends GlobalExceptionHandler.BusinessException {
/**
* @param code {@link ApiResponseCode} 的枚举名(如 {@code "VALIDATION_ERROR"})。
* 无法识别时退化为 500,并在异常首行标注原始码,避免静默变成「无信息的 400」。
*/
public BusinessException(String code) {
super(java.util.Arrays.stream(com.danyang.match.platform.shared.enums.ApiResponseCode.values())
.filter(value -> value.getCode().equals(code))
.map(com.danyang.match.platform.shared.enums.ApiResponseCode::getDescription)
.findFirst()
.orElse(code));
this.code = code;
super(resolve(code));
}
public BusinessException(String code, String message) {
super(message);
this.code = code;
super(resolve(code), message);
}
public String getCode() {
return code;
private static ApiResponseCode resolve(String code) {
if (code != null) {
for (ApiResponseCode candidate : ApiResponseCode.values()) {
if (candidate.getCode().equals(code) || candidate.name().equals(code)) {
return candidate;
}
}
}
return ApiResponseCode.SERVER_ERROR;
}
}
@@ -114,6 +114,20 @@ public class GlobalExceptionHandler {
return status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage()));
}
/**
* 显式标记的服务端故障 → 500。
*
* <p>与 {@link #handleUnknownException} 的区别只在**来源是否已知**:
* 这里是我们主动判定「这确实是服务端的问题」,因此不把 message 回给客户端
* (容器/IO/加密的报错常含路径与密钥线索),但按 ERROR 记全栈以便定位。
*/
@ExceptionHandler(ServerErrorException.class)
public ResponseEntity<ApiResponse<Void>> handleServerError(ServerErrorException e) {
log.error("Server-side failure (explicitly marked): {}", e.getMessage(), e);
return status(ApiResponseCode.SERVER_ERROR)
.body(ApiResponse.error(ApiResponseCode.SERVER_ERROR, "服务端内部错误,请联系管理员"));
}
/**
* 参数非法(例:活动类型不在白名单内)必须回 400。
*
@@ -0,0 +1,39 @@
package com.danyang.match.platform.shared.exception;
/**
* 服务端自身故障的**显式**标记。
*
* <p>适用范围:加解密失败、文件头读取失败、对象存储上传失败、签到码签名失败、
* 外部实名认证 Provider 不可用 —— 这些确实是我们这边的问题,HTTP 500 正确,
* 客户端重试也无害。
*
* <p><b>为什么需要一个专门的类,而不是继续用裸 {@code RuntimeException}:</b>
* 裸 {@code RuntimeException} 无法与「忘了给业务拒绝挑状态码」区分开 ——
* 两者在源码上长得一模一样,落到
* {@link GlobalExceptionHandler#handleUnknownException} 都是 500。
* 于是 2026-09-22 审计时发现:全仓 121 处裸抛里,「活动不存在」「无权取消他人预约」
* 这类明确的客户端错误被一并报成「服务端内部错误,请联系管理员」,
* 客户端据此判定为故障并重试,而重试必然再失败。
*
* <p>有了这个类,裸 {@code RuntimeException} 就只剩一个含义:**漏标了**。
* {@code RuntimeExceptionThrowGuardTest} 据此把「main 源码零裸抛」钉成不变量 ——
* 判据是扫描源码,因此不需要维护一份会过期的白名单副本。
*
* <p><b>为什么放在这里而不是 GlobalExceptionHandler 内部</b>:本异常需要从
* {@code shared.crypto.EncryptionUtil} 这类基础设施代码抛出,让它们依赖一个
* Web 层的 {@code @RestControllerAdvice} 是方向错误的依赖;放在异常包里,
* 基础设施只依赖异常语义本身。
*
* <p>对外文案刻意不回落异常的 message(容器/IO/加密的报错常含路径与密钥线索),
* 与兜底处理器保持一致;堆栈按 ERROR 级记录以便定位。
*/
public class ServerErrorException extends RuntimeException {
public ServerErrorException(String message) {
super(message);
}
public ServerErrorException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -26,6 +26,8 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 用户管理服务
@@ -104,7 +106,7 @@ public class UserService {
public UserDetailDTO getUserDetail(String userId, String operatorId) {
Optional<User> userOpt = userMapper.findById(userId);
if (userOpt.isEmpty()) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
User user = userOpt.get();
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
@@ -139,7 +141,7 @@ public class UserService {
public void updateAccountStatus(String userId, String newStatus, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
String oldStatus = user.getAccountStatus();
user.setAccountStatus(newStatus);
@@ -21,6 +21,9 @@ import java.time.Period;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
@Service
@RequiredArgsConstructor
@@ -54,14 +57,14 @@ public class IdentityVerificationService {
// 3. 检查是否已实名
userIdentityMapper.findByUserId(userId).ifPresent(exists -> {
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(exists.getVerificationStatus())) {
throw new RuntimeException("已经实名认证,无需重复提交");
throw new BusinessException(ApiResponseCode.CONFLICT, "已经实名认证,无需重复提交");
}
});
// 4. 检查身份证是否已被其他账号使用
String idHash = encryptionUtil.hashIdNumber(idNumber);
if (userIdentityMapper.existsByIdNumberHash(idHash)) {
throw new RuntimeException("该身份证已被其他账号实名认证");
throw new BusinessException(ApiResponseCode.CONFLICT, "该身份证已被其他账号实名认证");
}
// 5. 调用实名认证 Provider
@@ -70,7 +73,7 @@ public class IdentityVerificationService {
providerResult = identityProvider.verify(realName, idNumber);
} catch (Exception e) {
log.error("实名认证 Provider 调用失败", e);
throw new RuntimeException("实名认证服务暂时不可用,请稍后重试");
throw new ServerErrorException("实名认证服务暂时不可用,请稍后重试");
}
// 6. 检查验证结果
@@ -78,7 +81,7 @@ public class IdentityVerificationService {
if (verified == null || !verified) {
// 保存失败记录
saveFailedIdentity(userId, idNumber, idHash, realName, "VERIFY_FAILED");
throw new RuntimeException("实名认证失败:姓名与身份证不匹配");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "实名认证失败:姓名与身份证不匹配");
}
// 7. 成年校验(核心业务规则)
@@ -88,7 +91,7 @@ public class IdentityVerificationService {
if (age < MIN_AGE) {
saveFailedIdentity(userId, idNumber, idHash, realName, "UNDER_18");
throw new RuntimeException("未满18周岁,不能使用本婚恋服务");
throw new BusinessException(ApiResponseCode.USER_UNDER_18, "未满18周岁,不能使用本婚恋服务");
}
// 8. 从身份证解析性别(如果 Provider 未返回)
@@ -212,7 +215,7 @@ public class IdentityVerificationService {
.orElseThrow(() -> new RuntimeException("未找到实名认证记录"));
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
throw new RuntimeException("已认证的实名记录不能撤销");
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
}
identity.setVerificationStatus(Constants.VERIFICATION_STATUS_NOT_VERIFIED);
@@ -0,0 +1,150 @@
package com.danyang.match.platform.auth.token;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Refresh Token 的验签与用途隔离守卫。
*
* <p><b>为什么必须有这个测试</b>:2026-09-22 定位到「刷新 Token 永远失败 → 登录态被清 →
* 全站 401」的根因是 {@code AuthService.refreshToken} 调用了
* {@link JwtTokenService#validateToken} —— 而该方法用 **access 密钥**验签,
* refresh token 却由 **refresh 密钥**签发,两者在 dev/test/production 取值都不同
* (见 application-*.yml),因此签名校验必然失败。
*
* <p>这个缺陷此前完全没被发现,因为它有**两层掩盖**:
* <ol>
* <li>前端漏传 userId,/auth/refresh 先返回 400,根本走不到验签那一步;</li>
* <li>E2E 全仓没有任何 /auth/refresh 用例(只有登录 / 运营登录)。</li>
* </ol>
* 修掉第一层之后,第二层才会显形 —— 所以本测试同时钉住「验签用对密钥」
* 与「两类令牌不得互相顶替」两件事。
*
* <p>这是纯单元测试:不起 Spring、不连 Redis,绕过上面第 1 类掩盖。
*/
class JwtTokenServiceRefreshTokenTest {
private static final String ACCESS_SECRET = "unit-test-access-secret-0123456789abcdef";
private static final String REFRESH_SECRET = "unit-test-refresh-secret-0123456789abcdef";
private static final String USER_ID = "user-under-test";
private TokenBlacklistService blacklist;
@BeforeEach
void setUp() {
blacklist = mock(TokenBlacklistService.class);
}
private JwtTokenService newService(String accessSecret, String refreshSecret) {
JwtTokenService service = new JwtTokenService(blacklist);
ReflectionTestUtils.setField(service, "accessSecret", accessSecret);
ReflectionTestUtils.setField(service, "refreshSecret", refreshSecret);
ReflectionTestUtils.setField(service, "accessExpirationMs", 7_200_000L);
ReflectionTestUtils.setField(service, "refreshExpirationMs", 1_209_600_000L);
return service;
}
@Test
@DisplayName("refresh token 必须能用 refresh 密钥验签通过(回归:此前用 access 密钥验签恒失败)")
void refreshTokenValidatesWithRefreshKey() {
JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET);
String refreshToken = service.generateRefreshToken(USER_ID);
assertTrue(service.validateRefreshToken(refreshToken),
"refresh token 用 refresh 密钥签发,validateRefreshToken 必须放行;"
+ "若为 false,说明又回到了用 access 密钥验签的缺陷");
String subject = service.getUserIdFromRefreshToken(refreshToken);
assertEquals(USER_ID, subject, "refresh token 的 subject 必须是 userId(刷新时据此自证身份)");
}
@Test
@DisplayName("access token 不得充当 refresh token 使用")
void accessTokenIsNotRefreshToken() {
JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET);
String accessToken = service.generateAccessToken(USER_ID, null);
assertFalse(service.validateRefreshToken(accessToken),
"access token 不能换发新令牌,否则短期凭证获得了续期能力");
}
@Test
@DisplayName("refresh token 不得充当访问凭证(反向隔离)")
void refreshTokenIsNotAccessToken() {
JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET);
String refreshToken = service.generateRefreshToken(USER_ID);
assertFalse(service.validateToken(refreshToken),
"refresh token 不得通过访问凭证校验,否则长期凭证可直连业务接口");
}
@Test
@DisplayName("两把密钥配成同一个值时,只能靠 type 声明挡住用途顶替")
void typeClaimBlocksConfusionWhenSecretsAreIdentical() {
// 模拟运维把 access / refresh 密钥配成同一个值(或都用默认占位值)——
// 此时签名校验无法区分两类令牌,唯一的闸门是 type 声明。
JwtTokenService service = newService(ACCESS_SECRET, ACCESS_SECRET);
String accessToken = service.generateAccessToken(USER_ID, null);
assertFalse(service.validateRefreshToken(accessToken),
"密钥相同的情况下,只有 type=refresh 断言能阻止 access token 被当作 refresh token");
}
@Test
@DisplayName("同一秒内签发的令牌也必须互不相同(否则「刷新即轮换」形同虚设)")
void tokensMintedInTheSameSecondMustStillBeDistinct() {
JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET);
java.util.Set<String> refreshTokens = new java.util.HashSet<>();
java.util.Set<String> accessTokens = new java.util.HashSet<>();
for (int i = 0; i < 50; i++) {
refreshTokens.add(service.generateRefreshToken(USER_ID));
accessTokens.add(service.generateAccessToken(USER_ID, null));
}
// 载荷只有 subject / iat / type,而 JWT 的 iat 是**秒级**。同一 user 在同一秒内
// 重复签发会得到逐字节相同的令牌 —— 后果是刷新时写入 Redis 的「新」refresh token
// 与旧值相等,于是「刷新即轮换、旧令牌作废」这个安全属性直接失效
// (2026-09-22 由 tests/e2e/auth_refresh_test.py 的两个用例咬出来)。
// 修法是给每个令牌加唯一 jti。
assertEquals(50, refreshTokens.size(),
"50 次签发只产生了 " + refreshTokens.size() + " 个不同的 refresh token:"
+ "缺少唯一标识(jti),同一秒内的令牌会重复");
assertEquals(50, accessTokens.size(),
"50 次签发只产生了 " + accessTokens.size() + " 个不同的 access token:"
+ "缺少唯一标识(jti),同一秒内的令牌会重复");
}
@Test
@DisplayName("已撤销(黑名单)的 refresh token 必须被拒绝")
void blacklistedRefreshTokenIsRejected() {
JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET);
String refreshToken = service.generateRefreshToken(USER_ID);
assertNotNull(refreshToken);
when(blacklist.isBlacklisted(anyString())).thenReturn(true);
assertFalse(service.validateRefreshToken(refreshToken),
"登出后加入黑名单的 refresh token 不得再换发新令牌");
}
@Test
@DisplayName("无法解析的字符串必须被拒绝,且不得抛异常")
void malformedTokenIsRejectedWithoutThrowing() {
JwtTokenService service = newService(ACCESS_SECRET, REFRESH_SECRET);
assertFalse(service.validateRefreshToken("not-a-jwt"),
"非法令牌应返回 false 而不是抛异常(异常会在 Controller 层变成 500)");
assertFalse(service.validateRefreshToken(null), "null 令牌应返回 false");
}
}
@@ -21,10 +21,49 @@ class GlobalExceptionHandlerTest {
@Test
void testBusinessException() {
BusinessException ex = new BusinessException(ApiResponseCode.PROFILE_NOT_APPROVED.getCode());
assertEquals(ApiResponseCode.PROFILE_NOT_APPROVED.getCode(), ex.getCode());
// getCode() 返回的是枚举而非字符串:HTTP 语义(403 还是 400、该不该重试)
// 只能由 ApiResponseCode.httpStatus 决定,字符串码本身不携带这个信息。
assertEquals(ApiResponseCode.PROFILE_NOT_APPROVED, ex.getCode());
assertEquals("当前资料尚未通过审核", ex.getMessage());
}
@Test
void stringCodeAliasReachesItsHttpSemanticsInsteadOfFiveHundred() {
// 锁住 2026-09-22 的根因:shared.exception.BusinessException 曾
// extends RuntimeException,与 @ExceptionHandler(BusinessException.class) 解析到的
// 嵌套类互不相干,其实例一律落到 handleUnknownException → 500。
// 「资料未通过审核」本意是 403,客户端据此判断「别重试」。
BusinessException ex = new BusinessException(ApiResponseCode.PROFILE_NOT_APPROVED.getCode());
ResponseEntity<ApiResponse<Void>> res = handler.handleBusinessException(ex);
assertEquals(HttpStatus.FORBIDDEN, res.getStatusCode(),
"别名类的实例必须按业务码回 403,而不是兜底 500");
assertNotNull(res.getBody());
assertEquals("PROFILE_NOT_APPROVED", res.getBody().getCode());
}
@Test
void unknownStringCodeFailsClosedToServerError() {
// 无法识别的码不能退化成一个「无信息的 400」——那会让调用方以为改参数就能通过。
// fail-closed 到 500 至少能被监控发现,而 400 会静默掩盖映射缺失。
BusinessException ex = new BusinessException("NO_SUCH_CODE");
assertEquals(ApiResponseCode.SERVER_ERROR, ex.getCode());
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR,
handler.handleBusinessException(ex).getStatusCode());
}
@Test
void businessExceptionAliasMustStayASubclassOfTheMappedType() {
// 结构守卫:把别名类改回 extends RuntimeException 会**静默**恢复
// 「业务异常 → 500」。此时抛点一处未改,任何行为测试都咬不住,
// 只有这条继承关系断言会立刻变红。
assertTrue(GlobalExceptionHandler.BusinessException.class.isAssignableFrom(BusinessException.class),
"shared.exception.BusinessException 必须继承被 @ExceptionHandler 映射的那个类,"
+ "否则它的实例永远落进 handleUnknownException 变成 500");
}
@Test
void testApiResponseError() {
ApiResponse<Void> response = ApiResponse.error(ApiResponseCode.UNAUTHORIZED);
@@ -0,0 +1,203 @@
package com.danyang.match.platform.shared.exception;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 不变量:main 源码里不得出现裸的 {@code throw new RuntimeException(...)}。
*
* <p><b>为什么这是一个需要测试来守的约束</b>:裸 {@code RuntimeException} 没有携带
* HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成
* <b>500</b>。于是「活动不存在」「无权取消他人预约」「已经报名过该活动」这类
* 明确的客户端错误,被一并报成「服务端内部错误,请联系管理员」——
* 客户端据此判定为服务端故障并重试,而重试必然再失败,把「别重试」的语义
* 放大成故障风暴。2026-09-22 审计时全仓有 <b>121 处</b>。
*
* <p>收敛之后,{@code RuntimeException} 就只剩一个含义:**漏标了**。
* 真正的服务端故障请显式抛 {@link ServerErrorException}(仍是 500,但意图明确、
* 日志按 ERROR 记录、不向客户端泄露内部细节);业务拒绝请抛
* {@code GlobalExceptionHandler.BusinessException} 并带上
* {@link com.danyang.match.platform.shared.enums.ApiResponseCode}。
*
* <p><b>判据是扫描源码,不是维护白名单副本</b>——白名单会随重构过期,
* 而源码不会。这也是本项目对守卫的一贯要求(见 MEMORY 的守卫纪律)。
*
* <p>工作目录依赖:本仓 surefire 把 {@code workingDirectory} 设为
* {@code ${project.basedir}/..}(仓库根),故此处用仓库根相对路径。
*/
class RuntimeExceptionThrowGuardTest {
/**
* 允许 {@code throw} 与 {@code new} 之间有任意空白,以便跨行书写也被咬住。
*
* <p>{@code IllegalStateException} / {@code IllegalArgumentException} 不在守卫范围内:
* 前者已有专用处理器映射到 409(有明确状态语义),后者映射到 400。
*/
private static final Pattern BARE_THROW =
Pattern.compile("throw\\s+new\\s+RuntimeException\\s*\\(");
private static final Path MAIN_SOURCES = Paths.get("backend/src/main/java");
@Test
@DisplayName("main 源码不得裸抛 RuntimeException(会被兜底成 500,与业务拒绝无法区分)")
void mainSourcesMustNotThrowBareRuntimeException() throws IOException {
assertTrue(Files.isDirectory(MAIN_SOURCES),
"找不到源码根 " + MAIN_SOURCES.toAbsolutePath()
+ "。若 surefire 的 workingDirectory 不是仓库根,本守卫会静默空跑成"
+ "「假绿」,必须先修正路径而不是删掉断言。");
List<Path> javaFiles;
try (Stream<Path> walk = Files.walk(MAIN_SOURCES)) {
javaFiles = walk.filter(p -> p.toString().endsWith(".java")).sorted().toList();
}
assertFalse(javaFiles.isEmpty(), "源码根下没有任何 .java 文件,守卫形同虚设");
List<String> offenders = new ArrayList<>();
for (Path file : javaFiles) {
String content = Files.readString(file, StandardCharsets.UTF_8);
String[] lines = stripComments(content).split("\n", -1);
for (int i = 0; i < lines.length; i++) {
if (BARE_THROW.matcher(lines[i]).find()) {
offenders.add(file + ":" + (i + 1) + " " + lines[i].trim());
}
}
}
assertTrue(offenders.isEmpty(),
"发现 " + offenders.size() + " 处裸抛 RuntimeException。"
+ "业务拒绝请改成 new BusinessException(ApiResponseCode.XXX, \"文案\"),"
+ "服务端故障请改成 new ServerErrorException(\"文案\", e)。\n"
+ String.join("\n", offenders));
}
@Test
@DisplayName("守卫自身有判别力:注释里的提及不算,代码里的裸抛要能被认出来")
void guardDetectsCodeButIgnoresComments() {
String sample = """
// throw new RuntimeException("注释里的提及不应命中");
/* throw new RuntimeException("块注释里的也不应命中"); */
if (x == null) throw new RuntimeException("代码里的必须命中");
throw new RuntimeException("跨空格的必须命中");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "业务异常");
throw new ServerErrorException("服务端故障");
""";
String[] lines = stripComments(sample).split("\n", -1);
int hits = 0;
for (String line : lines) {
if (BARE_THROW.matcher(line).find()) {
hits++;
}
}
// 这条断言保证上面那个「零裸抛」不是恒真的空断言:
// 如果正则或注释剥离写坏(例如把代码也当成注释剥掉),这条会先红。
assertTrue(hits == 2, "应当且只应当命中 2 处裸抛(含同行 if 与多空格写法),实际命中 " + hits);
}
/**
* 把 Java 源码中的注释内容替换为空格(保留换行,行号不变),
* 但保留字符串/字符字面量,避免「注释里的示例代码」被误判成真实裸抛。
*/
private static String stripComments(String source) {
StringBuilder out = new StringBuilder(source.length());
int i = 0;
int n = source.length();
boolean inLineComment = false;
boolean inBlockComment = false;
boolean inString = false;
boolean inChar = false;
while (i < n) {
char c = source.charAt(i);
char next = i + 1 < n ? source.charAt(i + 1) : '\0';
if (inLineComment) {
if (c == '\n') {
inLineComment = false;
out.append(c);
} else {
out.append(' ');
}
} else if (inBlockComment) {
if (c == '*' && next == '/') {
inBlockComment = false;
out.append(" ");
i += 2;
continue;
}
out.append(c == '\n' ? '\n' : ' ');
} else if (inString) {
out.append(c);
if (c == '\\') {
if (next != '\0') {
out.append(next);
i += 2;
continue;
}
} else if (c == '"') {
inString = false;
}
} else if (inChar) {
out.append(c);
if (c == '\\') {
if (next != '\0') {
out.append(next);
i += 2;
continue;
}
} else if (c == '\'') {
inChar = false;
}
} else if (c == '/' && next == '/') {
inLineComment = true;
out.append(" ");
i += 2;
continue;
} else if (c == '/' && next == '*') {
inBlockComment = true;
out.append(" ");
i += 2;
continue;
} else {
out.append(c);
if (c == '"') {
inString = true;
} else if (c == '\'') {
inChar = true;
}
}
i++;
}
return out.toString();
}
/** 供上面两条断言共享的调试入口:确认正则本身没写坏。 */
private static boolean matches(String code) {
Matcher m = BARE_THROW.matcher(code);
return m.find();
}
@Test
@DisplayName("正则自身校验:确认识别的是 RuntimeException 而不是其他异常")
void patternOnlyMatchesRuntimeException() {
assertTrue(matches("throw new RuntimeException(\"x\");"));
assertFalse(matches("throw new BusinessException(ApiResponseCode.NOT_FOUND, \"x\");"));
assertFalse(matches("throw new ServerErrorException(\"x\");"));
assertFalse(matches("throw new IllegalStateException(\"x\");"));
}
}
+9 -1
View File
@@ -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)
+6 -3
View File
@@ -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 }>}
*/
+14 -4
View File
@@ -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 });
+28
View File
@@ -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 });
+124
View File
@@ -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]}"
)
+8 -1
View File
@@ -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
+35
View File
@@ -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}"
+96 -11
View File
@@ -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')