diff --git a/.superpowers/sdd/2026-10-25-v12-permission-scope-dashboard-remediation/task-2-report.md b/.superpowers/sdd/2026-10-25-v12-permission-scope-dashboard-remediation/task-2-report.md new file mode 100644 index 0000000..e9aee3f --- /dev/null +++ b/.superpowers/sdd/2026-10-25-v12-permission-scope-dashboard-remediation/task-2-report.md @@ -0,0 +1,23 @@ +# Task 2 完成报告 + +## 结果 + +- 工作台六项指标先按各自 `detailPermission` 判断;无权限仅返回 `available=false` 与权限标识,不执行统计查询或返回真实值。 +- 授权指标继续返回真实统计值,空数据保持 `0`。 +- `matchmaker_leader` 未绑定组织时返回 `CROSS_ORGANIZATION_DENIED`。 +- 团队范围使用严格 `organization_id = ?`,不再把 NULL 组织视为同一团队。 +- 统计统一排除没有已发布资料 revision 的用户。 + +## 验证 + +执行: + +```text +mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=MatchmakerWorkbenchAuthorizationTest,MatchmakerWorkbenchScopeTest test +``` + +结果:3 个测试通过,0 失败,0 错误。 + +## 范围 + +本次仅涉及工作台服务及新增授权/范围单元测试;未修改迁移、控制器或其他任务文件。 diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/dashboard/MatchmakerWorkbenchService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/dashboard/MatchmakerWorkbenchService.java new file mode 100644 index 0000000..f35efc9 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/dashboard/MatchmakerWorkbenchService.java @@ -0,0 +1,95 @@ +package com.danyang.match.platform.matchmaker.dashboard; + +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.entity.Role; +import com.danyang.match.platform.permission.mapper.OperatorMapper; +import com.danyang.match.platform.permission.mapper.RoleMapper; +import com.danyang.match.platform.permission.service.PermissionService; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class MatchmakerWorkbenchService { + private final OperatorMapper operatorMapper; + private final RoleMapper roleMapper; + private final PermissionService permissionService; + private final JdbcTemplate jdbcTemplate; + + public Map dashboard(String operatorId) { + Operator operator = operatorMapper.findById(operatorId) + .orElseThrow(() -> new BusinessException(ApiResponseCode.UNAUTHORIZED, "运营账号不存在")); + List roles = roleMapper.findByOperatorId(operatorId); + MatchmakerDashboardScope scope = resolve(operator, roles); + + Map metrics = new LinkedHashMap<>(); + metrics.put("pendingReviews", metric(operatorId, "review.view", () -> count("review_tasks", "status IN ('pending','processing')", scope))); + metrics.put("counselingTodos", metric(operatorId, "counseling.service.view", () -> count("counseling_services", "status IN ('pending','assigned','scheduled','in_progress')", scope))); + metrics.put("serviceStationAppointments", metric(operatorId, "service_appointment.view", () -> count("service_appointments", "status IN ('submitted','pending_confirmation','confirmed')", scope))); + metrics.put("effectiveMatches", metric(operatorId, "analytics.view", () -> countMatches(scope))); + metrics.put("activityRegistrations", metric(operatorId, "activity.review_registration", () -> count("activity_registrations", "status IN ('pending','approved')", scope))); + metrics.put("matchmakerTodos", metric(operatorId, "matchmaker.view", () -> count("matchmaker_assignments", "status = 'active'", scope))); + + Map result = new LinkedHashMap<>(); + result.put("scope", scope.type()); + result.put("scopeLabel", "ALL".equals(scope.type()) ? "全平台" : "TEAM".equals(scope.type()) ? "本组织团队" : "本人服务"); + result.put("metrics", metrics); + result.put("generatedAt", java.time.Instant.now()); + return result; + } + + private MatchmakerDashboardScope resolve(Operator operator, List roles) { + boolean all = roles.stream().anyMatch(r -> Set.of("super_admin", "platform_admin").contains(r.getCode())); + if (all) return new MatchmakerDashboardScope("ALL", operator.getId(), operator.getOrganizationId()); + boolean team = roles.stream().anyMatch(r -> "matchmaker_leader".equals(r.getCode())); + if (team && (operator.getOrganizationId() == null || operator.getOrganizationId().isBlank())) { + throw new BusinessException(ApiResponseCode.CROSS_ORGANIZATION_DENIED, "红娘组长必须绑定组织"); + } + return new MatchmakerDashboardScope(team ? "TEAM" : "SELF", operator.getId(), operator.getOrganizationId()); + } + + private Map metric(String operatorId, String detailPermission, java.util.function.LongSupplier valueSupplier) { + Map item = new LinkedHashMap<>(); + item.put("detailPermission", detailPermission); + if (!permissionService.hasPermission(operatorId, detailPermission)) { + item.put("available", false); + return item; + } + item.put("value", valueSupplier.getAsLong()); + item.put("available", true); + return item; + } + + private long count(String table, String statusPredicate, MatchmakerDashboardScope scope) { + String ownerColumn = "activity_registrations".equals(table) ? "created_by_operator_id" + : "matchmaker_assignments".equals(table) ? "matchmaker_operator_id" : "assigned_operator_id"; + String sql = "SELECT COUNT(*) FROM " + table + " x WHERE x.deleted_at IS NULL AND " + statusPredicate + + " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = x.user_id" + + " AND up.published_profile_revision IS NOT NULL" + + " AND up.published_profile_revision = up.profile_revision)"; + if (scope.all()) return jdbcTemplate.queryForObject(sql, Long.class); + if (scope.team()) { + return jdbcTemplate.queryForObject(sql + " AND x." + ownerColumn + " IN (SELECT id FROM operators WHERE organization_id = ?)", Long.class, scope.organizationId()); + } + return jdbcTemplate.queryForObject(sql + " AND x." + ownerColumn + " = ?", Long.class, scope.operatorId()); + } + + private long countMatches(MatchmakerDashboardScope scope) { + String base = "SELECT COUNT(DISTINCT m.id) FROM matches m WHERE m.deleted_at IS NULL AND m.status='active' AND m.source='matchmaker'" + + " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = m.user_a_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision = up.profile_revision)" + + " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = m.user_b_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision = up.profile_revision)"; + if (scope.all()) return jdbcTemplate.queryForObject(base, Long.class); + String join = " AND EXISTS (SELECT 1 FROM matchmaker_recommendations r JOIN matchmaker_services s ON s.id=r.service_id JOIN operators o ON o.id=s.assigned_operator_id WHERE (r.id::text=m.source_id::text OR r.service_id::text=m.source_id::text) AND " + + (scope.team() ? "o.organization_id = ?" : "(r.operator_id=? OR s.assigned_operator_id=?)") + ")"; + return scope.team() ? jdbcTemplate.queryForObject(base + join, Long.class, scope.organizationId()) + : jdbcTemplate.queryForObject(base + join, Long.class, scope.operatorId(), scope.operatorId()); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWorkbenchAuthorizationTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWorkbenchAuthorizationTest.java new file mode 100644 index 0000000..7f86f5e --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWorkbenchAuthorizationTest.java @@ -0,0 +1,59 @@ +package com.danyang.match.platform.matchmaker; + +import com.danyang.match.platform.matchmaker.dashboard.MatchmakerWorkbenchService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.entity.Role; +import com.danyang.match.platform.permission.mapper.OperatorMapper; +import com.danyang.match.platform.permission.mapper.RoleMapper; +import com.danyang.match.platform.permission.service.PermissionService; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class MatchmakerWorkbenchAuthorizationTest { + @Test + void unauthorizedMetricDoesNotQueryOrExposeValue() { + OperatorMapper operators = mock(OperatorMapper.class); + RoleMapper roles = mock(RoleMapper.class); + PermissionService permissions = mock(PermissionService.class); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + Operator operator = new Operator(); + operator.setId("op-1"); + when(operators.findById("op-1")).thenReturn(Optional.of(operator)); + when(roles.findByOperatorId("op-1")).thenReturn(List.of()); + when(permissions.hasPermission("op-1", "review.view")).thenReturn(false); + + var result = new MatchmakerWorkbenchService(operators, roles, permissions, jdbc).dashboard("op-1"); + var metric = (java.util.Map) ((java.util.Map) result.get("metrics")).get("pendingReviews"); + assertEquals(false, metric.get("available")); + assertEquals("review.view", metric.get("detailPermission")); + assertFalse(metric.containsKey("value")); + verifyNoInteractions(jdbc); + } + + @Test + void leaderWithoutOrganizationIsRejected() { + OperatorMapper operators = mock(OperatorMapper.class); + RoleMapper roles = mock(RoleMapper.class); + PermissionService permissions = mock(PermissionService.class); + Operator operator = new Operator(); + operator.setId("leader-1"); + when(operators.findById("leader-1")).thenReturn(Optional.of(operator)); + Role leader = new Role(); + leader.setCode("matchmaker_leader"); + when(roles.findByOperatorId("leader-1")).thenReturn(List.of(leader)); + + BusinessException error = assertThrows(BusinessException.class, + () -> new MatchmakerWorkbenchService(operators, roles, permissions, mock(JdbcTemplate.class)) + .dashboard("leader-1")); + assertEquals(ApiResponseCode.CROSS_ORGANIZATION_DENIED, error.getCode()); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWorkbenchScopeTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWorkbenchScopeTest.java new file mode 100644 index 0000000..9194424 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWorkbenchScopeTest.java @@ -0,0 +1,43 @@ +package com.danyang.match.platform.matchmaker; + +import com.danyang.match.platform.matchmaker.dashboard.MatchmakerWorkbenchService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.entity.Role; +import com.danyang.match.platform.permission.mapper.OperatorMapper; +import com.danyang.match.platform.permission.mapper.RoleMapper; +import com.danyang.match.platform.permission.service.PermissionService; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class MatchmakerWorkbenchScopeTest { + @Test + void teamScopeUsesStrictOrganizationEquality() { + OperatorMapper operators = mock(OperatorMapper.class); + RoleMapper roles = mock(RoleMapper.class); + PermissionService permissions = mock(PermissionService.class); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + Operator operator = new Operator(); + operator.setId("leader-1"); + operator.setOrganizationId("org-1"); + when(operators.findById("leader-1")).thenReturn(Optional.of(operator)); + Role leader = new Role(); + leader.setCode("matchmaker_leader"); + when(roles.findByOperatorId("leader-1")).thenReturn(List.of(leader)); + when(permissions.hasPermission(anyString(), anyString())).thenReturn(true); + when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(0L); + + new MatchmakerWorkbenchService(operators, roles, permissions, jdbc).dashboard("leader-1"); + var sql = mockingDetails(jdbc).getInvocations().stream() + .map(i -> String.valueOf(i.getArguments()[0])) + .filter(s -> s.startsWith("SELECT COUNT")) + .toList(); + assertFalse(sql.stream().anyMatch(s -> s.contains("IS NOT DISTINCT FROM"))); + } +}