7 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
WorkBuddy 47cccc9ca5 test: 补 v1.1.0 修复的回归测试
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
- RealIdentityVerificationProvider: 伪造证件校验位拦截、配置缺失 fail-fast、第三方 fail-closed
- AuthService: 钉死刷新 Token Redis TTL 用 getRefreshExpirationMs(14 天)而非 access*2(4 小时)
- PhotoService: 直传落库 + 归属/存在性护栏(修复「传了白传」)
- JwtAuthFilter: 管理端 HttpOnly Cookie(dy_op_at)兜底鉴权 + 头优先于 Cookie
- miniapp profileLabels: 枚举码→中文映射不退化(serviceTypeLabel / labelOf)
2026-09-30 14:05:48 +08:00
WorkBuddy 5c97c57c76 Merge branch 'main' of http://120.27.207.134:13000/admin/danyang-match
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
# Conflicts:
#	README.md
2026-09-30 13:44:13 +08:00
admin 8335ef780c Initial commit 2026-09-30 12:03:52 +08:00
18 changed files with 863 additions and 37 deletions
+7
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+3 -3
View File
@@ -174,8 +174,8 @@ const navigation: NavigationItem[] = [
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection },
{ label: '公益红娘', path: '/referral', permissions: ['matchmaker.service'], icon: Connection },
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.view'], icon: Connection },
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], 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('service_appointment.view')) return '服务站运营人员'
if (perms.includes('review.view')) return '资料审核人员'
if (perms.includes('matchmaker.service.view')) return '公益红娘'
if (perms.includes('matchmaker.view')) return '公益红娘'
return '运营人员'
})
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-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: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } },
{ 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.view'] } },
],
},
+2 -2
View File
@@ -13,9 +13,9 @@ interface OperatorInfo {
const previewRolePermissions: Record<string, string[]> = {
reviewer: ['review.view', 'user.view'],
counselor: ['counseling.service.view', 'referral.manage'],
counselor: ['counseling.service.view', 'matchmaker.service'],
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', () => {
@@ -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();
@@ -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);
}
@@ -95,9 +95,9 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approve(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
@RequestBody Map<String, Object> body) {
String operatorId = principal.id();
String comment = body.getOrDefault("comment", "");
String comment = (String) body.getOrDefault("comment", "");
return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment));
}
@@ -140,8 +140,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> needEvidence(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/approve-local-eligibility")
@@ -149,8 +149,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approveLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/reject-local-eligibility")
@@ -158,8 +158,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> rejectLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/evidences/{evidenceId}/access")
@@ -168,9 +168,9 @@ public class ReviewController {
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@PathVariable String evidenceId,
@RequestBody Map<String, String> body) {
@RequestBody Map<String, Object> body) {
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;
@@ -0,0 +1,98 @@
package com.danyang.match.platform.auth.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 回归守卫:刷新 Token 在 Redis 中登记的 TTL 必须与 refresh JWT 自身有效期一致
* ({@code JwtTokenService#getRefreshExpirationMs},默认 14 天),而不是
* {@code getExpirationMs() * 2}(4 小时)。
*
* <p>此前 AuthService 用 {@code getExpirationMs() * 2} 写入,导致 4h 后 Redis 记录消失、
* 刷新即失效,用户被迫频繁重新登录。本测试钉死两处写入点(登录 / 刷新)。
*/
@ExtendWith(MockitoExtension.class)
class AuthServiceRefreshTtlTest {
@Mock private UserMapper userMapper;
@Mock private WechatLoginProvider wechatLoginProvider;
@Mock private JwtTokenService jwtTokenService;
@Mock private StringRedisTemplate redisTemplate;
@Mock private com.danyang.match.platform.audit.mapper.AuditLogMapper auditLogMapper;
@Mock private com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper localEligibilityMapper;
@Mock private com.danyang.match.platform.capability.service.CapabilityService capabilityService;
@Mock private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
@Mock private com.danyang.match.platform.review.mapper.ReviewTaskMapper reviewTaskMapper;
@Mock private ValueOperations<String, String> valueOps;
@InjectMocks private AuthService authService;
private static final long ACCESS_MS = 7_200_000L; // 2h
private static final long REFRESH_MS = 1_209_600_000L; // 14d
/** 捕获 refresh token 在 Redis 中的登记 TTL。 */
private Duration captureRefreshTtl() {
ArgumentCaptor<Duration> cap = ArgumentCaptor.forClass(Duration.class);
verify(valueOps).set(eq("auth:refresh_token:u1"), anyString(), cap.capture());
return cap.getValue();
}
@Test
void wechatLoginStoresRefreshTokenWithRefreshExpiration() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(wechatLoginProvider.getCodeSession("code")).thenReturn(Map.of("openid", "o1"));
User user = new User();
user.setId("u1");
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
user.setPhone(null);
when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user));
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh");
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
when(auditLogMapper.insert(any())).thenReturn(1);
when(userMapper.updateById(any())).thenReturn(1);
authService.wechatLogin("code");
Duration ttl = captureRefreshTtl();
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "登录:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
}
@Test
void refreshTokenStoresRefreshTokenWithRefreshExpiration() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(jwtTokenService.validateRefreshToken("rt")).thenReturn(true);
when(jwtTokenService.getUserIdFromRefreshToken("rt")).thenReturn("u1");
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.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
authService.refreshToken("u1", "rt");
Duration ttl = captureRefreshTtl();
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "刷新:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
}
}
@@ -0,0 +1,69 @@
package com.danyang.match.platform.photo;
import com.amazonaws.services.s3.AmazonS3;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.photo.service.PhotoService;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 回归守卫:照片直传落库(此前「传了白传」——upload-token 之后没有任何端点写 user_photos)。
* <ul>
* <li>非法的 fileKey(不归属本人)必须被归属护栏拒绝;</li>
* <li>对象尚未上传完成(存储不存在)必须被存在性护栏拒绝;</li>
* <li>合法且已上传的直传必须真正落库(写入 user_photos 记录)。</li>
* </ul>
*/
class PhotoServiceConfirmUploadTest {
private final UserPhotoMapper photoMapper = mock(UserPhotoMapper.class);
private final AmazonS3 amazonS3 = mock(AmazonS3.class);
private final FeatureFlagConfig featureFlags = mock(FeatureFlagConfig.class);
private final ProfilePolicy profilePolicy = mock(ProfilePolicy.class);
private final UserMapper userMapper = mock(UserMapper.class);
private final AmazonS3 amazonS3Signer = mock(AmazonS3.class);
private final PhotoService photoService =
new PhotoService(photoMapper, amazonS3, featureFlags, profilePolicy, userMapper, amazonS3Signer);
@Test
void illegalFileKeyRejected() {
BusinessException ex = assertThrows(BusinessException.class,
() -> photoService.confirmDirectUpload("u1", "evil/key.jpg", "avatar"));
assertEquals("非法的文件标识", ex.getMessage());
}
@Test
void missingObjectRejected() {
when(amazonS3.doesObjectExist(any(), eq("photos/u1/x.jpg"))).thenReturn(false);
BusinessException ex = assertThrows(BusinessException.class,
() -> photoService.confirmDirectUpload("u1", "photos/u1/x.jpg", "lifestyle"));
assertEquals("文件尚未上传完成", ex.getMessage());
}
@Test
void validUploadPersistsPhoto() {
when(amazonS3.doesObjectExist(any(), eq("photos/u1/p.jpg"))).thenReturn(true);
when(photoMapper.countActiveProfilePhotos("u1")).thenReturn(0L);
when(profilePolicy.getMaxPhotoCount()).thenReturn(6);
when(photoMapper.insert(any())).thenReturn(1);
UserPhoto photo = photoService.confirmDirectUpload("u1", "photos/u1/p.jpg", "lifestyle");
assertNotNull(photo, "直传必须真实落库,不得白传");
assertEquals("u1", photo.getUserId());
assertEquals("photos/u1/p.jpg", photo.getFileKey());
assertEquals("lifestyle", photo.getPhotoType());
assertEquals(Constants.REVIEW_STATUS_PENDING, photo.getReviewStatus());
verify(photoMapper).insert(any(UserPhoto.class));
}
}
@@ -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);
}
}
}
@@ -16,6 +16,7 @@ import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.servlet.http.Cookie;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@@ -98,4 +99,77 @@ class JwtAuthFilterTest {
request.addHeader("Authorization", "Bearer " + token);
return request;
}
// ===== 本轮修复回归:管理端 HttpOnly Cookie(dy_op_at)兜底鉴权 =====
@Test
void cookieFallbackAuthenticatesOperatorWhenNoHeader() throws Exception {
String token = "cookie-op-token";
when(jwtTokenService.validateToken(token)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(token)).thenReturn("operator-1");
when(jwtTokenService.parseToken(token)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("operator_access");
when(permissionService.getPermissions("operator-1")).thenReturn(List.of("content.edit"));
filter.doFilter(cookieRequest(token), new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(OperatorPrincipal.class, authentication.getPrincipal());
assertEquals("operator-1", ((OperatorPrincipal) authentication.getPrincipal()).id());
}
@Test
void cookieFallbackAuthenticatesUserWhenNoHeader() throws Exception {
String token = "cookie-user-token";
when(jwtTokenService.validateToken(token)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(token)).thenReturn("user-1");
when(jwtTokenService.parseToken(token)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("access");
filter.doFilter(cookieRequest(token), new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(UserPrincipal.class, authentication.getPrincipal());
}
@Test
void cookieWithInvalidTokenReturns401() throws Exception {
String token = "bad-cookie-token";
when(jwtTokenService.validateToken(token)).thenReturn(false);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(cookieRequest(token), response, new MockFilterChain());
assertEquals(401, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
void headerTakesPrecedenceOverCookie() throws Exception {
// Authorization 头里是有效 user token;Cookie 里是无效 token,应被忽略(Cookie 仅作兜底)
String headerToken = "header-token";
when(jwtTokenService.validateToken(headerToken)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1");
when(jwtTokenService.parseToken(headerToken)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("access");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.addHeader("Authorization", "Bearer " + headerToken);
request.setCookies(new Cookie("dy_op_at", "bad-cookie"));
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(UserPrincipal.class, authentication.getPrincipal());
assertEquals("user-1", ((UserPrincipal) authentication.getPrincipal()).id());
}
private MockHttpServletRequest cookieRequest(String token) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.setCookies(new Cookie("dy_op_at", token));
return request;
}
}
@@ -0,0 +1,110 @@
package com.danyang.match.platform.verification.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* RealIdentityVerificationProvider 回归守卫:
* <ul>
* <li>伪造身份证号(校验位不符)必须在发起任何外部调用前被本地强校验拦截;</li>
* <li>配置缺失(apiUrl / apiKey 为空)必须 fail-fast,绝不静默放行任意证件;</li>
* <li>第三方核验 fail-closed:响应未明确「通过」一律视为未通过。</li>
* </ul>
*
* <p>使用 JDK 内置 {@link HttpServer} 模拟第三方核验端点,无需任何额外依赖。
*/
class RealIdentityVerificationProviderTest {
// 按 GB 11643-1999 手工计算得到的合法校验位身份证(前 17 位 + 校验位 '8')
private static final String VALID_ID = "110101199003073458";
// 校验位应为 '8',此处写成 'X' → 非法
private static final String INVALID_ID = "11010119900307345X";
private HttpServer server;
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
private RealIdentityVerificationProvider newProvider(String apiUrl, String apiKey) throws Exception {
RealIdentityVerificationProvider p = new RealIdentityVerificationProvider();
setField(p, "apiUrl", apiUrl);
setField(p, "apiKey", apiKey);
return p;
}
private void setField(Object target, String name, String value) throws Exception {
Field f = RealIdentityVerificationProvider.class.getDeclaredField(name);
f.setAccessible(true);
f.set(target, value);
}
/** 启动本地模拟端点,返回其 URL。 */
private String startServer(int statusCode, String body) throws Exception {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/verify", exchange -> {
byte[] resp = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, resp.length);
exchange.getResponseBody().write(resp);
exchange.close();
});
server.start();
return "http://localhost:" + server.getAddress().getPort() + "/verify";
}
@Test
void forgedIdRejectedBeforeAnyNetworkCall() throws Exception {
// apiUrl / apiKey 仅作占位(非空,避免误触 fail-fast 分支);
// 校验位不符应直接判未通过,且不应发起任何外部请求
RealIdentityVerificationProvider provider = newProvider("http://localhost:1/verify", "k");
Map<String, Object> result = provider.verify("张三", INVALID_ID);
assertFalse((Boolean) result.get("verified"), "校验位不符的伪造证件号必须被本地强校验判定为未通过");
}
@Test
void missingConfigFailsFast() throws Exception {
RealIdentityVerificationProvider provider = newProvider("", "");
BusinessException ex = assertThrows(BusinessException.class,
() -> provider.verify("张三", VALID_ID));
assertEquals(ApiResponseCode.SERVER_ERROR, ex.getCode(),
"缺第三方配置时必须 fail-fast 而非放行任意证件");
}
@Test
void validIdWithThirdPartySuccessVerified() throws Exception {
String url = startServer(200, "{\"success\":true}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertTrue((Boolean) result.get("verified"), "校验位合法 + 第三方明确通过 = 通过");
}
@Test
void validIdWithThirdPartyFailureNotVerified() throws Exception {
String url = startServer(200, "{\"success\":false}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertFalse((Boolean) result.get("verified"), "第三方未明确「通过」 = 未通过(fail-closed)");
}
@Test
void validIdWithThirdPartyErrorNotVerified() throws Exception {
String url = startServer(500, "{\"message\":\"internal\"}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertFalse((Boolean) result.get("verified"), "第三方非 2xx 响应 = 未通过(fail-closed)");
}
}
+3 -3
View File
@@ -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 的镜像源),不得"重试蒙过"。
+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)
}
+28
View File
@@ -0,0 +1,28 @@
// 纯函数回归测试:枚举码 → 中文映射(修复「枚举码直渲未映射中文」)
// 运行:node ui-tests/profileLabels.test.mjs
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { serviceTypeLabel, labelOf } = require('../miniapp/constants/profileLabels.js')
let passed = 0
function check(name, actual, expected) {
assert.equal(actual, expected, `${name}: 期望 "${expected}",实际 "${actual}"`)
passed++
}
// ===== 红娘服务类型映射(本次修复点:matchmaker-workbench 卡片曾直渲枚举码)=====
check('recommend_partner', serviceTypeLabel('recommend_partner'), '推荐对象')
check('review_preference', serviceTypeLabel('review_preference'), '择偶指导')
check('contact_intention', serviceTypeLabel('contact_intention'), '牵线意向')
check('未知码回退原值', serviceTypeLabel('mystery_code'), 'mystery_code')
check('空串返回空', serviceTypeLabel(''), '')
check('null 返回空', serviceTypeLabel(null), '')
// ===== 通用 labelOf(既有字典,回归不退化)=====
check('labelOf education', labelOf('educationCode', 'bachelor'), '本科')
check('labelOf marital', labelOf('maritalStatus', 'never_married'), '未婚')
check('labelOf 未知码回退', labelOf('maritalStatus', 'xxx'), 'xxx')
console.log(`✅ profileLabels 回归测试全部通过(${passed} 项)`)