fix: 收口红娘工作台权限与组织范围
This commit is contained in:
+23
@@ -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 错误。
|
||||
|
||||
## 范围
|
||||
|
||||
本次仅涉及工作台服务及新增授权/范围单元测试;未修改迁移、控制器或其他任务文件。
|
||||
+95
@@ -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());
|
||||
}
|
||||
}
|
||||
+59
@@ -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());
|
||||
}
|
||||
}
|
||||
+43
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user