fix: restore matchmaker workbench permissions

This commit is contained in:
WorkBuddy
2026-10-25 09:30:13 +08:00
parent 692d71696a
commit f6744d3f91
6 changed files with 171 additions and 26 deletions
+15 -20
View File
@@ -106,12 +106,11 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
const workbenchValue = (key: string) => numberOf(recordOf(workbenchMetrics, key), 'value') const workbenchValue = (key: string) => numberOf(recordOf(workbenchMetrics, key), 'value')
const hasWorkbenchMetric = (key: string) => Object.prototype.hasOwnProperty.call(workbenchMetrics, key) const hasWorkbenchMetric = (key: string) => Object.prototype.hasOwnProperty.call(workbenchMetrics, key)
const workbenchAvailable = (key: string) => Boolean(recordOf(workbenchMetrics, key).available) const workbenchAvailable = (key: string) => Boolean(recordOf(workbenchMetrics, key).available)
const metricStatus = (key: string, permission: string, fallbackAvailable: boolean): DashboardMetric['status'] => { const metricStatus = (key: string): DashboardMetric['status'] => {
if (hasWorkbenchMetric(key)) return workbenchAvailable(key) ? 'ready' : 'forbidden' if (hasWorkbenchMetric(key)) return workbenchAvailable(key) ? 'ready' : 'forbidden'
if (!permissions.includes(permission)) return 'forbidden' return 'error'
return fallbackAvailable ? 'ready' : 'error'
} }
const metricAvailable = (key: string, permission: string, fallbackAvailable: boolean) => metricStatus(key, permission, fallbackAvailable) === 'ready' const metricAvailable = (key: string) => metricStatus(key) === 'ready'
const metricRoute = (status: DashboardMetric['status'], route: string) => status === 'ready' ? route : '' const metricRoute = (status: DashboardMetric['status'], route: string) => status === 'ready' ? route : ''
const core = fulfilledData<JsonRecord>(coreResult) const core = fulfilledData<JsonRecord>(coreResult)
const service = fulfilledData<JsonRecord>(serviceResult) const service = fulfilledData<JsonRecord>(serviceResult)
@@ -121,9 +120,6 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
const managedServices = fulfilledData<JsonRecord[]>(managementResult) const managedServices = fulfilledData<JsonRecord[]>(managementResult)
const managedStats = summarizeMatchmakerServices(managedServices || []) const managedStats = summarizeMatchmakerServices(managedServices || [])
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker) const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
const matchmakerPending = canManageMatchmaker
? managedStats.pending
: numberOf(matchmaker || undefined, 'pendingCount') + numberOf(matchmaker || undefined, 'inProgressCount')
const matchmakerCompleted = canManageMatchmaker const matchmakerCompleted = canManageMatchmaker
? managedStats.completed ? managedStats.completed
: numberOf(matchmaker || undefined, 'completedCount') : numberOf(matchmaker || undefined, 'completedCount')
@@ -134,38 +130,37 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
const analyticsAvailable = Boolean(core || service) const analyticsAvailable = Boolean(core || service)
const counseling = recordOf(service || undefined, 'counseling') const counseling = recordOf(service || undefined, 'counseling')
const appointments = recordOf(service || undefined, 'serviceAppointments')
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections') const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
const registrationSources = recordOf(service || undefined, 'registrationSources') const registrationSources = recordOf(service || undefined, 'registrationSources')
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers') const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
const metrics: DashboardMetric[] = [ const metrics: DashboardMetric[] = [
{ {
key: 'pending_reviews', label: '待审核资料', value: hasWorkbenchMetric('pendingReviews') ? workbenchValue('pendingReviews') : numberOf(reviews || undefined, 'pending') + numberOf(reviews || undefined, 'processing'), key: 'pending_reviews', label: '待审核资料', value: workbenchValue('pendingReviews'),
trendText: workbenchAvailable('pendingReviews') ? '当前数据范围内待审核资料' : (reviews ? '待领取与审核中资料' : '暂无授权数据'), tone: 'rose', available: metricAvailable('pendingReviews', 'review.view', Boolean(reviews)), status: metricStatus('pendingReviews', 'review.view', Boolean(reviews)), route: metricRoute(metricStatus('pendingReviews', 'review.view', Boolean(reviews)), '/review'), trendText: workbenchAvailable('pendingReviews') ? '当前数据范围内待审核资料' : '暂无授权数据', tone: 'rose', available: metricAvailable('pendingReviews'), status: metricStatus('pendingReviews'), route: metricRoute(metricStatus('pendingReviews'), '/review'),
}, },
{ {
key: 'matchmaker_todos', label: '红娘服务待办', key: 'matchmaker_todos', label: '红娘服务待办',
value: hasWorkbenchMetric('matchmakerTodos') ? workbenchValue('matchmakerTodos') : matchmakerPending, value: workbenchValue('matchmakerTodos'),
trendText: workbenchAvailable('matchmakerTodos') ? `${workbench?.scopeLabel || '当前范围'}待完成服务` : (matchmakerAvailable ? (canManageMatchmaker ? '管理范围内待完成服务' : '本人服务对象待跟进') : '暂无授权数据'), tone: 'amber', available: metricAvailable('matchmakerTodos', 'matchmaker.view', matchmakerAvailable), status: metricStatus('matchmakerTodos', 'matchmaker.view', matchmakerAvailable), route: metricRoute(metricStatus('matchmakerTodos', 'matchmaker.view', matchmakerAvailable), permissions.includes('permission.manage') ? '' : '/matchmaker'), trendText: workbenchAvailable('matchmakerTodos') ? `${workbench?.scopeLabel || '当前范围'}待完成服务` : '暂无授权数据', tone: 'amber', available: metricAvailable('matchmakerTodos'), status: metricStatus('matchmakerTodos'), route: metricRoute(metricStatus('matchmakerTodos'), permissions.includes('permission.manage') ? '' : '/matchmaker'),
}, },
{ {
key: 'counseling_todos', label: '咨询服务待办', key: 'counseling_todos', label: '咨询服务待办',
value: hasWorkbenchMetric('counselingTodos') ? workbenchValue('counselingTodos') : numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'), value: workbenchValue('counselingTodos'),
trendText: workbenchAvailable('counselingTodos') ? `${workbench?.scopeLabel || '当前范围'}咨询待办` : (service ? '待受理与已分配' : '暂无授权数据'), tone: 'blue', available: metricAvailable('counselingTodos', 'counseling.service.view', Boolean(service)), status: metricStatus('counselingTodos', 'counseling.service.view', Boolean(service)), route: metricRoute(metricStatus('counselingTodos', 'counseling.service.view', Boolean(service)), '/counseling'), trendText: workbenchAvailable('counselingTodos') ? `${workbench?.scopeLabel || '当前范围'}咨询待办` : '暂无授权数据', tone: 'blue', available: metricAvailable('counselingTodos'), status: metricStatus('counselingTodos'), route: metricRoute(metricStatus('counselingTodos'), '/counseling'),
}, },
{ {
key: 'station_appointments', label: '服务站预约', key: 'station_appointments', label: '服务站预约',
value: hasWorkbenchMetric('serviceStationAppointments') ? workbenchValue('serviceStationAppointments') : numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'), value: workbenchValue('serviceStationAppointments'),
trendText: workbenchAvailable('serviceStationAppointments') ? `${workbench?.scopeLabel || '当前范围'}服务站预约` : (service ? '待确认与已确认' : '暂无授权数据'), tone: 'green', available: metricAvailable('serviceStationAppointments', 'service_appointment.view', Boolean(service)), status: metricStatus('serviceStationAppointments', 'service_appointment.view', Boolean(service)), route: metricRoute(metricStatus('serviceStationAppointments', 'service_appointment.view', Boolean(service)), '/service-appointment'), trendText: workbenchAvailable('serviceStationAppointments') ? `${workbench?.scopeLabel || '当前范围'}服务站预约` : '暂无授权数据', tone: 'green', available: metricAvailable('serviceStationAppointments'), status: metricStatus('serviceStationAppointments'), route: metricRoute(metricStatus('serviceStationAppointments'), '/service-appointment'),
}, },
{ {
key: 'effective_matches', label: '有效匹配', value: hasWorkbenchMetric('effectiveMatches') ? workbenchValue('effectiveMatches') : numberOf(effectiveConnections, 'activeMatches'), key: 'effective_matches', label: '有效匹配', value: workbenchValue('effectiveMatches'),
trendText: workbenchAvailable('effectiveMatches') ? `${workbench?.scopeLabel || '当前范围'}有效匹配` : (service ? '当前活跃匹配' : '暂无授权数据'), tone: 'purple', available: metricAvailable('effectiveMatches', 'analytics.view', Boolean(service)), status: metricStatus('effectiveMatches', 'analytics.view', Boolean(service)), route: metricRoute(metricStatus('effectiveMatches', 'analytics.view', Boolean(service)), '/analytics'), trendText: workbenchAvailable('effectiveMatches') ? `${workbench?.scopeLabel || '当前范围'}有效匹配` : '暂无授权数据', tone: 'purple', available: metricAvailable('effectiveMatches'), status: metricStatus('effectiveMatches'), route: metricRoute(metricStatus('effectiveMatches'), '/analytics'),
}, },
{ {
key: 'activity_registrations', label: '活动报名', value: hasWorkbenchMetric('activityRegistrations') ? workbenchValue('activityRegistrations') : numberOf(registrationSources, 'totalRegistrations'), key: 'activity_registrations', label: '活动报名', value: workbenchValue('activityRegistrations'),
trendText: workbenchAvailable('activityRegistrations') ? `${workbench?.scopeLabel || '当前范围'}活动报名` : (service ? '累计有效报名' : '暂无授权数据'), tone: 'red', available: metricAvailable('activityRegistrations', 'activity.review_registration', Boolean(service)), status: metricStatus('activityRegistrations', 'activity.review_registration', Boolean(service)), route: metricRoute(metricStatus('activityRegistrations', 'activity.review_registration', Boolean(service)), '/activities/interests'), trendText: workbenchAvailable('activityRegistrations') ? `${workbench?.scopeLabel || '当前范围'}活动报名` : '暂无授权数据', tone: 'red', available: metricAvailable('activityRegistrations'), status: metricStatus('activityRegistrations'), route: metricRoute(metricStatus('activityRegistrations'), '/activities/interests'),
}, },
// 保留 forbidden 卡片以明确展示“无权限”;其数值只在 available 时渲染,route 由 metricRoute 置空。 // 保留 forbidden 卡片以明确展示“无权限”;其数值只在 available 时渲染,route 由 metricRoute 置空。
] ]
@@ -0,0 +1,15 @@
-- V074: 恢复红娘工作台六项卡片及已确认的业务操作权限。
-- 仅使用既有 permission code;不授予资格原始佐证、咨询备注查看或代用户决策权限。
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.code IN ('matchmaker', 'matchmaker_leader')
AND p.code IN (
'workbench.view', 'review.view', 'review.process', 'local_eligibility.review',
'counseling.service.view', 'counseling.service.assign', 'counseling.service.process',
'counseling.note.create', 'service_appointment.view', 'service_appointment.assign',
'service_appointment.confirm', 'service_appointment.complete', 'activity.view',
'activity.review_registration', 'analytics.view'
)
ON CONFLICT (role_id, permission_id) DO NOTHING;
@@ -45,8 +45,8 @@ class MigrationValidationTest {
// V067 工作台聚合权限与红娘角色边界;V068/V069 红娘工作台权限; // V067 工作台聚合权限与红娘角色边界;V068/V069 红娘工作台权限;
// V070 收回红娘的咨询、活动、预约、统计权限; // V070 收回红娘的咨询、活动、预约、统计权限;
// V071 收回红娘的资料审核、本地资格审核权限。 // V071 收回红娘的资料审核、本地资格审核权限。
// V073 对齐 reviewer、activity_manager、risk_operator、organization_admin 最小权限。 // V073 对齐其他运营角色;V074 恢复红娘工作台六卡片及操作权限。
private static final int LATEST_MIGRATION = 73; private static final int LATEST_MIGRATION = 74;
private static final String MIGRATION_DIR = private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/"; "backend/src/main/resources/db/migration/";
@@ -0,0 +1,110 @@
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.List;
import static org.junit.jupiter.api.Assertions.*;
class V12MigrationContractTest {
private static final Path DIR = Path.of("backend/src/main/resources/db/migration");
@Test
void v12MigrationsAndLifecycleContractsExist() throws Exception {
assertMigration("V062__add_v12_user_account_lifecycle.sql");
assertMigration("V063__add_v12_import_batches.sql");
assertMigration("V064__add_v12_consent_and_service_state.sql");
assertMigration("V065__add_v12_registration_applications.sql");
assertMigration("V066__grant_user_onboarding_permission.sql");
String lifecycle = Files.readString(DIR.resolve("V062__add_v12_user_account_lifecycle.sql"));
assertAll(
() -> assertTrue(lifecycle.contains("profile_revision")),
() -> assertTrue(lifecycle.contains("published_profile_revision")),
() -> assertTrue(lifecycle.contains("user_credentials")),
() -> assertTrue(lifecycle.contains("must_change_password")),
() -> assertFalse(lifecycle.toLowerCase().contains("plaintext_password"))
);
String imports = Files.readString(DIR.resolve("V063__add_v12_import_batches.sql"));
assertAll(
() -> assertTrue(imports.contains("user_import_batches")),
() -> assertTrue(imports.contains("user_import_rows")),
() -> assertTrue(imports.contains("idempotency_key"))
);
String consent = Files.readString(DIR.resolve("V064__add_v12_consent_and_service_state.sql"));
assertAll(
() -> assertTrue(consent.contains("user_consent_states")),
() -> assertTrue(consent.contains("user_service_states")),
() -> assertTrue(consent.contains("service_active"))
);
}
@Test
void lifecycleMigrationsDoNotDropExistingObjects() throws Exception {
List<String> names = List.of(
"V062__add_v12_user_account_lifecycle.sql",
"V063__add_v12_import_batches.sql",
"V064__add_v12_consent_and_service_state.sql",
"V065__add_v12_registration_applications.sql",
"V066__grant_user_onboarding_permission.sql");
for (String name : names) {
String sql = Files.readString(DIR.resolve(name)).toUpperCase();
assertFalse(sql.contains("DROP TABLE"), name);
assertFalse(sql.contains("DROP COLUMN"), name);
}
}
@Test
void matchmakerCrossDomainPermissionsAreRevoked() throws Exception {
String sql = Files.readString(DIR.resolve("V070__remove_matchmaker_cross_domain_permissions.sql"));
assertAll(
() -> assertTrue(sql.contains("r.code IN ('matchmaker', 'matchmaker_leader')")),
() -> assertTrue(sql.contains("p.code LIKE 'counseling.%'")),
() -> assertTrue(sql.contains("p.code LIKE 'service_appointment.%'")),
() -> assertTrue(sql.contains("'activity.view'")),
() -> assertTrue(sql.contains("'activity.checkin'")),
() -> assertTrue(sql.contains("'activity.review_registration'")),
() -> assertTrue(sql.contains("'analytics.view'"))
);
}
@Test
void matchmakerReviewPermissionsAreRevoked() throws Exception {
String sql = Files.readString(DIR.resolve("V071__remove_matchmaker_review_permissions.sql"));
assertAll(
() -> assertTrue(sql.contains("r.code IN ('matchmaker', 'matchmaker_leader')")),
() -> assertTrue(sql.contains("'review.view'")),
() -> assertTrue(sql.contains("'review.process'")),
() -> assertTrue(sql.contains("'local_eligibility.review'"))
);
}
@Test
void matchmakerWorkbenchPermissionsAreRestoredWithoutSensitiveAccess() throws Exception {
Path migration = DIR.resolve("V074__restore_matchmaker_workbench_permissions.sql");
assertTrue(Files.exists(migration));
String sql = Files.readString(migration);
assertAll(
() -> assertTrue(sql.contains("r.code IN ('matchmaker', 'matchmaker_leader')")),
() -> assertTrue(sql.contains("'workbench.view'")),
() -> assertTrue(sql.contains("'review.process'")),
() -> assertTrue(sql.contains("'local_eligibility.review'")),
() -> assertTrue(sql.contains("'counseling.service.assign'")),
() -> assertTrue(sql.contains("'service_appointment.confirm'")),
() -> assertTrue(sql.contains("'service_appointment.complete'")),
() -> assertFalse(sql.contains("'local_eligibility.evidence.view'")),
() -> assertFalse(sql.contains("'counseling.note.view'")),
() -> assertFalse(sql.contains("'matchmaker.assign'")),
() -> assertTrue(sql.contains("ON CONFLICT (role_id, permission_id) DO NOTHING"))
);
}
private static void assertMigration(String name) {
assertTrue(Files.exists(DIR.resolve(name)), "缺少迁移: " + name);
}
}
@@ -0,0 +1,19 @@
# Phase V1.2 Final Fix Completion Report
## 完成内容
- 新增 `V074__restore_matchmaker_workbench_permissions.sql`,幂等恢复 `matchmaker`、`matchmaker_leader` 的工作台六卡片及审核、分配、确认、完成操作权限。
- 未授予资格原始佐证、咨询备注查看、`matchmaker.assign` 等敏感或代用户决策权限;未修改 V070/V071。
- 将迁移基线更新为 V074,并增加 V074 权限契约测试。
- workbench 请求失败或缺少数据时,六项 dashboard 卡片固定返回 `status=error`、`available=false`、`value=0`、空路由,不再使用旧 analytics/service/matchmaker/review 接口回退;workbench 成功返回真实 0 仍保留。
## 验证
- `tests/admin-dashboard-behavior.cjs`
- Maven 定向迁移契约测试:PASS(`MigrationValidationTest,V12MigrationContractTest`)
- `pnpm type-check`:PASS
- `node tests/admin-dashboard-behavior.cjs`:PASS
## 限制
本次未执行真实生产数据库迁移、真实角色登录或生产部署验收。
+10 -4
View File
@@ -23,14 +23,20 @@ const ts = require('../admin-web/node_modules/typescript');
}); });
const perms = ['analytics.view', 'activity.view', 'matchmaker.view', 'matchmaker.assign', 'review.view']; const perms = ['analytics.view', 'activity.view', 'matchmaker.view', 'matchmaker.assign', 'review.view'];
let model = await exports.loadDashboard(perms); let model = await exports.loadDashboard(perms);
assert.equal(model.metrics.find(m => m.key === 'pending_reviews').value, 3); const failedWorkbenchMetrics = ['pending_reviews', 'matchmaker_todos', 'counseling_todos', 'station_appointments', 'effective_matches', 'activity_registrations'];
assert.equal(model.metrics.find(m => m.key === 'matchmaker_todos').value, 1); for (const key of failedWorkbenchMetrics) {
const metric = model.metrics.find(m => m.key === key);
assert.equal(metric.value, 0, `${key} workbench 失败时不得使用旧接口 fallback`);
assert.equal(metric.available, false);
assert.equal(metric.status, 'error');
assert.equal(metric.route, '');
}
assert.equal(model.followups[0].content, 'Demo验收跟进'); assert.equal(model.followups[0].content, 'Demo验收跟进');
assert.equal(exports.summarizeMatchmakerServices(responses['/operator/matchmaker/management/services']).total, 1); assert.equal(exports.summarizeMatchmakerServices(responses['/operator/matchmaker/management/services']).total, 1);
assert.equal(model.metrics.find(m => m.key === 'matchmaker_todos').route, '/matchmaker'); assert.equal(model.metrics.find(m => m.key === 'matchmaker_todos').route, '');
pending = 1; pending = 1;
model = await exports.loadDashboard(perms); model = await exports.loadDashboard(perms);
assert.equal(model.metrics.find(m => m.key === 'pending_reviews').value, 2); assert.equal(model.metrics.find(m => m.key === 'pending_reviews').value, 0);
workbench = { metrics: { workbench = { metrics: {
pendingReviews: { value: 7, available: false }, pendingReviews: { value: 7, available: false },
matchmakerTodos: { value: 0, available: true }, matchmakerTodos: { value: 0, available: true },