修正权限

This commit is contained in:
2026-07-20 21:27:48 +08:00 Unverified
parent c31602593a
commit 76a1b66f53
12 changed files with 584 additions and 12 deletions
+8
View File
@@ -121,7 +121,9 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'ex
启动应用时设置连接信息,应用会自动创建以下关系表和系统基础配置,但不会自动写入测试业务数据: 启动应用时设置连接信息,应用会自动创建以下关系表和系统基础配置,但不会自动写入测试业务数据:
- `schools``school_classes``users``candidate_profiles` - `schools``school_classes``users``candidate_profiles`
- `school_student_partitions`(学校学生专属表登记)
- `exams``exam_subjects` - `exams``exam_subjects`
- `exam_data_partitions`(考试专属表登记)
- `registrations``registration_subjects` - `registrations``registration_subjects`
- `admission_number_rules``exam_arrangement_plans``admit_cards``admit_card_subjects` - `admission_number_rules``exam_arrangement_plans``admit_cards``admit_card_subjects`
- `results``notices``audit_logs` - `results``notices``audit_logs`
@@ -133,6 +135,12 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'ex
所有关联均有外键约束,账号、证件号、考试代码、报名关系、准考证号和单科成绩均有对应唯一约束。 所有关联均有外键约束,账号、证件号、考试代码、报名关系、准考证号和单科成绩均有对应唯一约束。
系统采用“总表索引 + 独立物理分表”存储:创建每场考试时,会立即创建该场考试专用的
`exam_<分区键>_candidates``exam_<分区键>_admissions``exam_<分区键>_results`
`exam_<分区键>_centers` 四张表;创建每所学校时,会创建 `school_<分区键>_students`
学生专属表。分区键由业务 ID 的 SHA-256 摘要生成,不直接拼接用户输入。报名、缴费、准考证编排、
成绩和学生资料发生变化后,专属表会自动同步;总表继续承担跨考试、跨学校查询和外键完整性约束。
```powershell ```powershell
$env:NODE_ENV = 'production' $env:NODE_ENV = 'production'
$env:DATABASE_CLIENT = 'mysql' $env:DATABASE_CLIENT = 'mysql'
+15
View File
@@ -137,6 +137,13 @@ document.addEventListener('click', async event => {
if (action === 'new-notice') { await openNoticeForm(); return; } if (action === 'new-notice') { await openNoticeForm(); return; }
if (action === 'new-exam') return openExamForm(); if (action === 'new-exam') return openExamForm();
if (action === 'new-admin') return openAdminForm(); if (action === 'new-admin') return openAdminForm();
if (action === 'new-school') return openSchoolForm();
if (action === 'edit-school') return openSchoolForm(state.pageData.schools.find(item => item.id === target.dataset.id));
if (action === 'toggle-school') {
const active = target.dataset.active === 'true';
await api(`/api/admin/schools/${target.dataset.id}`, { method: 'PATCH', body: { active } });
toast(active ? '学校已启用' : '学校已停用', active ? '考生公开入口已恢复显示' : '公开入口已隐藏,班级、管理员和历史数据均已保留'); return renderRoute();
}
if (action === 'new-school-class') return openSchoolClassForm(); if (action === 'new-school-class') return openSchoolClassForm();
if (action === 'edit-school-class') return openSchoolClassForm(state.pageData.classes.find(item => item.id === target.dataset.id)); if (action === 'edit-school-class') return openSchoolClassForm(state.pageData.classes.find(item => item.id === target.dataset.id));
if (action === 'toggle-school-class') { if (action === 'toggle-school-class') {
@@ -428,6 +435,10 @@ document.addEventListener('submit', async event => {
const body = formObject(form); const body = formObject(form);
await api('/api/admin/admins', { method: 'POST', body }); await api('/api/admin/admins', { method: 'POST', body });
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute();
} else if (kind === 'school-form') {
const body = formObject(form); body.active = form.active.checked;
await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body });
closeModal(); await refreshPublic(); toast(body.id ? '学校档案已更新' : '学校已创建', `${body.name} · ${body.code.toUpperCase()}`); renderRoute();
} else if (kind === 'school-class') { } else if (kind === 'school-class') {
const body = formObject(form); body.active = form.active.checked; const body = formObject(form); body.active = form.active.checked;
await api(body.id ? `/api/admin/classes/${body.id}` : '/api/admin/classes', { method: body.id ? 'PATCH' : 'POST', body }); await api(body.id ? `/api/admin/classes/${body.id}` : '/api/admin/classes', { method: body.id ? 'PATCH' : 'POST', body });
@@ -546,6 +557,10 @@ function openAdminForm() {
setModal(`<div class="modal-head"><div><span>ADMIN SCOPE</span><h2>添加分级管理员</h2><p>同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="admin-form"><div class="field-row"><label><span>姓名 *</span><input name="displayName" required></label><label><span>管理员层级 *</span><select name="adminLevel" data-action="admin-level"><option value="school">校级管理员</option><option value="class">班级管理员</option><option value="super">超级管理员</option></select></label></div><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label data-admin-school><span>绑定学校</span><select name="schoolId" data-action="school-select"><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label class="hidden" data-admin-class><span>绑定班级</span><select name="classId"><option value="">请先选择学校</option>${classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">创建管理员</button></div></form>`); setModal(`<div class="modal-head"><div><span>ADMIN SCOPE</span><h2>添加分级管理员</h2><p>同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="admin-form"><div class="field-row"><label><span>姓名 *</span><input name="displayName" required></label><label><span>管理员层级 *</span><select name="adminLevel" data-action="admin-level"><option value="school">校级管理员</option><option value="class">班级管理员</option><option value="super">超级管理员</option></select></label></div><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label data-admin-school><span>绑定学校</span><select name="schoolId" data-action="school-select"><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label class="hidden" data-admin-class><span>绑定班级</span><select name="classId"><option value="">请先选择学校</option>${classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">创建管理员</button></div></form>`);
} }
function openSchoolForm(school = null) {
setModal(`<div class="modal-head"><div><span>SCHOOL DIRECTORY</span><h2>${school ? '编辑学校档案' : '创建学校'}</h2><p>学校代码用于管理员范围、报名号规则和数据识别,建议使用稳定且唯一的简称。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="school-form">${school ? `<input type="hidden" name="id" value="${h(school.id)}">` : ''}<div class="field-row"><label><span>学校名称 *</span><input name="name" required maxlength="100" value="${h(school?.name || '')}" placeholder="例如 海州市第四中学"></label><label><span>学校代码 *</span><input name="code" required maxlength="40" pattern="[A-Za-z0-9_-]+" value="${h(school?.code || '')}" placeholder="例如 HZ04"></label></div><label><span>学校地址</span><input name="address" maxlength="200" value="${h(school?.address || '')}" placeholder="省、市、区县及详细地址"></label><label class="agreement"><input type="checkbox" name="active" ${school?.active === false ? '' : 'checked'}><span>启用该学校,可在公开入口选择并继续绑定管理员、班级和考点</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${school ? '保存学校' : '创建学校'}</button></div></form>`);
}
function openSchoolClassForm(schoolClass = null) { function openSchoolClassForm(schoolClass = null) {
setModal(`<div class="modal-head"><div><span>SCHOOL CLASS</span><h2>${schoolClass ? '编辑本校班级' : '新增本校班级'}</h2><p>班级是考生资料、报名和成绩权限的最小范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="school-class">${schoolClass ? `<input type="hidden" name="id" value="${h(schoolClass.id)}">` : ''}<div class="field-row"><label><span>年级 *</span><input name="grade" required value="${h(schoolClass?.grade || '')}" placeholder="例如 高三"></label><label><span>班级名称 *</span><input name="name" required value="${h(schoolClass?.name || '')}" placeholder="例如 高三(3)班"></label></div><label class="agreement"><input type="checkbox" name="active" ${schoolClass?.active === false ? '' : 'checked'}><span>启用该班级,可继续分配考生和班级管理员</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${schoolClass ? '保存班级' : '创建班级'}</button></div></form>`); setModal(`<div class="modal-head"><div><span>SCHOOL CLASS</span><h2>${schoolClass ? '编辑本校班级' : '新增本校班级'}</h2><p>班级是考生资料、报名和成绩权限的最小范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="school-class">${schoolClass ? `<input type="hidden" name="id" value="${h(schoolClass.id)}">` : ''}<div class="field-row"><label><span>年级 *</span><input name="grade" required value="${h(schoolClass?.grade || '')}" placeholder="例如 高三"></label><label><span>班级名称 *</span><input name="name" required value="${h(schoolClass?.name || '')}" placeholder="例如 高三(3)班"></label></div><label class="agreement"><input type="checkbox" name="active" ${schoolClass?.active === false ? '' : 'checked'}><span>启用该班级,可继续分配考生和班级管理员</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${schoolClass ? '保存班级' : '创建班级'}</button></div></form>`);
} }
+15 -1
View File
@@ -9,10 +9,12 @@ export const relationalTables = [
'organization', 'organization',
'schools', 'schools',
'school_classes', 'school_classes',
'school_student_partitions',
'users', 'users',
'candidate_profiles', 'candidate_profiles',
'notices', 'notices',
'exams', 'exams',
'exam_data_partitions',
'exam_subjects', 'exam_subjects',
'registrations', 'registrations',
'registration_subjects', 'registration_subjects',
@@ -57,7 +59,7 @@ export function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value; const nullable = value => value == null || value === '' ? null : value;
add( add(
'UPDATE schema_metadata SET schema_version = 15, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', 'UPDATE schema_metadata SET schema_version = 16, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString() state.meta?.createdAt || new Date().toISOString()
); );
@@ -1169,6 +1171,18 @@ function createRepository({ client, location, read, transaction, close }) {
auditOperation(log) auditOperation(log)
]); ]);
}, },
async saveSchool(school, isNew, log) {
const change = isNew
? operation(
'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)',
school.id, school.name, school.code, optional(school.address), school.active ? 1 : 0
)
: operation(
'UPDATE schools SET name = ?, code = ?, address = ?, active = ? WHERE id = ?',
school.name, school.code, optional(school.address), school.active ? 1 : 0, school.id
);
await transaction([change, auditOperation(log)]);
},
async saveSchoolClass(schoolClass, isNew, log) { async saveSchoolClass(schoolClass, isNew, log) {
const change = isNew const change = isNew
? operation( ? operation(
+15 -2
View File
@@ -101,8 +101,20 @@ async function prepareMysql() {
} }
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1'); const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
if (Number(metadataRows[0]?.schema_version) !== 15) { if (Number(metadataRows[0]?.schema_version) !== 16) {
throw new Error(`MySQL 数据库结构版本不是 v15(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`); throw new Error(`MySQL 数据库结构版本不是 v16(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
}
const [examPartitionRows] = await connection.query(
'SELECT candidates_table, admissions_table, results_table, centers_table FROM exam_data_partitions'
);
const [schoolPartitionRows] = await connection.query('SELECT students_table FROM school_student_partitions');
const dynamicPartitionTables = [
...examPartitionRows.flatMap(row => [row.candidates_table, row.admissions_table, row.results_table, row.centers_table]),
...schoolPartitionRows.map(row => row.students_table)
];
if (dynamicPartitionTables.some(table => !/^[a-z][a-z0-9_]{0,63}$/.test(table))) {
throw new Error('分表登记中存在非法表名,拒绝替换测试数据');
} }
const nonEmpty = []; const nonEmpty = [];
@@ -148,6 +160,7 @@ async function prepareMysql() {
} finally { } finally {
await connection.query('SET FOREIGN_KEY_CHECKS = 1'); await connection.query('SET FOREIGN_KEY_CHECKS = 1');
} }
for (const table of dynamicPartitionTables) await connection.query(`DROP TABLE IF EXISTS \`${table}\``);
return { location: `MySQL database ${databaseName}`, initialized: true }; return { location: `MySQL database ${databaseName}`, initialized: true };
} finally { } finally {
connection?.release(); connection?.release();
+8 -1
View File
@@ -32,6 +32,7 @@ export function createAdminViews(context) {
app.classList.add('admin-readable'); app.classList.add('admin-readable');
const meta = { const meta = {
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
schools: ['学校管理', '创建和维护学校档案,控制学校在考生公开入口中的可选状态。'],
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', state.user.adminLevel === 'class' ? '考生线下缴费后,由班级负责人确认并记录办理时间。' : '查看并导出当前管理范围内的考试缴费名单。'], payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', state.user.adminLevel === 'class' ? '考生线下缴费后,由班级负责人确认并记录办理时间。' : '查看并导出当前管理范围内的考试缴费名单。'],
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'], notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
@@ -55,7 +56,7 @@ export function createAdminViews(context) {
const content = { const content = {
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data), dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data),
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data), exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data), schools: () => adminSchools(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), security: () => accountSecurity() 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), security: () => accountSecurity()
}[page](); }[page]();
app.innerHTML = portalShell('admin', page, content, ...meta[page]); app.innerHTML = portalShell('admin', page, content, ...meta[page]);
@@ -72,6 +73,12 @@ export function createAdminViews(context) {
return `<div class="excel-toolbar"><span><strong>${h(label)} Excel</strong><small>使用系统模板可获得逐行校验</small></span><div>${template ? `<button class="row-action" data-action="excel-download" data-resource="${h(resource)}" data-template="1">下载模板</button>` : ''}<button class="row-action" data-action="excel-download" data-resource="${h(resource)}">导出当前数据</button>${importable ? `<button class="row-action primary" data-action="excel-import" data-resource="${h(resource)}">导入 Excel</button><input type="file" accept=".xlsx" hidden data-excel-file="${h(resource)}">` : ''}</div></div>`; return `<div class="excel-toolbar"><span><strong>${h(label)} Excel</strong><small>使用系统模板可获得逐行校验</small></span><div>${template ? `<button class="row-action" data-action="excel-download" data-resource="${h(resource)}" data-template="1">下载模板</button>` : ''}<button class="row-action" data-action="excel-download" data-resource="${h(resource)}">导出当前数据</button>${importable ? `<button class="row-action primary" data-action="excel-import" data-resource="${h(resource)}">导入 Excel</button><input type="file" accept=".xlsx" hidden data-excel-file="${h(resource)}">` : ''}</div></div>`;
} }
function adminSchools(data) {
const schools = data.schools || [];
const activeCount = schools.filter(item => item.active).length;
return `<section class="center-summary"><div><span>学校总数</span><strong>${schools.length}</strong></div><div><span>启用学校</span><strong>${activeCount}</strong></div><div><span>在册考生</span><strong>${schools.reduce((sum, item) => sum + item.candidateCount, 0)}</strong></div></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolTable" placeholder="搜索学校名称、代码或地址"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolTable" data-status="active">启用</button><button data-action="status-filter" data-target="schoolTable" data-status="inactive">停用</button></div></div><div class="table-scroll"><table id="schoolTable"><thead><tr><th>学校</th><th>学校代码</th><th>地址</th><th>班级</th><th>管理员</th><th>考生</th><th>考点</th><th>状态</th><th>操作</th></tr></thead><tbody>${schools.map(item => `<tr data-status="${item.active ? 'active' : 'inactive'}"><td><strong>${h(item.name)}</strong><small>${h(item.id)}</small></td><td class="mono"><strong>${h(item.code)}</strong></td><td>${h(item.address || '未填写')}</td><td>${item.classCount}</td><td>${item.adminCount}</td><td>${item.candidateCount}</td><td>${item.centerCount}</td><td>${badge(item.active ? 'approved' : 'closed')}</td><td><div class="candidate-account-actions"><button class="row-action" data-action="edit-school" data-id="${h(item.id)}">编辑</button><button class="row-action" data-action="toggle-school" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用' : '启用'}</button></div></td></tr>`).join('') || '<tr><td colspan="9" class="empty-state">还没有学校,请先创建学校档案。</td></tr>'}</tbody></table></div></section>`;
}
function adminSchoolOrganization(data) { function adminSchoolOrganization(data) {
const classes = data.classes || []; const classes = data.classes || [];
const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0); const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0);
+2 -1
View File
@@ -30,7 +30,7 @@ export function createCandidateViews(context) {
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security]; if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security];
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security]; if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security];
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security]; return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security];
} }
function portalShell(role, page, content, title, description) { function portalShell(role, page, content, title, description) {
@@ -43,6 +43,7 @@ export function createCandidateViews(context) {
function portalHeadingAction(role, page) { function portalHeadingAction(role, page) {
if (role === 'admin' && page === 'notices') return `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`; if (role === 'admin' && page === 'notices') return `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`;
if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`; if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`;
if (role === 'admin' && page === 'schools') return `<button class="solid-button" data-action="new-school">${icons.plus} 创建学校</button>`;
if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`; if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`;
if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`; if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`;
if (role === 'admin' && page === 'organization') return `<button class="solid-button" data-action="new-school-class">${icons.plus} 新增班级</button>`; if (role === 'admin' && page === 'organization') return `<button class="solid-button" data-action="new-school-class">${icons.plus} 新增班级</button>`;
+16 -2
View File
@@ -1,3 +1,5 @@
import { synchronizeMysqlPartitions } from './partition-storage.mjs';
export function createMysqlAdapter(context) { export function createMysqlAdapter(context) {
const { const {
mkdir, mkdir,
@@ -52,7 +54,7 @@ export function createMysqlAdapter(context) {
hasSchemaMetadata = metadataRows.length > 0; hasSchemaMetadata = metadataRows.length > 0;
existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null; existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null;
} }
if (existingAppTables.length && (!hasSchemaMetadata || existingSchemaVersion !== 15)) { if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16].includes(existingSchemaVersion))) {
for (const table of [...mysqlTableNames].reverse()) { for (const table of [...mysqlTableNames].reverse()) {
await pool.query(`DROP TABLE IF EXISTS \`${table}\``); await pool.query(`DROP TABLE IF EXISTS \`${table}\``);
} }
@@ -152,6 +154,10 @@ export function createMysqlAdapter(context) {
if (Number(metadataRows[0]?.schema_version || 1) < 15) { if (Number(metadataRows[0]?.schema_version || 1) < 15) {
throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动'); throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动');
} }
if (Number(metadataRows[0]?.schema_version || 1) < 16) {
await pool.execute('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1');
metadataRows[0].schema_version = 16;
}
if (Number(metadataRows[0]?.app_version || 1) < 2) { if (Number(metadataRows[0]?.app_version || 1) < 2) {
const extension = seed(); const extension = seed();
const connection = await pool.getConnection(); const connection = await pool.getConnection();
@@ -334,7 +340,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction(); await connection.beginTransaction();
const [insert] = await connection.execute(` const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 15, ?, ?, ?) VALUES (1, 16, ?, ?, ?)
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) { if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
@@ -357,12 +363,20 @@ export function createMysqlAdapter(context) {
await pool.query('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)'); await pool.query('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)');
} }
const partitionConnection = await pool.getConnection();
try {
await synchronizeMysqlPartitions(partitionConnection);
} finally {
partitionConnection.release();
}
const transaction = async operations => { const transaction = async operations => {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
for (const item of operations) await connection.execute(item.sql, item.params); for (const item of operations) await connection.execute(item.sql, item.params);
await connection.commit(); await connection.commit();
await synchronizeMysqlPartitions(connection);
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();
throw error; throw error;
+324
View File
@@ -0,0 +1,324 @@
import { createHash } from 'node:crypto';
const identifierPattern = /^[a-z][a-z0-9_]{0,63}$/;
function partitionKey(ownerId) {
return createHash('sha256').update(String(ownerId)).digest('hex').slice(0, 16);
}
function namesForExam(examId) {
const key = partitionKey(examId);
return {
key,
candidates: `exam_${key}_candidates`,
admissions: `exam_${key}_admissions`,
results: `exam_${key}_results`,
centers: `exam_${key}_centers`
};
}
function namesForSchool(schoolId) {
const key = partitionKey(schoolId);
return { key, students: `school_${key}_students` };
}
function quoteSqlite(identifier) {
if (!identifierPattern.test(identifier)) throw new Error(`非法 SQLite 分表名称:${identifier}`);
return `"${identifier}"`;
}
function quoteMysql(identifier) {
if (!identifierPattern.test(identifier)) throw new Error(`非法 MySQL 分表名称:${identifier}`);
return `\`${identifier}\``;
}
function sqliteExamTables(connection, names) {
const candidates = quoteSqlite(names.candidates);
const admissions = quoteSqlite(names.admissions);
const results = quoteSqlite(names.results);
const centers = quoteSqlite(names.centers);
connection.exec(`
CREATE TABLE IF NOT EXISTS ${candidates} (
registration_id TEXT PRIMARY KEY, exam_id TEXT NOT NULL, user_id TEXT NOT NULL,
candidate_number TEXT, candidate_name TEXT NOT NULL, school_id TEXT, class_id TEXT,
registration_status TEXT NOT NULL, payment_status TEXT NOT NULL, registered_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS ${admissions} (
registration_id TEXT NOT NULL, subject_id TEXT NOT NULL, exam_id TEXT NOT NULL,
candidate_number TEXT, admission_number TEXT, test_center TEXT, center_code TEXT,
center_address TEXT, room_id TEXT, room_name TEXT, room_code TEXT, exam_room_code TEXT,
building TEXT, floor TEXT, seat TEXT, generated_at TEXT,
PRIMARY KEY (registration_id, subject_id)
) STRICT;
CREATE TABLE IF NOT EXISTS ${results} (
result_id TEXT PRIMARY KEY, exam_id TEXT NOT NULL, registration_id TEXT NOT NULL,
subject_id TEXT NOT NULL, candidate_number TEXT, score REAL NOT NULL, grade TEXT NOT NULL,
published INTEGER NOT NULL CHECK (published IN (0, 1)), updated_at TEXT, published_at TEXT,
UNIQUE (registration_id, subject_id)
) STRICT;
CREATE TABLE IF NOT EXISTS ${centers} (
center_key TEXT PRIMARY KEY, exam_id TEXT NOT NULL, center_id TEXT, center_code TEXT,
center_name TEXT NOT NULL, center_address TEXT NOT NULL, candidate_count INTEGER NOT NULL,
room_count INTEGER NOT NULL
) STRICT;
`);
}
function sqliteSchoolTable(connection, names) {
const students = quoteSqlite(names.students);
connection.exec(`
CREATE TABLE IF NOT EXISTS ${students} (
user_id TEXT PRIMARY KEY, school_id TEXT NOT NULL, candidate_number TEXT,
candidate_name TEXT NOT NULL, id_number TEXT, gender TEXT, class_id TEXT, grade TEXT,
phone TEXT, email TEXT, profile_status TEXT, active INTEGER NOT NULL CHECK (active IN (0, 1)),
updated_at TEXT
) STRICT;
`);
}
function syncSqliteExam(connection, examId, names) {
const candidates = quoteSqlite(names.candidates);
const admissions = quoteSqlite(names.admissions);
const results = quoteSqlite(names.results);
const centers = quoteSqlite(names.centers);
connection.exec(`DELETE FROM ${candidates}; DELETE FROM ${admissions}; DELETE FROM ${results}; DELETE FROM ${centers};`);
connection.prepare(`
INSERT INTO ${candidates} (
registration_id, exam_id, user_id, candidate_number, candidate_name, school_id, class_id,
registration_status, payment_status, registered_at
)
SELECT registration.id, registration.exam_id, registration.user_id, user.candidate_number,
COALESCE(profile.name, user.display_name), COALESCE(profile.school_id, user.school_id),
COALESCE(profile.class_id, user.class_id), registration.status, registration.payment_status,
registration.created_at
FROM registrations registration
JOIN users user ON user.id = registration.user_id
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${admissions} (
registration_id, subject_id, exam_id, candidate_number, admission_number, test_center,
center_code, center_address, room_id, room_name, room_code, exam_room_code,
building, floor, seat, generated_at
)
SELECT registration.id, selected.subject_id, registration.exam_id, user.candidate_number,
card.card_number, card.test_center, card.center_code, card.center_address,
assignment.room_id, assignment.room, assignment.room_code, assignment.exam_room_code,
assignment.building, assignment.floor, assignment.seat, card.generated_at
FROM registrations registration
JOIN users user ON user.id = registration.user_id
JOIN registration_subjects selected ON selected.registration_id = registration.id
LEFT JOIN admit_cards card ON card.registration_id = registration.id
LEFT JOIN admit_card_subjects assignment
ON assignment.registration_id = registration.id AND assignment.subject_id = selected.subject_id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${results} (
result_id, exam_id, registration_id, subject_id, candidate_number, score, grade,
published, updated_at, published_at
)
SELECT result.id, registration.exam_id, result.registration_id, result.subject_id,
user.candidate_number, result.score, result.grade, result.published,
result.updated_at, result.published_at
FROM results result
JOIN registrations registration ON registration.id = result.registration_id
JOIN users user ON user.id = registration.user_id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${centers} (
center_key, exam_id, center_id, center_code, center_name, center_address,
candidate_count, room_count
)
SELECT COALESCE(card.center_id, 'snapshot:' || card.center_code), registration.exam_id,
card.center_id, card.center_code, MAX(card.test_center), MAX(card.center_address),
COUNT(DISTINCT card.registration_id), COUNT(DISTINCT assignment.room_id)
FROM admit_cards card
JOIN registrations registration ON registration.id = card.registration_id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = card.registration_id
WHERE registration.exam_id = ?
GROUP BY COALESCE(card.center_id, 'snapshot:' || card.center_code), registration.exam_id,
card.center_id, card.center_code
`).run(examId);
}
function syncSqliteSchool(connection, schoolId, names) {
const students = quoteSqlite(names.students);
connection.exec(`DELETE FROM ${students};`);
connection.prepare(`
INSERT INTO ${students} (
user_id, school_id, candidate_number, candidate_name, id_number, gender, class_id,
grade, phone, email, profile_status, active, updated_at
)
SELECT user.id, ?, user.candidate_number, COALESCE(profile.name, user.display_name),
profile.id_number, profile.gender, COALESCE(profile.class_id, user.class_id), profile.grade,
profile.phone, profile.email, profile.status, user.active, profile.updated_at
FROM users user
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?
`).run(schoolId, schoolId);
}
export function synchronizeSqlitePartitions(connection) {
const now = new Date().toISOString();
const exams = connection.prepare('SELECT id FROM exams ORDER BY id').all();
const schools = connection.prepare('SELECT id FROM schools ORDER BY id').all();
for (const { id } of exams) {
const names = namesForExam(id);
sqliteExamTables(connection, names);
connection.prepare(`
INSERT INTO exam_data_partitions (
exam_id, partition_key, candidates_table, admissions_table, results_table, centers_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_id) DO UPDATE SET partition_key = excluded.partition_key,
candidates_table = excluded.candidates_table, admissions_table = excluded.admissions_table,
results_table = excluded.results_table, centers_table = excluded.centers_table,
updated_at = excluded.updated_at
`).run(id, names.key, names.candidates, names.admissions, names.results, names.centers, now, now);
syncSqliteExam(connection, id, names);
}
for (const { id } of schools) {
const names = namesForSchool(id);
sqliteSchoolTable(connection, names);
connection.prepare(`
INSERT INTO school_student_partitions (school_id, partition_key, students_table, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(school_id) DO UPDATE SET partition_key = excluded.partition_key,
students_table = excluded.students_table, updated_at = excluded.updated_at
`).run(id, names.key, names.students, now, now);
syncSqliteSchool(connection, id, names);
}
}
async function mysqlExamTables(connection, names) {
const candidates = quoteMysql(names.candidates);
const admissions = quoteMysql(names.admissions);
const results = quoteMysql(names.results);
const centers = quoteMysql(names.centers);
await connection.query(`CREATE TABLE IF NOT EXISTS ${candidates} (
registration_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL,
candidate_number VARCHAR(120) NULL, candidate_name VARCHAR(120) NOT NULL, school_id VARCHAR(64) NULL,
class_id VARCHAR(64) NULL, registration_status VARCHAR(20) NOT NULL, payment_status VARCHAR(20) NOT NULL,
registered_at VARCHAR(35) NOT NULL, PRIMARY KEY (registration_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${admissions} (
registration_id VARCHAR(64) NOT NULL, subject_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL,
candidate_number VARCHAR(120) NULL, admission_number VARCHAR(120) NULL, test_center VARCHAR(200) NULL,
center_code VARCHAR(60) NULL, center_address VARCHAR(500) NULL, room_id VARCHAR(64) NULL,
room_name VARCHAR(120) NULL, room_code VARCHAR(60) NULL, exam_room_code VARCHAR(120) NULL,
building VARCHAR(120) NULL, floor VARCHAR(60) NULL, seat VARCHAR(60) NULL, generated_at VARCHAR(35) NULL,
PRIMARY KEY (registration_id, subject_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${results} (
result_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, registration_id VARCHAR(64) NOT NULL,
subject_id VARCHAR(64) NOT NULL, candidate_number VARCHAR(120) NULL, score DOUBLE NOT NULL,
grade VARCHAR(30) NOT NULL, published BOOLEAN NOT NULL, updated_at VARCHAR(35) NULL,
published_at VARCHAR(35) NULL, PRIMARY KEY (result_id), UNIQUE KEY uq_registration_subject (registration_id, subject_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${centers} (
center_key VARCHAR(160) NOT NULL, exam_id VARCHAR(64) NOT NULL, center_id VARCHAR(64) NULL,
center_code VARCHAR(60) NULL, center_name VARCHAR(200) NOT NULL, center_address VARCHAR(500) NOT NULL,
candidate_count INT UNSIGNED NOT NULL, room_count INT UNSIGNED NOT NULL, PRIMARY KEY (center_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
}
async function mysqlSchoolTable(connection, names) {
const students = quoteMysql(names.students);
await connection.query(`CREATE TABLE IF NOT EXISTS ${students} (
user_id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL, candidate_number VARCHAR(120) NULL,
candidate_name VARCHAR(120) NOT NULL, id_number VARCHAR(30) NULL, gender VARCHAR(20) NULL,
class_id VARCHAR(64) NULL, grade VARCHAR(60) NULL, phone VARCHAR(60) NULL, email VARCHAR(160) NULL,
profile_status VARCHAR(20) NULL, active BOOLEAN NOT NULL, updated_at VARCHAR(35) NULL,
PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
}
async function syncMysqlExam(connection, examId, names) {
const candidates = quoteMysql(names.candidates);
const admissions = quoteMysql(names.admissions);
const results = quoteMysql(names.results);
const centers = quoteMysql(names.centers);
await connection.query(`DELETE FROM ${candidates}`);
await connection.execute(`INSERT INTO ${candidates} (
registration_id, exam_id, user_id, candidate_number, candidate_name, school_id, class_id,
registration_status, payment_status, registered_at
) SELECT registration.id, registration.exam_id, registration.user_id, user.candidate_number,
COALESCE(profile.name, user.display_name), COALESCE(profile.school_id, user.school_id),
COALESCE(profile.class_id, user.class_id), registration.status, registration.payment_status,
registration.created_at FROM registrations registration JOIN users user ON user.id = registration.user_id
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${admissions}`);
await connection.execute(`INSERT INTO ${admissions} (
registration_id, subject_id, exam_id, candidate_number, admission_number, test_center,
center_code, center_address, room_id, room_name, room_code, exam_room_code,
building, floor, seat, generated_at
) SELECT registration.id, selected.subject_id, registration.exam_id, user.candidate_number,
card.card_number, card.test_center, card.center_code, card.center_address,
assignment.room_id, assignment.room, assignment.room_code, assignment.exam_room_code,
assignment.building, assignment.floor, assignment.seat, card.generated_at
FROM registrations registration JOIN users user ON user.id = registration.user_id
JOIN registration_subjects selected ON selected.registration_id = registration.id
LEFT JOIN admit_cards card ON card.registration_id = registration.id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = registration.id
AND assignment.subject_id = selected.subject_id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${results}`);
await connection.execute(`INSERT INTO ${results} (
result_id, exam_id, registration_id, subject_id, candidate_number, score, grade,
published, updated_at, published_at
) SELECT result.id, registration.exam_id, result.registration_id, result.subject_id,
user.candidate_number, result.score, result.grade, result.published, result.updated_at, result.published_at
FROM results result JOIN registrations registration ON registration.id = result.registration_id
JOIN users user ON user.id = registration.user_id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${centers}`);
await connection.execute(`INSERT INTO ${centers} (
center_key, exam_id, center_id, center_code, center_name, center_address, candidate_count, room_count
) SELECT COALESCE(card.center_id, CONCAT('snapshot:', card.center_code)), registration.exam_id,
card.center_id, card.center_code, MAX(card.test_center), MAX(card.center_address),
COUNT(DISTINCT card.registration_id), COUNT(DISTINCT assignment.room_id)
FROM admit_cards card JOIN registrations registration ON registration.id = card.registration_id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = card.registration_id
WHERE registration.exam_id = ? GROUP BY COALESCE(card.center_id, CONCAT('snapshot:', card.center_code)),
registration.exam_id, card.center_id, card.center_code`, [examId]);
}
async function syncMysqlSchool(connection, schoolId, names) {
const students = quoteMysql(names.students);
await connection.query(`DELETE FROM ${students}`);
await connection.execute(`INSERT INTO ${students} (
user_id, school_id, candidate_number, candidate_name, id_number, gender, class_id,
grade, phone, email, profile_status, active, updated_at
) SELECT user.id, ?, user.candidate_number, COALESCE(profile.name, user.display_name),
profile.id_number, profile.gender, COALESCE(profile.class_id, user.class_id), profile.grade,
profile.phone, profile.email, profile.status, user.active, profile.updated_at
FROM users user LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?`, [schoolId, schoolId]);
}
export async function synchronizeMysqlPartitions(connection) {
const now = new Date().toISOString();
const [exams] = await connection.query('SELECT id FROM exams ORDER BY id');
const [schools] = await connection.query('SELECT id FROM schools ORDER BY id');
for (const { id } of exams) {
const names = namesForExam(id);
await mysqlExamTables(connection, names);
await connection.execute(`INSERT INTO exam_data_partitions (
exam_id, partition_key, candidates_table, admissions_table, results_table, centers_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE partition_key = VALUES(partition_key),
candidates_table = VALUES(candidates_table), admissions_table = VALUES(admissions_table),
results_table = VALUES(results_table), centers_table = VALUES(centers_table), updated_at = VALUES(updated_at)`,
[id, names.key, names.candidates, names.admissions, names.results, names.centers, now, now]);
await syncMysqlExam(connection, id, names);
}
for (const { id } of schools) {
const names = namesForSchool(id);
await mysqlSchoolTable(connection, names);
await connection.execute(`INSERT INTO school_student_partitions (
school_id, partition_key, students_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE partition_key = VALUES(partition_key),
students_table = VALUES(students_table), updated_at = VALUES(updated_at)`,
[id, names.key, names.students, now, now]);
await syncMysqlSchool(connection, id, names);
}
}
+47
View File
@@ -34,6 +34,14 @@ export const sqliteSchema = `
UNIQUE (school_id, name) UNIQUE (school_id, name)
) STRICT; ) STRICT;
CREATE TABLE IF NOT EXISTS school_student_partitions (
school_id TEXT PRIMARY KEY REFERENCES schools(id) ON DELETE CASCADE,
partition_key TEXT NOT NULL UNIQUE,
students_table TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
@@ -119,6 +127,17 @@ export const sqliteSchema = `
created_at TEXT NOT NULL created_at TEXT NOT NULL
) STRICT; ) STRICT;
CREATE TABLE IF NOT EXISTS exam_data_partitions (
exam_id TEXT PRIMARY KEY REFERENCES exams(id) ON DELETE CASCADE,
partition_key TEXT NOT NULL UNIQUE,
candidates_table TEXT NOT NULL UNIQUE,
admissions_table TEXT NOT NULL UNIQUE,
results_table TEXT NOT NULL UNIQUE,
centers_table TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS exam_subjects ( CREATE TABLE IF NOT EXISTS exam_subjects (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
@@ -497,6 +516,17 @@ export const mysqlSchema = [
UNIQUE KEY uq_classes_school_name (school_id, name), UNIQUE KEY uq_classes_school_name (school_id, name),
CONSTRAINT fk_classes_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE CONSTRAINT fk_classes_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS school_student_partitions (
school_id VARCHAR(64) NOT NULL,
partition_key VARCHAR(32) NOT NULL,
students_table VARCHAR(64) NOT NULL,
created_at VARCHAR(35) NOT NULL,
updated_at VARCHAR(35) NOT NULL,
PRIMARY KEY (school_id),
UNIQUE KEY uq_school_partitions_key (partition_key),
UNIQUE KEY uq_school_partitions_table (students_table),
CONSTRAINT fk_school_partitions_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) NOT NULL, id VARCHAR(64) NOT NULL,
username VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL,
@@ -601,6 +631,23 @@ export const mysqlSchema = [
KEY idx_exams_archive (archived_at, exam_end), KEY idx_exams_archive (archived_at, exam_end),
CONSTRAINT fk_exams_archived_by FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE RESTRICT CONSTRAINT fk_exams_archived_by FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS exam_data_partitions (
exam_id VARCHAR(64) NOT NULL,
partition_key VARCHAR(32) NOT NULL,
candidates_table VARCHAR(64) NOT NULL,
admissions_table VARCHAR(64) NOT NULL,
results_table VARCHAR(64) NOT NULL,
centers_table VARCHAR(64) NOT NULL,
created_at VARCHAR(35) NOT NULL,
updated_at VARCHAR(35) NOT NULL,
PRIMARY KEY (exam_id),
UNIQUE KEY uq_exam_partitions_key (partition_key),
UNIQUE KEY uq_exam_partitions_candidates (candidates_table),
UNIQUE KEY uq_exam_partitions_admissions (admissions_table),
UNIQUE KEY uq_exam_partitions_results (results_table),
UNIQUE KEY uq_exam_partitions_centers (centers_table),
CONSTRAINT fk_exam_partitions_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS exam_subjects ( `CREATE TABLE IF NOT EXISTS exam_subjects (
id VARCHAR(64) NOT NULL, id VARCHAR(64) NOT NULL,
exam_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL,
+9 -1
View File
@@ -1,3 +1,5 @@
import { synchronizeSqlitePartitions } from './partition-storage.mjs';
export function createSqliteAdapter(context) { export function createSqliteAdapter(context) {
const { const {
mkdir, mkdir,
@@ -240,6 +242,9 @@ export function createSqliteAdapter(context) {
if (existingSystem && Number(existingSystem.schema_version || 1) < 15) { if (existingSystem && Number(existingSystem.schema_version || 1) < 15) {
throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库'); throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库');
} }
if (existingSystem && Number(existingSystem.schema_version || 1) < 16) {
connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.app_version || 1) < 2) { if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed(); const extension = seed();
connection.exec('BEGIN IMMEDIATE'); connection.exec('BEGIN IMMEDIATE');
@@ -400,7 +405,7 @@ export function createSqliteAdapter(context) {
try { try {
connection.prepare(` connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 15, ?, ?, ?) VALUES (1, 16, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT'); connection.exec('COMMIT');
@@ -411,10 +416,13 @@ export function createSqliteAdapter(context) {
} }
} }
synchronizeSqlitePartitions(connection);
const transaction = async operations => { const transaction = async operations => {
connection.exec('BEGIN IMMEDIATE'); connection.exec('BEGIN IMMEDIATE');
try { try {
for (const item of operations) connection.prepare(item.sql).run(...item.params); for (const item of operations) connection.prepare(item.sql).run(...item.params);
synchronizeSqlitePartitions(connection);
connection.exec('COMMIT'); connection.exec('COMMIT');
} catch (error) { } catch (error) {
connection.exec('ROLLBACK'); connection.exec('ROLLBACK');
+43
View File
@@ -157,6 +157,49 @@ export function createAdminRoutes(context) {
return sendJson(response, 200, { ok: true, ...result }); return sendJson(response, 200, { ok: true, ...result });
} }
if (pathname === '/api/admin/schools' && request.method === 'GET') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以管理学校');
const schools = db.schools.map(school => ({
...school,
classCount: db.classes.filter(item => item.schoolId === school.id).length,
adminCount: db.users.filter(item => item.role === 'admin' && item.schoolId === school.id).length,
candidateCount: db.candidateProfiles.filter(item => item.schoolId === school.id).length,
centerCount: db.testCenters.filter(item => item.schoolId === school.id).length
}));
return sendJson(response, 200, { ok: true, schools });
}
if (pathname === '/api/admin/schools' && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建学校');
const body = await readJson(request);
const name = cleanText(body.name, 100);
const code = cleanText(body.code, 40).toUpperCase();
const address = cleanText(body.address, 200);
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
if (db.schools.some(item => item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
if (db.schools.some(item => item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
const school = { id: uid('school'), name, code, address, active: body.active !== false };
await database.saveSchool(school, true, logAction(db, user, '创建学校', `${name} · ${code}`));
return sendJson(response, 201, { ok: true, school });
}
const schoolMatch = pathname.match(/^\/api\/admin\/schools\/([^/]+)$/);
if (schoolMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以维护学校');
const body = await readJson(request);
const school = db.schools.find(item => item.id === schoolMatch[1]);
if (!school) return sendError(response, 404, '学校不存在');
const name = cleanText(body.name ?? school.name, 100);
const code = cleanText(body.code ?? school.code, 40).toUpperCase();
const address = cleanText(body.address ?? school.address, 200);
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
if (db.schools.some(item => item.id !== school.id && item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
if (db.schools.some(item => item.id !== school.id && item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
Object.assign(school, { name, code, address, active: body.active == null ? school.active : Boolean(body.active) });
await database.saveSchool(school, false, logAction(db, user, '维护学校', `${name} · ${code} · ${school.active ? '启用' : '停用'}`));
return sendJson(response, 200, { ok: true, school });
}
if (pathname === '/api/admin/school-organization' && request.method === 'GET') { if (pathname === '/api/admin/school-organization' && request.method === 'GET') {
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织'); if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织');
const school = db.schools.find(item => item.id === user.schoolId); const school = db.schools.find(item => item.id === user.schoolId);
+82 -4
View File
@@ -37,7 +37,7 @@ assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'My
assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行'); assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行');
assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建'); assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行'); assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| existingSchemaVersion !== 15\)/, 'MySQL 应重建未完成或过期的开发结构'); assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15 结构并重建更旧或未完成的开发结构');
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理'); assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8'); const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器'); assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
@@ -177,6 +177,7 @@ try {
const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name); const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name); const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name);
const seededSchoolCount = inspector.prepare('SELECT COUNT(*) AS count FROM schools').get().count; const seededSchoolCount = inspector.prepare('SELECT COUNT(*) AS count FROM schools').get().count;
const seededExamCount = inspector.prepare('SELECT COUNT(*) AS count FROM exams').get().count;
const seededCandidateCount = inspector.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'candidate'").get().count; const seededCandidateCount = inspector.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'candidate'").get().count;
const seededRegistrationCounts = inspector.prepare(` const seededRegistrationCounts = inspector.prepare(`
SELECT status, payment_status, COUNT(*) AS count SELECT status, payment_status, COUNT(*) AS count
@@ -185,10 +186,46 @@ try {
`).all(); `).all();
const seededArrangementCount = inspector.prepare('SELECT COUNT(*) AS count FROM exam_arrangement_plans').get().count; const seededArrangementCount = inspector.prepare('SELECT COUNT(*) AS count FROM exam_arrangement_plans').get().count;
const seededAdmitCardCount = inspector.prepare('SELECT COUNT(*) AS count FROM admit_cards').get().count; const seededAdmitCardCount = inspector.prepare('SELECT COUNT(*) AS count FROM admit_cards').get().count;
const examPartitions = inspector.prepare('SELECT * FROM exam_data_partitions ORDER BY exam_id').all();
const schoolPartitions = inspector.prepare('SELECT * FROM school_student_partitions ORDER BY school_id').all();
const examPartitionCoverage = examPartitions.map(partition => {
for (const name of [partition.candidates_table, partition.admissions_table, partition.results_table, partition.centers_table]) {
assert.match(name, /^[a-z][a-z0-9_]{0,63}$/, '考试分表名必须是安全的数据库标识符');
assert.ok(tableNames.includes(name), `考试专属表 ${name} 必须真实存在`);
}
return {
examId: partition.exam_id,
candidates: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.candidates_table}"`).get().count,
expectedCandidates: inspector.prepare('SELECT COUNT(*) AS count FROM registrations WHERE exam_id = ?').get(partition.exam_id).count,
admissions: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.admissions_table}"`).get().count,
expectedAdmissions: inspector.prepare(`SELECT COUNT(*) AS count FROM registration_subjects selected
JOIN registrations registration ON registration.id = selected.registration_id WHERE registration.exam_id = ?`).get(partition.exam_id).count,
results: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.results_table}"`).get().count,
expectedResults: inspector.prepare(`SELECT COUNT(*) AS count FROM results result
JOIN registrations registration ON registration.id = result.registration_id WHERE registration.exam_id = ?`).get(partition.exam_id).count
};
});
const schoolPartitionCoverage = schoolPartitions.map(partition => {
assert.match(partition.students_table, /^[a-z][a-z0-9_]{0,63}$/, '学校学生分表名必须是安全的数据库标识符');
assert.ok(tableNames.includes(partition.students_table), `学校学生专属表 ${partition.students_table} 必须真实存在`);
return {
schoolId: partition.school_id,
students: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.students_table}"`).get().count,
expectedStudents: inspector.prepare(`SELECT COUNT(*) AS count FROM users user
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?`).get(partition.school_id).count
};
});
inspector.close(); inspector.close();
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储'); assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
assert.equal(schemaVersion, 15, '线下缴费确认应使用 v15 数据结构'); assert.equal(schemaVersion, 16, '考试与学校物理分表应使用 v16 数据结构');
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');
assert.ok(schoolPartitions.length > 0, '每所学校都应登记学生专属物理表');
assert.equal(schoolPartitions.length, seededSchoolCount, '学校学生分表登记不得缺漏');
assert.ok(schoolPartitionCoverage.every(item => item.students === item.expectedStudents), '学校学生专属表应仅保存本校学生且数量一致');
assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员'); assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员');
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩'); assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人'); assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
@@ -273,6 +310,21 @@ try {
assert.match(selfRegister.data.registrationNumber, /^2026-HZ03-F-\d{4}$/); assert.match(selfRegister.data.registrationNumber, /^2026-HZ03-F-\d{4}$/);
await admin.request('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled: false } }); await admin.request('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled: false } });
const schoolDirectory = await admin.request('/api/admin/schools');
assert.equal(schoolDirectory.response.status, 200, '超级管理员应有学校管理入口');
assert.ok(schoolDirectory.data.schools.every(item => Number.isInteger(item.classCount) && Number.isInteger(item.adminCount)), '学校目录应汇总班级和管理员数量');
assert.equal((await schoolAdmin.request('/api/admin/schools')).response.status, 403, '校级管理员不得跨校维护学校档案');
const createSchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市第四中学', code: 'hz04', address: '海州市测试区学校路 4 号', active: true } });
assert.equal(createSchool.response.status, 201, '超级管理员应能创建学校');
assert.equal(createSchool.data.school.code, 'HZ04', '学校代码应规范化为大写');
const createdSchoolId = createSchool.data.school.id;
assert.equal((await admin.request('/api/admin/schools', { method: 'POST', body: { name: '重复代码学校', code: 'HZ04' } })).response.status, 409, '学校代码必须唯一');
const disableSchool = await admin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { name: '海州市第四实验中学', active: false } });
assert.equal(disableSchool.response.status, 200, '超级管理员应能编辑和停用学校');
assert.equal(disableSchool.data.school.active, false);
assert.equal(disableSchool.data.school.name, '海州市第四实验中学');
assert.equal((await schoolAdmin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { active: true } })).response.status, 403, '校级管理员不得启停学校');
const adminDirectory = await admin.request('/api/admin/admins'); const adminDirectory = await admin.request('/api/admin/admins');
assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员'); assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员');
const schoolCenters = await schoolAdmin.request('/api/admin/centers'); const schoolCenters = await schoolAdmin.request('/api/admin/centers');
@@ -450,6 +502,14 @@ try {
assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线'); assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线');
assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线'); assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线');
const exam = createExam.data.exam; const exam = createExam.data.exam;
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');
for (const table of [createdExamPartition.candidates_table, createdExamPartition.admissions_table, createdExamPartition.results_table, createdExamPartition.centers_table]) {
assert.match(table, /^[a-z][a-z0-9_]{0,63}$/);
assert.ok(createdExamInspector.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), `新考试专属表 ${table} 应立即创建`);
}
createdExamInspector.close();
const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } }); const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } });
assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划'); assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划');
@@ -742,6 +802,24 @@ try {
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩'); assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩'); assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
const livePartitionInspector = new DatabaseSync(testDb, { readOnly: true });
const liveExamPartition = livePartitionInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
const partitionCandidate = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.candidates_table}" WHERE registration_id = ?`).get(registrationId);
const partitionAdmissions = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.admissions_table}" WHERE registration_id = ? ORDER BY subject_id`).all(registrationId);
const partitionResults = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.results_table}" WHERE registration_id = ? ORDER BY subject_id`).all(registrationId);
const partitionCenters = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.centers_table}"`).all();
assert.equal(partitionCandidate.candidate_number, candidateNumber, '考试考生专属表应保存该场报名考生');
assert.equal(partitionCandidate.payment_status, 'paid', '考试考生专属表应同步报名与缴费状态');
assert.equal(partitionAdmissions.length, 2, '考试准考证专属表应按报考科目保存考号和座位');
assert.ok(partitionAdmissions.every(item => item.admission_number === generatedCard.number && item.seat), '考试准考证专属表应同步准考证号、考场和座位');
assert.equal(partitionResults.length, 2, '考试成绩专属表应只保存该场考试成绩');
assert.ok(partitionResults.every(item => item.published === 1), '考试成绩专属表应同步发布状态');
assert.ok(partitionCenters.some(item => item.center_code === generatedCard.centerCode), '考试考点专属表应保存本场实际使用的考点');
const schoolStudentPartition = livePartitionInspector.prepare('SELECT students_table FROM school_student_partitions WHERE school_id = ?').get('school_hz1');
const partitionStudent = livePartitionInspector.prepare(`SELECT * FROM "${schoolStudentPartition.students_table}" WHERE candidate_number = ?`).get(candidateNumber);
assert.equal(partitionStudent.candidate_name, '测试考生新名', '学校学生专属表应保存且同步本校学生资料');
livePartitionInspector.close();
const appealResults = results.data.results.filter(item => [exam.subjects[0].id, exam.subjects[2].id].includes(item.subjectId)); const appealResults = results.data.results.filter(item => [exam.subjects[0].id, exam.subjects[2].id].includes(item.subjectId));
assert.equal(appealResults.length, 2, '测试考生应有两科可提交成绩复议'); assert.equal(appealResults.length, 2, '测试考生应有两科可提交成绩复议');
const submittedAppeals = []; const submittedAppeals = [];
@@ -821,7 +899,7 @@ try {
assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程'); assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程');
console.log('✓ 公开首页与通知读取'); console.log('✓ 公开首页与通知读取');
console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张分表)`); console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length}核心表 + 按考试/学校动态专属分表)`);
console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离'); console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离');
console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关'); console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关');
console.log('✓ 多科目考试创建与考生自主选科报名'); console.log('✓ 多科目考试创建与考生自主选科报名');