fix: 收口红娘工作台权限与组织范围

This commit is contained in:
WorkBuddy
2026-10-25 08:50:18 +08:00
parent a3bd1e8a95
commit 88f4176e60
4 changed files with 220 additions and 0 deletions
@@ -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 错误。
## 范围
本次仅涉及工作台服务及新增授权/范围单元测试;未修改迁移、控制器或其他任务文件。
@@ -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<String, Object> dashboard(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.UNAUTHORIZED, "运营账号不存在"));
List<Role> roles = roleMapper.findByOperatorId(operatorId);
MatchmakerDashboardScope scope = resolve(operator, roles);
Map<String, Object> 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<String, Object> 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<Role> 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<String, Object> metric(String operatorId, String detailPermission, java.util.function.LongSupplier valueSupplier) {
Map<String, Object> 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());
}
}
@@ -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());
}
}
@@ -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")));
}
}