Compare commits
4
Commits
release-v1.1.0
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4edb7619bc | ||
|
|
dfb74f3386 | ||
|
|
9fc3385d6c | ||
|
|
c1c75edc55 |
@@ -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
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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'] } },
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
+14
-16
@@ -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();
|
||||||
|
|||||||
+14
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -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;
|
||||||
-2
@@ -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");
|
||||||
|
|||||||
+148
@@ -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);
|
||||||
|
|||||||
@@ -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 的镜像源),不得"重试蒙过"。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user