2 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
7 changed files with 198 additions and 17 deletions
+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', () => {
@@ -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,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);
}
}
}
+5
View File
@@ -41,6 +41,10 @@ const backendTestRoot = resolve(root, 'backend', 'src', 'test', 'java')
// MeRouteContractTest / CanonicalRouteContractTest
// PhotoLimitGuardTest / PhotoLimitConcurrencyGuardTest / PhotoUploadValidationTest
// OperatorAuthServiceTest
// 第 14 项(2026-10-09):审核决策端点的「请求体契约」守卫 —— 前端统一发送 {comment, reasonCodes[]},
// 若 @RequestBody 被再度收窄为 Map<String,String>,数组值反序列化失败即 400
// (现象:「小程序提交资料后,管理员点通过直接报错」)。
// ReviewControllerRequestBodyContractTest
// 新增/删减请同步修改本数组(下限随之变化)。
const CURATED = [
'AuthServiceRefreshTtlTest',
@@ -56,6 +60,7 @@ const CURATED = [
'PhotoLimitConcurrencyGuardTest',
'PhotoUploadValidationTest',
'OperatorAuthServiceTest',
'ReviewControllerRequestBodyContractTest',
]
const MIN_GUARDS = Number(process.env.BACKEND_GUARDS_MIN ?? CURATED.length)