修正权限
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]);
|
||||||
@@ -71,6 +72,12 @@ export function createAdminViews(context) {
|
|||||||
function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) {
|
function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) {
|
||||||
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 || [];
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -356,6 +362,13 @@ export function createMysqlAdapter(context) {
|
|||||||
if (!candidateNumberIndexes.length) {
|
if (!candidateNumberIndexes.length) {
|
||||||
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();
|
||||||
@@ -363,6 +376,7 @@ export function createMysqlAdapter(context) {
|
|||||||
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -410,11 +415,14 @@ export function createSqliteAdapter(context) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|||||||
@@ -156,6 +156,49 @@ export function createAdminRoutes(context) {
|
|||||||
const result = await importExcelResource(db, user, resource, rows);
|
const result = await importExcelResource(db, user, resource, rows);
|
||||||
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, '只有校级管理员可以维护本校组织');
|
||||||
|
|||||||
+82
-4
@@ -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('✓ 多科目考试创建与考生自主选科报名');
|
||||||
|
|||||||
Reference in New Issue
Block a user