diff --git a/backend/src/main/resources/db/migration/V072__validate_matchmaker_organization_scope.sql b/backend/src/main/resources/db/migration/V072__validate_matchmaker_organization_scope.sql new file mode 100644 index 0000000..7a8d1fa --- /dev/null +++ b/backend/src/main/resources/db/migration/V072__validate_matchmaker_organization_scope.sql @@ -0,0 +1,5 @@ +-- V072: 为操作员组织范围查询补充幂等索引。 +-- 仅优化已有 nullable 外键列的查询;不改变存量组织归属,也不把 NULL 组织强行归组。 +CREATE INDEX IF NOT EXISTS idx_operators_organization_id + ON operators (organization_id) + WHERE organization_id IS NOT NULL; diff --git a/backend/src/main/resources/db/migration/V073__align_operator_role_permissions.sql b/backend/src/main/resources/db/migration/V073__align_operator_role_permissions.sql new file mode 100644 index 0000000..95bd570 --- /dev/null +++ b/backend/src/main/resources/db/migration/V073__align_operator_role_permissions.sql @@ -0,0 +1,41 @@ +-- V073: 对齐 V1.2 其他运营角色的最小权限集合。 +-- 仅按角色代码授予明确权限;不授予平台范围 analytics/data.export/permission.manage。 +-- 幂等:重复执行不会产生重复 role_permissions。 + +-- reviewer:资料与本地资格审核,不进入红娘、咨询、活动或风险域。 +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r CROSS JOIN permissions p +WHERE r.code = 'reviewer' + AND p.code IN ('review.view', 'review.process', 'local_eligibility.view', 'local_eligibility.review') +ON CONFLICT (role_id, permission_id) DO NOTHING; + +-- activity_manager:活动流程及活动所需的非敏感用户基础信息。 +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r CROSS JOIN permissions p +WHERE r.code = 'activity_manager' + AND p.code IN ('user.view', 'activity.view', 'activity.create', 'activity.review_registration', 'activity.checkin') +ON CONFLICT (role_id, permission_id) DO NOTHING; + +-- risk_operator:举报处置及案件所需用户信息;敏感查看是风控专门授权。 +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r CROSS JOIN permissions p +WHERE r.code = 'risk_operator' + AND p.code IN ('user.view', 'user.sensitive.view', 'risk.report.view', 'risk.report.process', 'risk.user.ban') +ON CONFLICT (role_id, permission_id) DO NOTHING; + +-- organization_admin:仅本组织的运营管理权限;组织范围由服务端数据范围门禁执行。 +-- 刻意不授 analytics.view:当前统计表缺少统一 organization_id,避免暴露全平台数据。 +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r CROSS JOIN permissions p +WHERE r.code = 'organization_admin' + AND p.code IN ( + 'user.view', 'review.view', 'review.process', + 'matchmaker.view', 'matchmaker.service', + 'activity.view', 'activity.create', 'activity.review_registration', + 'organization.manage' + ) +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index f88a075..643043a 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -41,7 +41,12 @@ class MigrationValidationTest { // 用户展示编号:V058 users.display_number 及历史数据回填。 // 公共身份展示编号:V059 operators.display_id 及历史数据回填。 // 红娘协助对象与授权字段:V061 matchmaker_services 扩展。 - private static final int LATEST_MIGRATION = 61; + // V1.2:V062-V066 账号生命周期、导入批次、服务授权、自主注册与建档权限; + // V067 工作台聚合权限与红娘角色边界;V068/V069 红娘工作台权限; + // V070 收回红娘的咨询、活动、预约、统计权限; + // V071 收回红娘的资料审核、本地资格审核权限。 + // V073 对齐 reviewer、activity_manager、risk_operator、organization_admin 最小权限。 + private static final int LATEST_MIGRATION = 73; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; @@ -77,7 +82,10 @@ class MigrationValidationTest { assertTrue(version >= 1 && version <= LATEST_MIGRATION, "版本号超出当前基线: " + name); } - assertEquals(LATEST_MIGRATION, count, "迁移文件数量不符合当前基线"); + // V072 为 Task 2 的可选范围约束迁移;未生成时允许 V073 合法跳号。 + int expectedCount = Files.exists(Paths.get(MIGRATION_DIR, "V072__validate_matchmaker_organization_scope.sql")) + ? LATEST_MIGRATION : LATEST_MIGRATION - 1; + assertEquals(expectedCount, count, "迁移文件数量不符合当前基线"); } } @@ -94,6 +102,7 @@ class MigrationValidationTest { content.contains("CREATE INDEX") || content.contains("CREATE UNIQUE INDEX") || content.contains("INSERT INTO") || + content.contains("DELETE FROM") || content.contains("CREATE TYPE"); assertTrue(hasDDL || content.contains("占位"), "迁移文件缺少DDL语句且未声明为占位: " + file.getFileName()); diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/OperatorRolePermissionContractTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/OperatorRolePermissionContractTest.java new file mode 100644 index 0000000..de2659f --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/deployment/OperatorRolePermissionContractTest.java @@ -0,0 +1,70 @@ +package com.danyang.match.platform.deployment; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +/** Locks the minimum V1.2 permissions for non-admin operator roles. */ +class OperatorRolePermissionContractTest { + + private static final Path MIGRATION = Path.of( + "backend/src/main/resources/db/migration/V073__align_operator_role_permissions.sql"); + + @Test + void migrationIsIdempotentAndKeepsEachRoleScoped() throws Exception { + String sql = Files.readString(MIGRATION); + assertEquals(4, count(sql, "WHERE r.code = '"), "V073 应只处理四个正式运营角色"); + assertEquals(4, count(sql, "ON CONFLICT (role_id, permission_id) DO NOTHING"), + "每个角色授权块必须幂等"); + assertFalse(sql.contains("SELECT r.id, p.id FROM roles r CROSS JOIN permissions p\nWHERE p.code"), + "禁止无角色条件的批量授权"); + assertFalse(sql.contains("p.code IN (SELECT"), "禁止通过全量权限子查询扩权"); + } + + @Test + void minimumPermissionsAndForbiddenBoundariesAreExplicit() throws Exception { + String sql = Files.readString(MIGRATION); + Map required = Map.of( + "reviewer", new String[]{"review.view", "review.process", "local_eligibility.view", "local_eligibility.review"}, + "activity_manager", new String[]{"user.view", "activity.view", "activity.create", "activity.review_registration", "activity.checkin"}, + "risk_operator", new String[]{"user.view", "user.sensitive.view", "risk.report.view", "risk.report.process", "risk.user.ban"}, + "organization_admin", new String[]{"user.view", "review.view", "review.process", "matchmaker.view", "matchmaker.service", "activity.view", "activity.create", "activity.review_registration", "organization.manage"} + ); + for (Map.Entry entry : required.entrySet()) { + String block = roleBlock(sql, entry.getKey()); + for (String permission : entry.getValue()) { + assertTrue(block.contains("'" + permission + "'"), + entry.getKey() + " 缺少最小权限 " + permission); + } + assertFalse(block.contains("analytics.view"), entry.getKey() + " 不得获得平台统计权限"); + assertFalse(block.contains("data.export"), entry.getKey() + " 不得获得数据导出权限"); + assertFalse(block.contains("permission.manage"), entry.getKey() + " 不得获得权限管理权限"); + } + assertFalse(sql.contains("r.code = 'super_admin'") || sql.contains("r.code = 'platform_admin'"), + "V073 不得修改管理员角色授权"); + } + + private static String roleBlock(String sql, String role) { + Pattern pattern = Pattern.compile("WHERE r\\.code = '" + Pattern.quote(role) + + "'(.*?)ON CONFLICT", Pattern.DOTALL); + Matcher matcher = pattern.matcher(sql); + assertTrue(matcher.find(), "缺少角色授权块: " + role); + return matcher.group(1); + } + + private static int count(String value, String needle) { + int count = 0; + int from = 0; + while ((from = value.indexOf(needle, from)) >= 0) { + count++; + from += needle.length(); + } + return count; + } +} diff --git a/docs/Phase-V12-Task5-Completion-Report.md b/docs/Phase-V12-Task5-Completion-Report.md new file mode 100644 index 0000000..464c695 --- /dev/null +++ b/docs/Phase-V12-Task5-Completion-Report.md @@ -0,0 +1,37 @@ +# V1.2 Task 5 Completion Report + +## Status + +PARTIAL + +## Implemented + +- 新增 `V073__align_operator_role_permissions.sql`,以角色代码交叉查询幂等授予四类运营角色的最小权限: + - `reviewer`:资料/本地资格审核; + - `activity_manager`:活动流程及必要的基础用户信息; + - `risk_operator`:举报处置及风控案件所需信息; + - `organization_admin`:组织内运营管理,不授平台统计权限。 +- 未授予 `analytics.view`、`data.export`、`permission.manage`,也未修改管理员角色或历史迁移。 +- 新增 `OperatorRolePermissionContractTest`,锁定角色授权块、幂等性和禁止扩权边界。 +- 更新迁移验证守卫以支持可选 V072 后的合法 V073 跳号。 +- 补充 V072 的幂等组织索引,支持 Task 2 的组织范围查询且不改变存量数据。 +- 管理端菜单/认证文件未修改;现有入口已按服务端权限码显隐,Task 4 文件保持不变。 + +## Automated Tests + +```text +mvn -B -f backend/pom.xml -s backend/maven-settings.xml "-Dtest=com.danyang.match.platform.deployment.OperatorRolePermissionContractTest" test +2 tests passed, 0 failures, 0 errors +``` + +迁移命名/结构守卫已覆盖 V001-V073;当前未连接干净 PostgreSQL,因此未声称数据库迁移验收通过。 + +## Not Verified + +- 未在干净 PostgreSQL/Redis 栈执行 Flyway V001-V073 和真实角色登录验证。 +- 未执行完整后端测试、管理端构建或浏览器验收。 +- `organization_admin` 的组织范围仍依赖后端数据范围门禁,迁移本身不提供跨组织隔离。 + +## Next Phase + +Task 6:使用干净运行栈验证角色登录、菜单、工作台/详情范围和跨组织拒绝。