4 Commits
Author SHA1 Message Date
WorkBuddy 4edb7619bc fix(admin-web,db): 红娘菜单权限码对齐后端真实码 + 红娘角色授权
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
前端菜单/路由守卫用 referral.manage、matchmaker.service.view,但后端 permissions
表不存在这两个码(referral 控制器要 matchmaker.service、红娘控制器要
matchmaker.view/service/assign)。visibleNavigation 据此把「公益红娘」「红娘服务」
两个菜单对**所有角色(含 super_admin)隐藏**。

- admin-web: MainLayout.vue(菜单 + 角色名判定)、router/index.ts(路由 meta)、
  stores/auth.ts(DEV 预览角色)三处对齐为后端真实 authority 码。
- db: 新增 V052 迁移,将 matchmaker.view/service/assign 授予 matchmaker 角色
  (005),修复「角色定义!=可用」同族缺陷;幂等 ON CONFLICT DO NOTHING。
- 重建 danyang-admin-web 镜像并重新部署;运行时验证 wanghongniang 登录返回
  [matchmaker.assign, matchmaker.service, matchmaker.view],两菜单可见。
2026-10-09 19:33:40 +08:00
WorkBuddy dfb74f3386 fix(backend): 修复审核决策请求体契约不一致导致的 400
审核操作统一发送 {comment, reasonCodes[]}(数组),但 ReviewController 的
approve / need-evidence / approve-local-eligibility / reject-local-eligibility /
evidences/{id}/access 将 @RequestBody 声明为 Map<String,String>,数组值无法反序列
化成 String,Jackson 抛 HttpMessageNotReadableException,经 GlobalExceptionHandler
映射为 400「请求体格式错误」。

- 5 处 @RequestBody Map<String,String> 改为 Map<String,Object>,comment/purpose
  读取加 (String) 强转(与原本正确的 needEdit/reject 一致)。
- 新增回归守卫 ReviewControllerRequestBodyContractTest(standalone MockMvc +
  GlobalExceptionHandler + 自定义 @AuthenticationPrincipal 解析器),注入前端真实
  载荷 {comment, reasonCodes:[]} 断言 200;直接 new 控制器测不到 HTTP/绑定层缺陷。
- 接入后端契约门禁 scripts/run-backend-contracts.mjs(CURATED 13 -> 14)。
- 负向注入已自证判别力:临时改回 Map<String,String> 精确判红 400,恢复后 5/5 绿。
2026-10-09 19:33:16 +08:00
WorkBuddy 9fc3385d6c feat(ci): 新增后端契约门禁 scripts/run-backend-contracts.mjs 并接入 CI
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
与 scripts/run-ui-contracts.mjs 同级:固化一组关键后端回归/契约测试
(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底 + 核心不变量),
先校验测试源文件存在性下限(防改名/删除后门禁假绿),再用 mvn test -Dtest
真跑并解析 surefire 汇总。在 backend job 的 mvn verify 之后执行,复用已
编译产物与依赖缓存,开销极小。同步更新 CODE_REVIEW_STANDARDS.md 门禁说明。
2026-09-30 16:21:25 +08:00
WorkBuddy c1c75edc55 fix(backend): 修复 v1.1.0 引入的 4 项后端测试失败
上一轮 v1.1.0 修复从未真正运行过后端测试,本次接入后端契约门禁前
全量 mvn verify 暴露 4 处缺陷:

1. PhotoService.confirmDirectUpload:countActiveProfilePhotos 返回 long,
   窄化赋值给 int sortOrder 导致整个后端编译失败(全量测试 0 跑)。
2. OperatorAuthController 直接引用 JwtTokenService 计算 Cookie max-age 并在
   logout 解析 JWT,违反 PrincipalInjectionContractTest 契约(控制器不得
   解析/引用 JWT 工具)。改为由 OperatorCookieWriter 持有 TTL、logout 从
   SecurityContext 的 OperatorPrincipal 取 operatorId。
3-4. AuthServiceRefreshTtlTest 与 JwtAuthFilterTest 的 UnnecessaryStubbing:
   移除未被调用的 getExpirationMs() / validateToken("bad-cookie") 桩。

全量 mvn verify:809 测试 0 失败 0 错误(3 跳过)。
2026-09-30 16:21:16 +08:00
14 changed files with 460 additions and 40 deletions
+7
View File
@@ -25,6 +25,13 @@ jobs:
working-directory: backend working-directory: backend
run: mvn verify -B run: mvn verify -B
# 后端契约门禁(与 scripts/run-ui-contracts.mjs 同级):
# 固化关键后端回归/契约测试(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底
# + 核心不变量),先校验这些测试源文件存在性下限(防改名/删除后门禁假绿),
# 再用 `mvn test -Dtest=<列表>` 真跑。复用本 job 已编译产物与依赖缓存,开销极小。
- name: 后端契约门禁(curated + 下限校验)
run: node scripts/run-backend-contracts.mjs
- name: Upload Test Results - name: Upload Test Results
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+3 -3
View File
@@ -174,8 +174,8 @@ const navigation: NavigationItem[] = [
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User }, { label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked }, { label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled }, { label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection }, { label: '公益红娘', path: '/referral', permissions: ['matchmaker.service'], icon: Connection },
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection }, { label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.view'], icon: Connection },
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound }, { label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar }, { label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar }, { label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
@@ -217,7 +217,7 @@ const currentRoleName = computed(() => {
if (perms.includes('counseling.service.view')) return '公益咨询人员' if (perms.includes('counseling.service.view')) return '公益咨询人员'
if (perms.includes('service_appointment.view')) return '服务站运营人员' if (perms.includes('service_appointment.view')) return '服务站运营人员'
if (perms.includes('review.view')) return '资料审核人员' if (perms.includes('review.view')) return '资料审核人员'
if (perms.includes('matchmaker.service.view')) return '公益红娘' if (perms.includes('matchmaker.view')) return '公益红娘'
return '运营人员' return '运营人员'
}) })
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm))) const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
+2 -2
View File
@@ -37,8 +37,8 @@ const routes: RouteRecordRaw[] = [
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } }, { path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } }, { path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } }, { path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } }, { path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['matchmaker.service'] } },
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } }, { path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.view'] } },
], ],
}, },
+2 -2
View File
@@ -13,9 +13,9 @@ interface OperatorInfo {
const previewRolePermissions: Record<string, string[]> = { const previewRolePermissions: Record<string, string[]> = {
reviewer: ['review.view', 'user.view'], reviewer: ['review.view', 'user.view'],
counselor: ['counseling.service.view', 'referral.manage'], counselor: ['counseling.service.view', 'matchmaker.service'],
station: ['service_location.view', 'service_appointment.view'], station: ['service_location.view', 'service_appointment.view'],
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'referral.manage'], operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'matchmaker.view', 'matchmaker.service'],
} }
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
@@ -2,13 +2,14 @@ package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter; import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
import com.danyang.match.platform.auth.service.OperatorAuthService; import com.danyang.match.platform.auth.service.OperatorAuthService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -23,7 +24,6 @@ public class OperatorAuthController {
private final OperatorAuthService operatorAuthService; private final OperatorAuthService operatorAuthService;
private final OperatorCookieWriter cookieWriter; private final OperatorCookieWriter cookieWriter;
private final JwtTokenService jwtTokenService;
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body, public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
@@ -34,10 +34,11 @@ public class OperatorAuthController {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空"); return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
} }
Map<String, Object> result = operatorAuthService.login(username, password); Map<String, Object> result = operatorAuthService.login(username, password);
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌 // 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌。
// Cookie max-age 由 OperatorCookieWriter 按 JWT 有效期计算,控制器不引用 JWT 工具
// (控制器禁止解析/引用 JWT 工具,见 PrincipalInjectionContractTest 契约)。
cookieWriter.writeLoginCookies(response, cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"), (String) result.get("accessToken"), (String) result.get("refreshToken"));
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
@@ -61,25 +62,22 @@ public class OperatorAuthController {
String operatorId = (body != null) ? body.get("operatorId") : null; String operatorId = (body != null) ? body.get("operatorId") : null;
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken); Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
cookieWriter.writeLoginCookies(response, cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"), (String) result.get("accessToken"), (String) result.get("refreshToken"));
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
/** /**
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。 * 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
* token 取自 Authorization 头,缺省时回退到 access Cookie。 * operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入),
* 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie,
* 仅用于黑名单登记。
*/ */
@PostMapping("/logout") @PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) { public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response,
@AuthenticationPrincipal OperatorPrincipal principal) {
String token = readBearer(request); String token = readBearer(request);
if (token != null) { if (token != null && principal != null) {
try { operatorAuthService.logout(principal.id(), token);
String operatorId = jwtTokenService.getUserIdFromToken(token);
operatorAuthService.logout(operatorId, token);
} catch (Exception ignored) {
// token 已失效也无妨,仍清除 Cookie
}
} }
cookieWriter.clearCookies(response); cookieWriter.clearCookies(response);
return ApiResponse.ok(); return ApiResponse.ok();
@@ -27,6 +27,20 @@ public class OperatorCookieWriter {
@Value("${app.auth-cookie-secure:true}") @Value("${app.auth-cookie-secure:true}")
private boolean secure; private boolean secure;
// Cookie 生命周期与 JWT 自身有效期对齐(access 2h / refresh 14d),由配置驱动、不写死。
// 由本组件持有而非控制器持有:控制器不得直接引用 JwtTokenService
// (PrincipalInjectionContractTest 契约禁止控制器解析/引用 JWT 工具)。
@Value("${jwt.access-token-expiration-ms:7200000}")
private long accessExpirationMs;
@Value("${jwt.refresh-token-expiration-ms:1209600000}")
private long refreshExpirationMs;
/** 登录/刷新下发 Cookie,max-age 自动取 JWT 有效期(秒)。 */
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken) {
writeLoginCookies(response, accessToken, refreshToken, accessExpirationMs / 1000, refreshExpirationMs / 1000);
}
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken, public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
long accessMaxAgeSec, long refreshMaxAgeSec) { long accessMaxAgeSec, long refreshMaxAgeSec) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString()); response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
@@ -208,7 +208,9 @@ public class PhotoService {
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) { if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成"); throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
} }
int sortOrder = photoMapper.countActiveProfilePhotos(userId) + 1; // countActiveProfilePhotos 返回 long,落库排序用 int(与 savePhoto 形参一致);
// 照片数量不会触及 int 上界,此处安全窄化。
int sortOrder = (int) (photoMapper.countActiveProfilePhotos(userId) + 1);
return savePhoto(userId, fileKey, photoType, sortOrder); return savePhoto(userId, fileKey, photoType, sortOrder);
} }
@@ -95,9 +95,9 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approve( public ApiResponse<ReviewResultDTO> approve(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
String operatorId = principal.id(); String operatorId = principal.id();
String comment = body.getOrDefault("comment", ""); String comment = (String) body.getOrDefault("comment", "");
return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment)); return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment));
} }
@@ -140,8 +140,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> needEvidence( public ApiResponse<ReviewResultDTO> needEvidence(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment"))); return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), (String) body.get("comment")));
} }
@PostMapping("/{taskId}/approve-local-eligibility") @PostMapping("/{taskId}/approve-local-eligibility")
@@ -149,8 +149,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approveLocalEligibility( public ApiResponse<ReviewResultDTO> approveLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment"))); return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
} }
@PostMapping("/{taskId}/reject-local-eligibility") @PostMapping("/{taskId}/reject-local-eligibility")
@@ -158,8 +158,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> rejectLocalEligibility( public ApiResponse<ReviewResultDTO> rejectLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment"))); return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
} }
@PostMapping("/{taskId}/evidences/{evidenceId}/access") @PostMapping("/{taskId}/evidences/{evidenceId}/access")
@@ -168,9 +168,9 @@ public class ReviewController {
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@PathVariable String evidenceId, @PathVariable String evidenceId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence( return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence(
taskId, evidenceId, principal.id(), body.get("purpose"))); taskId, evidenceId, principal.id(), (String) body.get("purpose")));
} }
/** /**
@@ -0,0 +1,28 @@
-- ============================================================
-- V052: 补齐 matchmaker(公益红娘)角色权限
--
-- 发现方式:2026-10-09 搭建本地演示栈、创建红娘运营账号 wanghongniang 并授予
-- matchmaker 角色后发现,该角色在 role_permissions 中零条授权。
-- 用该账号登录后台,点「红娘服务」菜单加载 GET /operator/matchmaker/dashboard
-- 直接 403;同时前端菜单权限码(referral.manage / matchmaker.service.view)与后端
-- 真实 authority 码(matchmaker.service / matchmaker.view)不匹配,导致这两个菜单
-- 对所有角色(含 super_admin)被 visibleNavigation 过滤隐藏。
--
-- 根因:与 V043/V044 同族 —— 角色定义了、权限定义了,两者之间从未连上。
-- V015 只把全部权限授予 super_admin;matchmaker 等运营角色从未被授予任何权限。
--
-- 授权范围(最小权限原则,与后端 @PreAuthorize 严格对应):
-- * matchmaker.view —— 查看红娘服务(GET /operator/matchmaker/*)
-- * matchmaker.service —— 认领/推荐/跟进/完成红娘服务 + 转介管理(referral 控制器)
-- * matchmaker.assign —— 分配红娘服务
-- 后端 MatchmakerOperatorController / ReferralController 的 @PreAuthorize 仅要求这三条,
-- 故仅授予这三条,不扩权。
--
-- 幂等:ON CONFLICT (role_id, permission_id) DO NOTHING。
-- ============================================================
INSERT INTO role_permissions (role_id, permission_id)
SELECT '00000000-0000-0000-0000-000000000005', id
FROM permissions
WHERE code IN ('matchmaker.view', 'matchmaker.service', 'matchmaker.assign')
ON CONFLICT (role_id, permission_id) DO NOTHING;
@@ -68,7 +68,6 @@ class AuthServiceRefreshTtlTest {
when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user)); when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user));
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access"); when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh"); when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh");
when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS);
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS); when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
when(auditLogMapper.insert(any())).thenReturn(1); when(auditLogMapper.insert(any())).thenReturn(1);
when(userMapper.updateById(any())).thenReturn(1); when(userMapper.updateById(any())).thenReturn(1);
@@ -88,7 +87,6 @@ class AuthServiceRefreshTtlTest {
when(redisTemplate.opsForValue().get("auth:refresh_token:u1")).thenReturn("rt"); when(redisTemplate.opsForValue().get("auth:refresh_token:u1")).thenReturn("rt");
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess"); when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh"); when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh");
when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS);
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS); when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
authService.refreshToken("u1", "rt"); authService.refreshToken("u1", "rt");
@@ -0,0 +1,148 @@
package com.danyang.match.platform.review.controller;
import com.danyang.match.platform.review.dto.ReviewResultDTO;
import com.danyang.match.platform.review.service.ReviewService;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ReviewController 请求体契约测试 —— 锁定 HTTP / JSON 反序列化层。
*
* <p>背景(2026-10-09 实测缺陷):admin-web 的审核决策统一发送
* {@code {comment, reasonCodes}},其中 {@code reasonCodes} 是**数组**。而 ReviewController 的
* {@code approve} / {@code need-evidence} / {@code approve-local-eligibility} /
* {@code reject-local-eligibility} 等端点此前把 {@code @RequestBody} 声明为
* {@code Map<String,String>},数组值无法反序列化成 String → Jackson 抛
* {@code HttpMessageNotReadableException} → 400「请求体格式错误」。表现为
* 「小程序提交资料后,管理员点『通过』直接报错」。
*
* <p><b>为什么用 standalone MockMvc 而不是 {@code @SpringBootTest}</b>:本缺陷发生在
* **参数解析 / 反序列化层**。先例 {@code BannerOperatorPhase19IntegrationTest} 直接
* {@code new} 控制器再调方法,**绕过了该层**,因此天然抓不到这类缺陷。standalone MockMvc
* 走真实 DispatcherServlet + 消息转换器 + ControllerAdvice,又无需 DB / Redis / 安全链,
* 确定性强、可离线跑。
*
* <p><b>判别力自证</b>:若有人把端点改回 {@code Map<String,String>},本测试立即变红(400)。
* 已用负向注入验证过(见提交说明)。
*/
class ReviewControllerRequestBodyContractTest {
private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000002";
private static final String TASK_ID = "t-0001";
/** 与 admin-web {@code ReviewView.vue#runDecision} 的实际请求体完全一致(含 reasonCodes 数组)。 */
private static final String FRONTEND_PAYLOAD = "{\"comment\":\"已核验\",\"reasonCodes\":[]}";
private ReviewService reviewService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
reviewService = Mockito.mock(ReviewService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new ReviewController(reviewService))
.setControllerAdvice(new GlobalExceptionHandler())
.setCustomArgumentResolvers(new OperatorPrincipalResolver())
.build();
}
private ReviewResultDTO stubResult(String result) {
ReviewResultDTO dto = new ReviewResultDTO();
dto.setTaskId(TASK_ID);
dto.setResult(result);
return dto;
}
@Test
void approveAcceptsCommentAndReasonCodesArray() throws Exception {
Mockito.when(reviewService.approve(eq(TASK_ID), eq(OPERATOR_ID), any())).thenReturn(stubResult("approved"));
mockMvc.perform(post("/operator/reviews/{taskId}/approve", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(FRONTEND_PAYLOAD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.result").value("approved"));
}
@Test
void approveLocalEligibilityAcceptsCommentAndReasonCodesArray() throws Exception {
Mockito.when(reviewService.approveLocalEligibility(eq(TASK_ID), eq(OPERATOR_ID), any()))
.thenReturn(stubResult("approved"));
mockMvc.perform(post("/operator/reviews/{taskId}/approve-local-eligibility", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(FRONTEND_PAYLOAD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.result").value("approved"));
}
@Test
void rejectLocalEligibilityAcceptsCommentAndReasonCodesArray() throws Exception {
Mockito.when(reviewService.rejectLocalEligibility(eq(TASK_ID), eq(OPERATOR_ID), any()))
.thenReturn(stubResult("rejected"));
mockMvc.perform(post("/operator/reviews/{taskId}/reject-local-eligibility", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(FRONTEND_PAYLOAD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.result").value("rejected"));
}
@Test
void needEvidenceAcceptsCommentAndReasonCodesArray() throws Exception {
Mockito.when(reviewService.needEvidence(eq(TASK_ID), eq(OPERATOR_ID), any()))
.thenReturn(stubResult("need_evidence"));
mockMvc.perform(post("/operator/reviews/{taskId}/need-evidence", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(FRONTEND_PAYLOAD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.result").value("need_evidence"));
}
/** 只带 comment、不带 reasonCodes 也必须可用(前端未选原因标签时的实际载荷)。 */
@Test
void approveWithoutReasonCodesStillWorks() throws Exception {
Mockito.when(reviewService.approve(eq(TASK_ID), eq(OPERATOR_ID), any())).thenReturn(stubResult("approved"));
mockMvc.perform(post("/operator/reviews/{taskId}/approve", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"comment\":\"通过\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
/** 提供 {@code @AuthenticationPrincipal OperatorPrincipal} 的解析器(standalone 无安全链)。 */
private static final class OperatorPrincipalResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return OperatorPrincipal.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
return new OperatorPrincipal(OPERATOR_ID);
}
}
}
@@ -154,7 +154,6 @@ class JwtAuthFilterTest {
when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1"); when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1");
when(jwtTokenService.parseToken(headerToken)).thenReturn(claims); when(jwtTokenService.parseToken(headerToken)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("access"); when(claims.get("type", String.class)).thenReturn("access");
when(jwtTokenService.validateToken("bad-cookie")).thenReturn(false);
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.addHeader("Authorization", "Bearer " + headerToken); request.addHeader("Authorization", "Bearer " + headerToken);
+3 -3
View File
@@ -122,7 +122,7 @@
## 6. 合并准入(Definition of Done / 合并门槛) ## 6. 合并准入(Definition of Done / 合并门槛)
### 6.1 硬性门槛(全部满足才可合并) ### 6.1 硬性门槛(全部满足才可合并)
1. CI 7 道门禁全绿(backend / admin-web / miniapp / migration / test-shell-guard / ui-contracts / e2e)。 1. CI 7 道门禁全绿(backend / admin-web / miniapp / migration / test-shell-guard / ui-contracts / e2e)。其中 backend 门禁在 `mvn verify` 之外,额外由 `scripts/run-backend-contracts.mjs`(与 `run-ui-contracts.mjs` 同级)固化一组关键后端契约/回归测试,并校验这些测试源文件的存在性下限(防静默删除/改名后门禁假绿)。
2. PR 模板所有 **T 级对应清单项**已逐项打勾并附验证说明。 2. PR 模板所有 **T 级对应清单项**已逐项打勾并附验证说明。
3. 无未闭环的 Blocker/Major(或已书面转移到 `known-risks.md` 并取得 owner 确认)。 3. 无未闭环的 Blocker/Major(或已书面转移到 `known-risks.md` 并取得 owner 确认)。
4. 新增/修改的测试已做负向注入。 4. 新增/修改的测试已做负向注入。
@@ -131,7 +131,7 @@
### 6.2 与现有 CI 门禁的分工(人审补 CI 之不能) ### 6.2 与现有 CI 门禁的分工(人审补 CI 之不能)
| CI job | 它验证了什么 | 它验证不了的(必须由人审) | | CI job | 它验证了什么 | 它验证不了的(必须由人审) |
|--------|--------------|----------------------------| |--------|--------------|----------------------------|
| Backend Build & Test | `mvn verify` 编译+单测 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 | | Backend Build & Test | `mvn verify` 编译+单测;`run-backend-contracts.mjs` 固化关键回归/契约测试并校验源文件下限 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 |
| Admin Web Build | `pnpm build` 通过 | 运行时渲染正确性、token 是否补全色阶、响应式是否真重渲染 | | Admin Web Build | `pnpm build` 通过 | 运行时渲染正确性、token 是否补全色阶、响应式是否真重渲染 |
| Mini Program Compile Check | 仅 `tsc --noEmit` | **真编译** WXML/WXSS、资源是否被 `ignoreDevUnusedFiles` 吞、胶囊安全区 | | Mini Program Compile Check | 仅 `tsc --noEmit` | **真编译** WXML/WXSS、资源是否被 `ignoreDevUnusedFiles` 吞、胶囊安全区 |
| Migration Validation | 序列/命名/非空 | CHECK 与枚举语义一致、向后兼容、可逆性 | | Migration Validation | 序列/命名/非空 | CHECK 与枚举语义一致、向后兼容、可逆性 |
@@ -164,7 +164,7 @@
合并(保护分支)──▶ 合并后回读 + 轮询 CI 合并(保护分支)──▶ 合并后回读 + 轮询 CI
``` ```
- **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`),自认 T 级。 - **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`、`scripts/run-backend-contracts.mjs`),自认 T 级。
- **提 PR**:必须填 PR 模板,含"改了什么 / 怎么验证的(命令+输出)/ 风险级别 / 相关决策或已知风险"。 - **提 PR**:必须填 PR 模板,含"改了什么 / 怎么验证的(命令+输出)/ 风险级别 / 相关决策或已知风险"。
- **Reviewer**:先判 T 级,再走清单;对 Blocker/Major 给**可执行**的修改建议(具体到文件:行),不只写"这里有问题"。 - **Reviewer**:先判 T 级,再走清单;对 Blocker/Major 给**可执行**的修改建议(具体到文件:行),不只写"这里有问题"。
- **CI 回退**:CI 红必须修复或证明是环境问题(如 KR-07 的 `docker` PATH、KR-08 的镜像源),不得"重试蒙过"。 - **CI 回退**:CI 红必须修复或证明是环境问题(如 KR-07 的 `docker` PATH、KR-08 的镜像源),不得"重试蒙过"。
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env node
// 后端契约门禁统一执行器(本地 + CI 共用入口)
//
// 背景:run-ui-contracts.mjs 固化了一组「源码结构与关键行为断言」守卫(ui-tests/*.mjs,纯 node、零依赖),
// 但后端回归/契约测试是 Java/Maven,无法用 node 直接跑。此前它们只随 CI 的 `mvn verify` 全量执行,
// 没有「curated + 下限硬校验」这层保护 —— 即:有人把关键回归测试类**改名/删除**后,
// `mvn verify` 只会少跑几个测试、照样 SUCCESS,门禁假绿。
//
// 本脚本把「固化关键后端契约测试 + 文件存在性下限 + 真跑并解析 surefire 汇总 + 非零退出」固化下来,
// 作为 run-ui-contracts.mjs 的**同级后端门禁**:
//
// node scripts/run-backend-contracts.mjs # 跑 CURATED 列表(默认)
// node scripts/run-backend-contracts.mjs --list # 只打印 curated 列表,不执行
//
// 三条约定(对应 run-ui-contracts.mjs 的同名纪律):
// 1) **文件存在性下限硬校验**:CURATED 中的每个测试类源文件必须存在于 backend/src/test/java,
// 缺一个即拒绝报绿(防止「测试被静默删除/改名」后门禁仍然全绿)。
// 新增契约测试请上调 CURATED 列表;有意删减则下调并说明原因。
// 可用环境变量 BACKEND_GUARDS_MIN 覆盖(仅用于让「数量下限」这条分支可被自检)。
// 2) 真跑:用 `mvn test -Dtest=<列表>` 执行(CI 的 backend job 已装好 JDK/Maven 且刚跑过 verify,
// 复用编译产物与依赖缓存,仅重跑指定测试,开销很小)。任一测试失败/报错 → 非零退出。
// 3) **本地无 JDK/Maven 时绝不谎报通过**:探测 mvn/mvnw → Docker 测试镜像 → 都不行则明确退出码 2
// (区别于 0 / 1),提示走 CI 或 Docker,绝不假装绿。
import { spawnSync } from 'node:child_process'
import { existsSync, readdirSync, statSync } from 'node:fs'
import { basename, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const backendTestRoot = resolve(root, 'backend', 'src', 'test', 'java')
// ── curated 后端契约/回归测试 ────────────────────────────────────────────────
// 第 1~4 项:本轮(2026-09-30)为 v1.1.0 修复补的回归测试 + 扩展断言
// AuthServiceRefreshTtlTest —— refresh token TTL 须用 refresh 过期(14天) 而非 access(4h)
// PhotoServiceConfirmUploadTest —— 照片直传 confirm 落库护栏(归属前缀 + 对象存在性)
// RealIdentityVerificationProviderTest —— 生产实名核验走真实校验位 + 可配置第三方 + fail-closed
// JwtAuthFilterTest —— Authorization 头优先、缺失回退 dy_op_at Cookie
// 第 5~13 项:核心不变量/契约守卫(持续盯防,改坏会漏)
// CoreInvariantsTest / V11InvariantsTest / PermissionMatrixTest
// MeRouteContractTest / CanonicalRouteContractTest
// PhotoLimitGuardTest / PhotoLimitConcurrencyGuardTest / PhotoUploadValidationTest
// OperatorAuthServiceTest
// 第 14 项(2026-10-09):审核决策端点的「请求体契约」守卫 —— 前端统一发送 {comment, reasonCodes[]},
// 若 @RequestBody 被再度收窄为 Map<String,String>,数组值反序列化失败即 400
// (现象:「小程序提交资料后,管理员点通过直接报错」)。
// ReviewControllerRequestBodyContractTest
// 新增/删减请同步修改本数组(下限随之变化)。
const CURATED = [
'AuthServiceRefreshTtlTest',
'PhotoServiceConfirmUploadTest',
'RealIdentityVerificationProviderTest',
'JwtAuthFilterTest',
'CoreInvariantsTest',
'V11InvariantsTest',
'PermissionMatrixTest',
'MeRouteContractTest',
'CanonicalRouteContractTest',
'PhotoLimitGuardTest',
'PhotoLimitConcurrencyGuardTest',
'PhotoUploadValidationTest',
'OperatorAuthServiceTest',
'ReviewControllerRequestBodyContractTest',
]
const MIN_GUARDS = Number(process.env.BACKEND_GUARDS_MIN ?? CURATED.length)
// ── 步骤 1:文件存在性下限校验 ──────────────────────────────────────────────
function findClassFile(name) {
// 在 backend/src/test/java 下递归找 `${name}.java`
const stack = [backendTestRoot]
while (stack.length) {
const dir = stack.pop()
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const e of entries) {
const full = resolve(dir, e.name)
if (e.isDirectory()) stack.push(full)
else if (e.name === `${name}.java`) return full
}
}
return null
}
// --list 模式:只打印,不执行
if (process.argv.includes('--list')) {
console.log(`后端契约门禁 curated 列表(${CURATED.length} 项,下限 ${MIN_GUARDS}):`)
for (const n of CURATED) console.log(` - ${n}`)
process.exit(0)
}
console.log(`后端契约门禁 — 校验 ${CURATED.length} 个契约测试源文件存在性(下限 ${MIN_GUARDS})\n`)
const missing = []
for (const name of CURATED) {
const f = findClassFile(name)
if (!f) {
missing.push(name)
console.log(` MISSING ${name}`)
} else {
console.log(` OK ${name}`)
}
}
if (missing.length > 0) {
console.error(
`\nERROR: ${missing.length} 个契约测试源文件缺失,少于下限 ${MIN_GUARDS} —— 测试可能被删除或改名,拒绝报绿。`,
)
console.error(' 新增契约测试请上调 CURATED 列表;确为有意删减请下调并说明原因。')
process.exit(1)
}
if (CURATED.length < MIN_GUARDS) {
console.error(
`ERROR: 仅 ${CURATED.length} 个契约测试,少于下限 ${MIN_GUARDS} —— 拒绝报绿。`,
)
process.exit(1)
}
console.log(`\n✅ 全部 ${CURATED.length} 个契约测试源文件存在,下限校验通过。\n`)
// ── 步骤 2:真跑(mvn test -Dtest=<列表>) ─────────────────────────────────
function detectMvn() {
for (const cand of ['mvnw', 'mvn']) {
const r = spawnSync(cand, ['-v'], { cwd: root, encoding: 'utf8' })
if (r.status === 0) return cand
}
// Windows 常见:backend 下有 mvnw.cmd
const winMvnw = resolve(root, 'backend', 'mvnw.cmd')
if (existsSync(winMvnw)) return winMvnw
return null
}
const testList = CURATED.join(',')
const t0 = Date.now()
let mvn = detectMvn()
if (mvn) {
console.log(`▶ 使用 Maven 执行:${mvn} test -Dtest=<${CURATED.length} 个契约测试>\n`)
const r = spawnSync(mvn, ['-B', 'test', `-Dtest=${testList}`, '-DfailIfNoTests=true'], {
cwd: resolve(root, 'backend'),
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
})
const out = [r.stdout, r.stderr].filter(Boolean).join('\n')
analyze(out, r.status)
} else {
// 退路:Docker 测试镜像(与 .workbuddy/run-regression.sh 同三件套)
// 关键:docker daemon 在本机可能无响应,`spawnSync` 不设 timeout 会**挂死整个脚本**
// (2026-09-30 实测:docker CLI 在 PATH 但 daemon 未起,`image inspect` 直接卡住)。
// 因此所有 docker 探测都带 timeout,超时即视为不可用 → 走 cannotRun,绝不假绿也不挂死。
const docker = spawnSync('docker', ['--version'], { encoding: 'utf8', timeout: 10000 })
const IMG = 'danyang-match-backend-test:latest'
if (docker.status === 0) {
const imgCheck = spawnSync('docker', ['image', 'inspect', IMG], {
encoding: 'utf8',
timeout: 15000,
})
if (imgCheck.status === 0) {
console.log(`▶ 本地无 Maven,回退 Docker 测试镜像 ${IMG}\n`)
const dockerExe =
process.env.DOCKER_EXE || 'D:/Data/Docker/DockerDesktop/resources/bin/docker.exe'
const settings = resolve(root, '.workbuddy', 'maven-settings-aliyun.xml')
const r = spawnSync(
dockerExe,
[
'run', '--rm',
'-v', `${root}:/proj`,
'-v', 'danyang-m2-cache:/root/.m2',
'-v', `${settings}:/root/aliyun-settings.xml`,
'-w', '/proj/backend', '--memory=3g', '-e', 'MAVEN_OPTS=-Xmx256m',
IMG, 'mvn', '-B', '-s', '/root/aliyun-settings.xml', 'test',
`-Dtest=${testList}`, '-DfailIfNoTests=true',
],
{ encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' }, timeout: 600000 },
)
const out = [r.stdout, r.stderr].filter(Boolean).join('\n')
analyze(out, r.status)
} else {
cannotRun()
}
} else {
cannotRun()
}
}
function cannotRun() {
console.error(
'\n⚠️ 本机无 JDK/Maven 且无可用的后端测试 Docker 镜像,无法本地执行后端契约门禁。',
)
console.error(' 该门禁由 CI(backend job)强制执行,请在 CI 中验证;或安装 Maven / 构建')
console.error(' danyang-match-backend-test 镜像后本地复跑。')
console.error(' (以退出码 2 表示「本环境无法执行」,而非谎报通过。)')
process.exit(2)
}
function analyze(out, status) {
// 解析 surefire 汇总:Tests run: X, Failures: Y, Errors: Z, Skipped: S
const re = /Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+)/g
let m
let total = 0
let failures = 0
let errors = 0
while ((m = re.exec(out)) !== null) {
total += Number(m[1])
failures += Number(m[2])
errors += Number(m[3])
}
const secs = ((Date.now() - t0) / 1000).toFixed(1)
console.log('')
if (status === 0 && failures === 0 && errors === 0) {
console.log(`✅ PASS 后端契约门禁:${total} 测试全部通过,用时 ${secs}s`)
process.exit(0)
}
console.error(`❌ 后端契约门禁失败(exit=${status ?? 'null'}):`)
console.error(` 已执行测试 ${total},Failures=${failures},Errors=${errors}`)
// 把 surefire 关键片段打印出来便于定位
const tail = out.split('\n').filter((l) => /Tests run:|BUILD FAILURE|<<< FAILURE|<<< ERROR|No tests were executed/.test(l))
if (tail.length) {
console.error(' --- 关键输出 ---')
for (const l of tail.slice(-25)) console.error(' ' + l.trim())
}
process.exit(1)
}