diff --git a/README.md b/README.md
index 4d32450..a046b82 100644
--- a/README.md
+++ b/README.md
@@ -121,7 +121,9 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'ex
启动应用时设置连接信息,应用会自动创建以下关系表和系统基础配置,但不会自动写入测试业务数据:
- `schools`、`school_classes`、`users`、`candidate_profiles`
+- `school_student_partitions`(学校学生专属表登记)
- `exams`、`exam_subjects`
+- `exam_data_partitions`(考试专属表登记)
- `registrations`、`registration_subjects`
- `admission_number_rules`、`exam_arrangement_plans`、`admit_cards`、`admit_card_subjects`
- `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
$env:NODE_ENV = 'production'
$env:DATABASE_CLIENT = 'mysql'
diff --git a/app.js b/app.js
index 0d4c3d9..21be7e6 100644
--- a/app.js
+++ b/app.js
@@ -137,6 +137,13 @@ document.addEventListener('click', async event => {
if (action === 'new-notice') { await openNoticeForm(); return; }
if (action === 'new-exam') return openExamForm();
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 === 'edit-school-class') return openSchoolClassForm(state.pageData.classes.find(item => item.id === target.dataset.id));
if (action === 'toggle-school-class') {
@@ -428,6 +435,10 @@ document.addEventListener('submit', async event => {
const body = formObject(form);
await api('/api/admin/admins', { method: 'POST', body });
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') {
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 });
@@ -546,6 +557,10 @@ function openAdminForm() {
setModal(`
ADMIN SCOPE添加分级管理员
同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。
`);
}
+function openSchoolForm(school = null) {
+ setModal(`SCHOOL DIRECTORY${school ? '编辑学校档案' : '创建学校'}
学校代码用于管理员范围、报名号规则和数据识别,建议使用稳定且唯一的简称。
`);
+}
+
function openSchoolClassForm(schoolClass = null) {
setModal(`SCHOOL CLASS${schoolClass ? '编辑本校班级' : '新增本校班级'}
班级是考生资料、报名和成绩权限的最小范围。
`);
}
diff --git a/database.mjs b/database.mjs
index 0b9dfcf..30707f5 100644
--- a/database.mjs
+++ b/database.mjs
@@ -9,10 +9,12 @@ export const relationalTables = [
'organization',
'schools',
'school_classes',
+ 'school_student_partitions',
'users',
'candidate_profiles',
'notices',
'exams',
+ 'exam_data_partitions',
'exam_subjects',
'registrations',
'registration_subjects',
@@ -57,7 +59,7 @@ export function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value;
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,
state.meta?.createdAt || new Date().toISOString()
);
@@ -1169,6 +1171,18 @@ function createRepository({ client, location, read, transaction, close }) {
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) {
const change = isNew
? operation(
diff --git a/scripts/import-test-data.mjs b/scripts/import-test-data.mjs
index 30426ee..6382de1 100644
--- a/scripts/import-test-data.mjs
+++ b/scripts/import-test-data.mjs
@@ -101,8 +101,20 @@ async function prepareMysql() {
}
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
- if (Number(metadataRows[0]?.schema_version) !== 15) {
- throw new Error(`MySQL 数据库结构版本不是 v15(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
+ if (Number(metadataRows[0]?.schema_version) !== 16) {
+ 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 = [];
@@ -148,6 +160,7 @@ async function prepareMysql() {
} finally {
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 };
} finally {
connection?.release();
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index f661ebc..f620308 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -32,6 +32,7 @@ export function createAdminViews(context) {
app.classList.add('admin-readable');
const meta = {
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
+ schools: ['学校管理', '创建和维护学校档案,控制学校在考生公开入口中的可选状态。'],
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', state.user.adminLevel === 'class' ? '考生线下缴费后,由班级负责人确认并记录办理时间。' : '查看并导出当前管理范围内的考试缴费名单。'],
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
@@ -55,7 +56,7 @@ export function createAdminViews(context) {
const content = {
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),
- 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()
}[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 = '数据' } = {}) {
return ``;
}
+
+ function adminSchools(data) {
+ const schools = data.schools || [];
+ const activeCount = schools.filter(item => item.active).length;
+ return `学校总数${schools.length}
启用学校${activeCount}
在册考生${schools.reduce((sum, item) => sum + item.candidateCount, 0)}
`;
+ }
function adminSchoolOrganization(data) {
const classes = data.classes || [];
diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs
index 8c8fb59..8ed0f54 100644
--- a/src/client/candidate-views.mjs
+++ b/src/client/candidate-views.mjs
@@ -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];
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];
- 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) {
@@ -43,6 +43,7 @@ export function createCandidateViews(context) {
function portalHeadingAction(role, page) {
if (role === 'admin' && page === 'notices') return ``;
if (role === 'admin' && page === 'exams') return ``;
+ if (role === 'admin' && page === 'schools') return ``;
if (role === 'admin' && page === 'admins') return ``;
if (role === 'admin' && page === 'centers') return ``;
if (role === 'admin' && page === 'organization') return ``;
diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs
index be54b0e..431e8b5 100644
--- a/src/database/mysql-adapter.mjs
+++ b/src/database/mysql-adapter.mjs
@@ -1,3 +1,5 @@
+import { synchronizeMysqlPartitions } from './partition-storage.mjs';
+
export function createMysqlAdapter(context) {
const {
mkdir,
@@ -52,7 +54,7 @@ export function createMysqlAdapter(context) {
hasSchemaMetadata = metadataRows.length > 0;
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()) {
await pool.query(`DROP TABLE IF EXISTS \`${table}\``);
}
@@ -152,6 +154,10 @@ export function createMysqlAdapter(context) {
if (Number(metadataRows[0]?.schema_version || 1) < 15) {
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) {
const extension = seed();
const connection = await pool.getConnection();
@@ -334,7 +340,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction();
const [insert] = await connection.execute(`
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()]);
if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
@@ -356,6 +362,13 @@ export function createMysqlAdapter(context) {
if (!candidateNumberIndexes.length) {
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 connection = await pool.getConnection();
@@ -363,6 +376,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction();
for (const item of operations) await connection.execute(item.sql, item.params);
await connection.commit();
+ await synchronizeMysqlPartitions(connection);
} catch (error) {
await connection.rollback();
throw error;
diff --git a/src/database/partition-storage.mjs b/src/database/partition-storage.mjs
new file mode 100644
index 0000000..0c7f0cd
--- /dev/null
+++ b/src/database/partition-storage.mjs
@@ -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);
+ }
+}
diff --git a/src/database/schema.mjs b/src/database/schema.mjs
index 08c72c6..366df7a 100644
--- a/src/database/schema.mjs
+++ b/src/database/schema.mjs
@@ -34,6 +34,14 @@ export const sqliteSchema = `
UNIQUE (school_id, name)
) 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 (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -119,6 +127,17 @@ export const sqliteSchema = `
created_at TEXT NOT NULL
) 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 (
id TEXT PRIMARY KEY,
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),
CONSTRAINT fk_classes_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 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 (
id VARCHAR(64) NOT NULL,
username VARCHAR(100) NOT NULL,
@@ -601,6 +631,23 @@ export const mysqlSchema = [
KEY idx_exams_archive (archived_at, exam_end),
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`,
+ `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 (
id VARCHAR(64) NOT NULL,
exam_id VARCHAR(64) NOT NULL,
diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs
index 66d7e92..2a66562 100644
--- a/src/database/sqlite-adapter.mjs
+++ b/src/database/sqlite-adapter.mjs
@@ -1,3 +1,5 @@
+import { synchronizeSqlitePartitions } from './partition-storage.mjs';
+
export function createSqliteAdapter(context) {
const {
mkdir,
@@ -240,6 +242,9 @@ export function createSqliteAdapter(context) {
if (existingSystem && Number(existingSystem.schema_version || 1) < 15) {
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) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
@@ -400,7 +405,7 @@ export function createSqliteAdapter(context) {
try {
connection.prepare(`
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());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT');
@@ -410,11 +415,14 @@ export function createSqliteAdapter(context) {
throw error;
}
}
+
+ synchronizeSqlitePartitions(connection);
const transaction = async operations => {
connection.exec('BEGIN IMMEDIATE');
try {
for (const item of operations) connection.prepare(item.sql).run(...item.params);
+ synchronizeSqlitePartitions(connection);
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs
index 6fbefc7..692c725 100644
--- a/src/routes/admin.routes.mjs
+++ b/src/routes/admin.routes.mjs
@@ -156,6 +156,49 @@ export function createAdminRoutes(context) {
const result = await importExcelResource(db, user, resource, rows);
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 (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织');
diff --git a/tests/system.test.mjs b/tests/system.test.mjs
index 266c209..ce150c2 100644
--- a/tests/system.test.mjs
+++ b/tests/system.test.mjs
@@ -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, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
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 半成品表应按外键依赖逆序清理');
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
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 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 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 seededRegistrationCounts = inspector.prepare(`
SELECT status, payment_status, COUNT(*) AS count
@@ -185,10 +186,46 @@ try {
`).all();
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 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();
- assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
+ assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
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.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
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}$/);
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');
assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员');
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].passScore, null, '排名比例不应伪造固定分数线');
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: '越权考试' } });
assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划');
@@ -742,6 +802,24 @@ try {
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, '班级管理员不得录入或发布成绩');
+ 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));
assert.equal(appealResults.length, 2, '测试考生应有两科可提交成绩复议');
const submittedAppeals = [];
@@ -821,7 +899,7 @@ try {
assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程');
console.log('✓ 公开首页与通知读取');
- console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张分表)`);
+ console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张核心表 + 按考试/学校动态专属分表)`);
console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离');
console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关');
console.log('✓ 多科目考试创建与考生自主选科报名');