fix: restore matchmaker workbench permissions
This commit is contained in:
@@ -106,12 +106,11 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
const workbenchValue = (key: string) => numberOf(recordOf(workbenchMetrics, key), 'value')
|
||||
const hasWorkbenchMetric = (key: string) => Object.prototype.hasOwnProperty.call(workbenchMetrics, key)
|
||||
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 (!permissions.includes(permission)) return 'forbidden'
|
||||
return fallbackAvailable ? 'ready' : 'error'
|
||||
return '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 core = fulfilledData<JsonRecord>(coreResult)
|
||||
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||
@@ -121,9 +120,6 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
|
||||
const managedStats = summarizeMatchmakerServices(managedServices || [])
|
||||
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
|
||||
const matchmakerPending = canManageMatchmaker
|
||||
? managedStats.pending
|
||||
: numberOf(matchmaker || undefined, 'pendingCount') + numberOf(matchmaker || undefined, 'inProgressCount')
|
||||
const matchmakerCompleted = canManageMatchmaker
|
||||
? managedStats.completed
|
||||
: numberOf(matchmaker || undefined, 'completedCount')
|
||||
@@ -134,38 +130,37 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
const analyticsAvailable = Boolean(core || service)
|
||||
|
||||
const counseling = recordOf(service || undefined, 'counseling')
|
||||
const appointments = recordOf(service || undefined, 'serviceAppointments')
|
||||
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
||||
const registrationSources = recordOf(service || undefined, 'registrationSources')
|
||||
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
|
||||
|
||||
const metrics: DashboardMetric[] = [
|
||||
{
|
||||
key: 'pending_reviews', label: '待审核资料', value: hasWorkbenchMetric('pendingReviews') ? workbenchValue('pendingReviews') : numberOf(reviews || undefined, 'pending') + numberOf(reviews || undefined, 'processing'),
|
||||
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'),
|
||||
key: 'pending_reviews', label: '待审核资料', value: workbenchValue('pendingReviews'),
|
||||
trendText: workbenchAvailable('pendingReviews') ? '当前数据范围内待审核资料' : '暂无授权数据', tone: 'rose', available: metricAvailable('pendingReviews'), status: metricStatus('pendingReviews'), route: metricRoute(metricStatus('pendingReviews'), '/review'),
|
||||
},
|
||||
{
|
||||
key: 'matchmaker_todos', label: '红娘服务待办',
|
||||
value: hasWorkbenchMetric('matchmakerTodos') ? workbenchValue('matchmakerTodos') : matchmakerPending,
|
||||
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'),
|
||||
value: workbenchValue('matchmakerTodos'),
|
||||
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: '咨询服务待办',
|
||||
value: hasWorkbenchMetric('counselingTodos') ? workbenchValue('counselingTodos') : numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'),
|
||||
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'),
|
||||
value: workbenchValue('counselingTodos'),
|
||||
trendText: workbenchAvailable('counselingTodos') ? `${workbench?.scopeLabel || '当前范围'}咨询待办` : '暂无授权数据', tone: 'blue', available: metricAvailable('counselingTodos'), status: metricStatus('counselingTodos'), route: metricRoute(metricStatus('counselingTodos'), '/counseling'),
|
||||
},
|
||||
{
|
||||
key: 'station_appointments', label: '服务站预约',
|
||||
value: hasWorkbenchMetric('serviceStationAppointments') ? workbenchValue('serviceStationAppointments') : numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'),
|
||||
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'),
|
||||
value: workbenchValue('serviceStationAppointments'),
|
||||
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'),
|
||||
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'),
|
||||
key: 'effective_matches', label: '有效匹配', value: workbenchValue('effectiveMatches'),
|
||||
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'),
|
||||
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'),
|
||||
key: 'activity_registrations', label: '活动报名', value: workbenchValue('activityRegistrations'),
|
||||
trendText: workbenchAvailable('activityRegistrations') ? `${workbench?.scopeLabel || '当前范围'}活动报名` : '暂无授权数据', tone: 'red', available: metricAvailable('activityRegistrations'), status: metricStatus('activityRegistrations'), route: metricRoute(metricStatus('activityRegistrations'), '/activities/interests'),
|
||||
},
|
||||
// 保留 forbidden 卡片以明确展示“无权限”;其数值只在 available 时渲染,route 由 metricRoute 置空。
|
||||
]
|
||||
|
||||
+15
@@ -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;
|
||||
+2
-2
@@ -45,8 +45,8 @@ class MigrationValidationTest {
|
||||
// V067 工作台聚合权限与红娘角色边界;V068/V069 红娘工作台权限;
|
||||
// V070 收回红娘的咨询、活动、预约、统计权限;
|
||||
// V071 收回红娘的资料审核、本地资格审核权限。
|
||||
// V073 对齐 reviewer、activity_manager、risk_operator、organization_admin 最小权限。
|
||||
private static final int LATEST_MIGRATION = 73;
|
||||
// V073 对齐其他运营角色;V074 恢复红娘工作台六卡片及操作权限。
|
||||
private static final int LATEST_MIGRATION = 74;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+110
@@ -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
|
||||
|
||||
## 限制
|
||||
|
||||
本次未执行真实生产数据库迁移、真实角色登录或生产部署验收。
|
||||
@@ -23,14 +23,20 @@ const ts = require('../admin-web/node_modules/typescript');
|
||||
});
|
||||
const perms = ['analytics.view', 'activity.view', 'matchmaker.view', 'matchmaker.assign', 'review.view'];
|
||||
let model = await exports.loadDashboard(perms);
|
||||
assert.equal(model.metrics.find(m => m.key === 'pending_reviews').value, 3);
|
||||
assert.equal(model.metrics.find(m => m.key === 'matchmaker_todos').value, 1);
|
||||
const failedWorkbenchMetrics = ['pending_reviews', 'matchmaker_todos', 'counseling_todos', 'station_appointments', 'effective_matches', 'activity_registrations'];
|
||||
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(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;
|
||||
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: {
|
||||
pendingReviews: { value: 7, available: false },
|
||||
matchmakerTodos: { value: 0, available: true },
|
||||
|
||||
Reference in New Issue
Block a user