Compare commits
2
Commits
47cccc9ca5
...
9fc3385d6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc3385d6c | ||
|
|
c1c75edc55 |
@@ -25,6 +25,13 @@ jobs:
|
||||
working-directory: backend
|
||||
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
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+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.service.OperatorAuthService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
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.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -23,7 +24,6 @@ public class OperatorAuthController {
|
||||
|
||||
private final OperatorAuthService operatorAuthService;
|
||||
private final OperatorCookieWriter cookieWriter;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
|
||||
@@ -34,10 +34,11 @@ public class OperatorAuthController {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
|
||||
}
|
||||
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,
|
||||
(String) result.get("accessToken"), (String) result.get("refreshToken"),
|
||||
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
|
||||
(String) result.get("accessToken"), (String) result.get("refreshToken"));
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
@@ -61,25 +62,22 @@ public class OperatorAuthController {
|
||||
String operatorId = (body != null) ? body.get("operatorId") : null;
|
||||
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
|
||||
cookieWriter.writeLoginCookies(response,
|
||||
(String) result.get("accessToken"), (String) result.get("refreshToken"),
|
||||
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
|
||||
(String) result.get("accessToken"), (String) result.get("refreshToken"));
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
|
||||
* token 取自 Authorization 头,缺省时回退到 access Cookie。
|
||||
* operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入),
|
||||
* 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie,
|
||||
* 仅用于黑名单登记。
|
||||
*/
|
||||
@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);
|
||||
if (token != null) {
|
||||
try {
|
||||
String operatorId = jwtTokenService.getUserIdFromToken(token);
|
||||
operatorAuthService.logout(operatorId, token);
|
||||
} catch (Exception ignored) {
|
||||
// token 已失效也无妨,仍清除 Cookie
|
||||
}
|
||||
if (token != null && principal != null) {
|
||||
operatorAuthService.logout(principal.id(), token);
|
||||
}
|
||||
cookieWriter.clearCookies(response);
|
||||
return ApiResponse.ok();
|
||||
|
||||
+14
@@ -27,6 +27,20 @@ public class OperatorCookieWriter {
|
||||
@Value("${app.auth-cookie-secure:true}")
|
||||
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,
|
||||
long accessMaxAgeSec, long refreshMaxAgeSec) {
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
|
||||
|
||||
@@ -208,7 +208,9 @@ public class PhotoService {
|
||||
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -68,7 +68,6 @@ class AuthServiceRefreshTtlTest {
|
||||
when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user));
|
||||
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access");
|
||||
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh");
|
||||
when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS);
|
||||
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
|
||||
when(auditLogMapper.insert(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(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess");
|
||||
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh");
|
||||
when(jwtTokenService.getExpirationMs()).thenReturn(ACCESS_MS);
|
||||
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
|
||||
|
||||
authService.refreshToken("u1", "rt");
|
||||
|
||||
@@ -154,7 +154,6 @@ class JwtAuthFilterTest {
|
||||
when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1");
|
||||
when(jwtTokenService.parseToken(headerToken)).thenReturn(claims);
|
||||
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");
|
||||
request.addHeader("Authorization", "Bearer " + headerToken);
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
## 6. 合并准入(Definition of Done / 合并门槛)
|
||||
|
||||
### 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 级对应清单项**已逐项打勾并附验证说明。
|
||||
3. 无未闭环的 Blocker/Major(或已书面转移到 `known-risks.md` 并取得 owner 确认)。
|
||||
4. 新增/修改的测试已做负向注入。
|
||||
@@ -131,7 +131,7 @@
|
||||
### 6.2 与现有 CI 门禁的分工(人审补 CI 之不能)
|
||||
| CI job | 它验证了什么 | 它验证不了的(必须由人审) |
|
||||
|--------|--------------|----------------------------|
|
||||
| Backend Build & Test | `mvn verify` 编译+单测 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 |
|
||||
| Backend Build & Test | `mvn verify` 编译+单测;`run-backend-contracts.mjs` 固化关键回归/契约测试并校验源文件下限 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 |
|
||||
| Admin Web Build | `pnpm build` 通过 | 运行时渲染正确性、token 是否补全色阶、响应式是否真重渲染 |
|
||||
| Mini Program Compile Check | 仅 `tsc --noEmit` | **真编译** WXML/WXSS、资源是否被 `ignoreDevUnusedFiles` 吞、胶囊安全区 |
|
||||
| Migration Validation | 序列/命名/非空 | CHECK 与枚举语义一致、向后兼容、可逆性 |
|
||||
@@ -164,7 +164,7 @@
|
||||
合并(保护分支)──▶ 合并后回读 + 轮询 CI
|
||||
```
|
||||
|
||||
- **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`),自认 T 级。
|
||||
- **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`、`scripts/run-backend-contracts.mjs`),自认 T 级。
|
||||
- **提 PR**:必须填 PR 模板,含"改了什么 / 怎么验证的(命令+输出)/ 风险级别 / 相关决策或已知风险"。
|
||||
- **Reviewer**:先判 T 级,再走清单;对 Blocker/Major 给**可执行**的修改建议(具体到文件:行),不只写"这里有问题"。
|
||||
- **CI 回退**:CI 红必须修复或证明是环境问题(如 KR-07 的 `docker` PATH、KR-08 的镜像源),不得"重试蒙过"。
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/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
|
||||
// 新增/删减请同步修改本数组(下限随之变化)。
|
||||
const CURATED = [
|
||||
'AuthServiceRefreshTtlTest',
|
||||
'PhotoServiceConfirmUploadTest',
|
||||
'RealIdentityVerificationProviderTest',
|
||||
'JwtAuthFilterTest',
|
||||
'CoreInvariantsTest',
|
||||
'V11InvariantsTest',
|
||||
'PermissionMatrixTest',
|
||||
'MeRouteContractTest',
|
||||
'CanonicalRouteContractTest',
|
||||
'PhotoLimitGuardTest',
|
||||
'PhotoLimitConcurrencyGuardTest',
|
||||
'PhotoUploadValidationTest',
|
||||
'OperatorAuthServiceTest',
|
||||
]
|
||||
|
||||
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