Compare commits
2
Commits
9fc3385d6c
...
4edb7619bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4edb7619bc | ||
|
|
dfb74f3386 |
@@ -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)))
|
||||
|
||||
@@ -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'] } },
|
||||
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+10
-10
@@ -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;
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user