feat(iit): QC deep fix + V3.1 architecture plan + project member management

QC System Deep Fix:
- HardRuleEngine: add null tolerance + field availability pre-check (skipped status)
- SkillRunner: baseline data merge for follow-up events + field availability check
- QcReportService: record-level pass rate calculation + accurate LLM XML report
- iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary
- seed-iit-qc-rules: null/empty string tolerance + applicableEvents config

V3.1 Architecture Design (docs only, no code changes):
- QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions
- Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines)
- Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions
- CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts

Project Member Management:
- Cross-tenant member search and assignment (remove tenant restriction)
- IIT project detail page enhancement with tabbed layout (KB + members)
- IitProjectContext for business-side project selection
- System-KB route access control adjustment for project operators

Frontend:
- AdminLayout sidebar menu restructure
- IitLayout with project context provider
- IitMemberManagePage new component
- Business-side pages adapt to project context

Prisma:
- 2 new migrations (user-project RBAC + is_demo flag)
- Schema updates for project member management

Made-with: Cursor
This commit is contained in:
2026-03-01 15:27:05 +08:00
parent c3f7d54fdf
commit 0b29fe88b5
61 changed files with 6877 additions and 524 deletions

View File

@@ -0,0 +1,267 @@
/**
* Phase 2 + Phase 3 联合端到端测试
*
* 测试内容:
* 1. 认证中间件IIT admin 路由需要 Token
* 2. RBAC 角色权限(不同角色看到不同内容)
* 3. /my-projects API用户-项目关联)
* 4. 租户过滤(项目按 tenantId 隔离)
* 5. 租户选项 API
* 6. 项目创建带 tenantId
* 7. IIT_OPERATOR 角色枚举
* 8. UserRole / TenantOption 类型完整性
*
* 运行方式: npx tsx tests/e2e-phase2-phase3-test.ts
*/
const AUTH_BASE = 'http://localhost:3001/api/v1/auth';
const ADMIN_IIT_BASE = 'http://localhost:3001/api/v1/admin/iit-projects';
const IIT_BASE = 'http://localhost:3001/api/v1/iit';
let passCount = 0;
let failCount = 0;
const results: { name: string; ok: boolean; detail?: string }[] = [];
// ========== Helpers ==========
async function rawFetch(url: string, opts?: RequestInit) {
const res = await fetch(url, opts);
const json = await res.json().catch(() => null);
return { status: res.status, data: json };
}
async function authApi(method: string, url: string, token: string, body?: any) {
const opts: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
};
if (body) opts.body = JSON.stringify(body);
return rawFetch(url, opts);
}
async function login(phone: string, password: string = '123456'): Promise<string | null> {
const { status, data } = await rawFetch(`${AUTH_BASE}/login/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, password }),
});
if (status === 200 && data?.data?.tokens?.accessToken) {
return data.data.tokens.accessToken;
}
return null;
}
function assert(name: string, condition: boolean, detail?: string) {
if (condition) {
passCount++;
results.push({ name, ok: true });
console.log(`${name}`);
} else {
failCount++;
results.push({ name, ok: false, detail });
console.log(`${name}${detail ? `${detail}` : ''}`);
}
}
// ========== Test Suites ==========
async function testAuthMiddleware() {
console.log('\n🔒 [1/8] IIT Admin 路由认证中间件');
// 无 Token 访问应返回 401
const { status: noTokenStatus } = await rawFetch(`${ADMIN_IIT_BASE}`, { method: 'GET' });
assert('无 Token 访问 GET /iit-projects → 401', noTokenStatus === 401, `status=${noTokenStatus}`);
const { status: noTokenStatus2 } = await rawFetch(`${ADMIN_IIT_BASE}/tenant-options`, { method: 'GET' });
assert('无 Token 访问 GET /tenant-options → 401', noTokenStatus2 === 401, `status=${noTokenStatus2}`);
}
async function testRoleForbidden() {
console.log('\n🚫 [2/8] RBAC: 普通 USER 不能访问 IIT Admin');
const userToken = await login('13800138003'); // 王医生, role=USER
assert('普通用户登录成功', userToken !== null);
if (userToken) {
const { status } = await authApi('GET', ADMIN_IIT_BASE, userToken);
assert('USER 角色访问 IIT Admin → 403', status === 403, `status=${status}`);
}
}
async function testSuperAdminAccess() {
console.log('\n👑 [3/8] SUPER_ADMIN 全权限访问');
const adminToken = await login('13800000001'); // 超级管理员
assert('SUPER_ADMIN 登录成功', adminToken !== null);
if (adminToken) {
const { status, data } = await authApi('GET', ADMIN_IIT_BASE, adminToken);
assert('SUPER_ADMIN 访问项目列表 → 200', status === 200, `status=${status}`);
assert('返回项目数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
// 项目带 tenantName
const projects = data?.data || [];
if (projects.length > 0) {
assert('项目含 tenantName 字段', projects[0].tenantName !== undefined,
`keys=${Object.keys(projects[0]).join(',')}`);
assert('项目含 tenantId 字段', projects[0].tenantId !== undefined);
}
}
return adminToken;
}
async function testTenantOptions(adminToken: string) {
console.log('\n🏢 [4/8] 租户选项 API');
const { status, data } = await authApi('GET', `${ADMIN_IIT_BASE}/tenant-options`, adminToken);
assert('GET /tenant-options → 200', status === 200, `status=${status}`);
assert('返回租户数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
const tenants = data?.data || [];
assert('租户数量 > 0', tenants.length > 0, `count=${tenants.length}`);
if (tenants.length > 0) {
assert('租户含 id/name/code/type',
tenants[0].id && tenants[0].name && tenants[0].code && tenants[0].type,
`keys=${Object.keys(tenants[0]).join(',')}`);
}
return tenants;
}
async function testTenantFiltering(adminToken: string) {
console.log('\n🔍 [5/8] 租户过滤');
// 壹证循科技 的 tenantId
const yizhengxunId = 'eb4e93b7-0210-4bf5-b853-cbea49cdadf8';
// 不带租户过滤 → 返回所有项目
const { data: allData } = await authApi('GET', ADMIN_IIT_BASE, adminToken);
const allCount = allData?.data?.length || 0;
assert('无过滤返回所有项目', allCount > 0, `count=${allCount}`);
// 带租户过滤 → 只返回该租户项目
const { data: filteredData } = await authApi('GET',
`${ADMIN_IIT_BASE}?tenantId=${yizhengxunId}`, adminToken);
const filteredProjects = filteredData?.data || [];
assert('按租户过滤返回结果', filteredProjects.length > 0, `count=${filteredProjects.length}`);
assert('过滤结果中所有项目 tenantId 一致',
filteredProjects.every((p: any) => p.tenantId === yizhengxunId),
`tenantIds=${filteredProjects.map((p: any) => p.tenantId).join(',')}`);
// 不存在的租户 → 返回空数组
const { data: emptyData } = await authApi('GET',
`${ADMIN_IIT_BASE}?tenantId=non-existent-tenant`, adminToken);
assert('不存在的租户返回空数组', (emptyData?.data?.length || 0) === 0);
}
async function testPharmaAdminIsolation() {
console.log('\n🏭 [6/8] PHARMA_ADMIN 租户隔离');
const pharmaToken = await login('13800000006'); // 药企管理员, tenant=takeda
assert('PHARMA_ADMIN 登录成功', pharmaToken !== null);
if (pharmaToken) {
const { status, data } = await authApi('GET', ADMIN_IIT_BASE, pharmaToken);
assert('PHARMA_ADMIN 可以访问项目列表', status === 200, `status=${status}`);
const projects = data?.data || [];
// PHARMA_ADMIN 的 tenantId 是 tenant-takeda而测试项目属于 壹证循科技
// 所以不应该看到测试项目
const takedaTenantId = 'tenant-takeda';
const allMatchTenant = projects.every((p: any) => p.tenantId === takedaTenantId);
assert('PHARMA_ADMIN 只能看到自己租户的项目(或空)',
projects.length === 0 || allMatchTenant,
`count=${projects.length}, tenantIds=${projects.map((p: any) => p.tenantId).join(',')}`);
}
}
async function testMyProjectsApi() {
console.log('\n👤 [7/8] /my-projects API用户-项目关联)');
// 无 Token → 401
const { status: noTokenStatus } = await rawFetch(`${IIT_BASE}/my-projects`, { method: 'GET' });
assert('无 Token 访问 /my-projects → 401', noTokenStatus === 401, `status=${noTokenStatus}`);
// 登录用户(有 IitUserMapping 关联)
const adminToken = await login('13800000001');
if (adminToken) {
const { status, data } = await authApi('GET', `${IIT_BASE}/my-projects`, adminToken);
assert('/my-projects 返回 200', status === 200, `status=${status}`);
assert('返回 data 数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
// 当前超级管理员可能没有 IitUserMapping所以可能是空数组
// 但 API 不应报错
const projects = data?.data || [];
assert('/my-projects 不报错(即使无关联项目)', true, `count=${projects.length}`);
if (projects.length > 0) {
assert('项目含 myRole 字段', projects[0].myRole !== undefined,
`keys=${Object.keys(projects[0]).join(',')}`);
}
}
}
async function testProjectDetailWithTenant(adminToken: string) {
console.log('\n📋 [8/8] 项目详情含租户信息');
const projectId = 'test0102-pd-study';
const { status, data } = await authApi('GET', `${ADMIN_IIT_BASE}/${projectId}`, adminToken);
assert('GET 项目详情 → 200', status === 200, `status=${status}`);
const project = data?.data;
assert('项目详情含 tenantId', project?.tenantId !== undefined && project?.tenantId !== null,
`tenantId=${project?.tenantId}`);
assert('项目详情含 tenant 对象', project?.tenant !== undefined,
`tenant=${JSON.stringify(project?.tenant)}`);
if (project?.tenant) {
assert('tenant 含 name', !!project.tenant.name, `name=${project.tenant.name}`);
}
}
// ========== Main ==========
async function main() {
console.log('🚀 Phase 2 + Phase 3 联合端到端测试');
console.log('='.repeat(60));
try {
// Phase 2 Tests
await testAuthMiddleware();
await testRoleForbidden();
const adminToken = await testSuperAdminAccess();
await testMyProjectsApi();
// Phase 3 Tests
if (adminToken) {
const tenants = await testTenantOptions(adminToken);
await testTenantFiltering(adminToken);
await testProjectDetailWithTenant(adminToken);
}
await testPharmaAdminIsolation();
} catch (err) {
console.error('\n💥 测试执行异常:', err);
failCount++;
}
// Summary
console.log('\n' + '='.repeat(60));
console.log(`📊 测试结果: ${passCount} 通过, ${failCount} 失败, 共 ${passCount + failCount}`);
if (failCount > 0) {
console.log('\n❌ 失败项:');
results.filter(r => !r.ok).forEach(r => {
console.log(`${r.name}${r.detail ? `${r.detail}` : ''}`);
});
}
console.log('\n' + (failCount === 0 ? '🎉 全部通过!' : '⚠️ 有失败项,请检查'));
process.exit(failCount > 0 ? 1 : 0);
}
main();