修正权限

This commit is contained in:
2026-07-20 21:27:48 +08:00 Unverified
parent c31602593a
commit 76a1b66f53
12 changed files with 584 additions and 12 deletions
+8 -1
View File
@@ -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 `<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) {
const classes = data.classes || [];
+2 -1
View File
@@ -30,7 +30,7 @@ export function createCandidateViews(context) {
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security];
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 `<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 === '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 === '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>`;
+16 -2
View File
@@ -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;
+324
View File
@@ -0,0 +1,324 @@
import { createHash } from 'node:crypto';
const identifierPattern = /^[a-z][a-z0-9_]{0,63}$/;
function partitionKey(ownerId) {
return createHash('sha256').update(String(ownerId)).digest('hex').slice(0, 16);
}
function namesForExam(examId) {
const key = partitionKey(examId);
return {
key,
candidates: `exam_${key}_candidates`,
admissions: `exam_${key}_admissions`,
results: `exam_${key}_results`,
centers: `exam_${key}_centers`
};
}
function namesForSchool(schoolId) {
const key = partitionKey(schoolId);
return { key, students: `school_${key}_students` };
}
function quoteSqlite(identifier) {
if (!identifierPattern.test(identifier)) throw new Error(`非法 SQLite 分表名称:${identifier}`);
return `"${identifier}"`;
}
function quoteMysql(identifier) {
if (!identifierPattern.test(identifier)) throw new Error(`非法 MySQL 分表名称:${identifier}`);
return `\`${identifier}\``;
}
function sqliteExamTables(connection, names) {
const candidates = quoteSqlite(names.candidates);
const admissions = quoteSqlite(names.admissions);
const results = quoteSqlite(names.results);
const centers = quoteSqlite(names.centers);
connection.exec(`
CREATE TABLE IF NOT EXISTS ${candidates} (
registration_id TEXT PRIMARY KEY, exam_id TEXT NOT NULL, user_id TEXT NOT NULL,
candidate_number TEXT, candidate_name TEXT NOT NULL, school_id TEXT, class_id TEXT,
registration_status TEXT NOT NULL, payment_status TEXT NOT NULL, registered_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS ${admissions} (
registration_id TEXT NOT NULL, subject_id TEXT NOT NULL, exam_id TEXT NOT NULL,
candidate_number TEXT, admission_number TEXT, test_center TEXT, center_code TEXT,
center_address TEXT, room_id TEXT, room_name TEXT, room_code TEXT, exam_room_code TEXT,
building TEXT, floor TEXT, seat TEXT, generated_at TEXT,
PRIMARY KEY (registration_id, subject_id)
) STRICT;
CREATE TABLE IF NOT EXISTS ${results} (
result_id TEXT PRIMARY KEY, exam_id TEXT NOT NULL, registration_id TEXT NOT NULL,
subject_id TEXT NOT NULL, candidate_number TEXT, score REAL NOT NULL, grade TEXT NOT NULL,
published INTEGER NOT NULL CHECK (published IN (0, 1)), updated_at TEXT, published_at TEXT,
UNIQUE (registration_id, subject_id)
) STRICT;
CREATE TABLE IF NOT EXISTS ${centers} (
center_key TEXT PRIMARY KEY, exam_id TEXT NOT NULL, center_id TEXT, center_code TEXT,
center_name TEXT NOT NULL, center_address TEXT NOT NULL, candidate_count INTEGER NOT NULL,
room_count INTEGER NOT NULL
) STRICT;
`);
}
function sqliteSchoolTable(connection, names) {
const students = quoteSqlite(names.students);
connection.exec(`
CREATE TABLE IF NOT EXISTS ${students} (
user_id TEXT PRIMARY KEY, school_id TEXT NOT NULL, candidate_number TEXT,
candidate_name TEXT NOT NULL, id_number TEXT, gender TEXT, class_id TEXT, grade TEXT,
phone TEXT, email TEXT, profile_status TEXT, active INTEGER NOT NULL CHECK (active IN (0, 1)),
updated_at TEXT
) STRICT;
`);
}
function syncSqliteExam(connection, examId, names) {
const candidates = quoteSqlite(names.candidates);
const admissions = quoteSqlite(names.admissions);
const results = quoteSqlite(names.results);
const centers = quoteSqlite(names.centers);
connection.exec(`DELETE FROM ${candidates}; DELETE FROM ${admissions}; DELETE FROM ${results}; DELETE FROM ${centers};`);
connection.prepare(`
INSERT INTO ${candidates} (
registration_id, exam_id, user_id, candidate_number, candidate_name, school_id, class_id,
registration_status, payment_status, registered_at
)
SELECT registration.id, registration.exam_id, registration.user_id, user.candidate_number,
COALESCE(profile.name, user.display_name), COALESCE(profile.school_id, user.school_id),
COALESCE(profile.class_id, user.class_id), registration.status, registration.payment_status,
registration.created_at
FROM registrations registration
JOIN users user ON user.id = registration.user_id
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${admissions} (
registration_id, subject_id, exam_id, candidate_number, admission_number, test_center,
center_code, center_address, room_id, room_name, room_code, exam_room_code,
building, floor, seat, generated_at
)
SELECT registration.id, selected.subject_id, registration.exam_id, user.candidate_number,
card.card_number, card.test_center, card.center_code, card.center_address,
assignment.room_id, assignment.room, assignment.room_code, assignment.exam_room_code,
assignment.building, assignment.floor, assignment.seat, card.generated_at
FROM registrations registration
JOIN users user ON user.id = registration.user_id
JOIN registration_subjects selected ON selected.registration_id = registration.id
LEFT JOIN admit_cards card ON card.registration_id = registration.id
LEFT JOIN admit_card_subjects assignment
ON assignment.registration_id = registration.id AND assignment.subject_id = selected.subject_id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${results} (
result_id, exam_id, registration_id, subject_id, candidate_number, score, grade,
published, updated_at, published_at
)
SELECT result.id, registration.exam_id, result.registration_id, result.subject_id,
user.candidate_number, result.score, result.grade, result.published,
result.updated_at, result.published_at
FROM results result
JOIN registrations registration ON registration.id = result.registration_id
JOIN users user ON user.id = registration.user_id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${centers} (
center_key, exam_id, center_id, center_code, center_name, center_address,
candidate_count, room_count
)
SELECT COALESCE(card.center_id, 'snapshot:' || card.center_code), registration.exam_id,
card.center_id, card.center_code, MAX(card.test_center), MAX(card.center_address),
COUNT(DISTINCT card.registration_id), COUNT(DISTINCT assignment.room_id)
FROM admit_cards card
JOIN registrations registration ON registration.id = card.registration_id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = card.registration_id
WHERE registration.exam_id = ?
GROUP BY COALESCE(card.center_id, 'snapshot:' || card.center_code), registration.exam_id,
card.center_id, card.center_code
`).run(examId);
}
function syncSqliteSchool(connection, schoolId, names) {
const students = quoteSqlite(names.students);
connection.exec(`DELETE FROM ${students};`);
connection.prepare(`
INSERT INTO ${students} (
user_id, school_id, candidate_number, candidate_name, id_number, gender, class_id,
grade, phone, email, profile_status, active, updated_at
)
SELECT user.id, ?, user.candidate_number, COALESCE(profile.name, user.display_name),
profile.id_number, profile.gender, COALESCE(profile.class_id, user.class_id), profile.grade,
profile.phone, profile.email, profile.status, user.active, profile.updated_at
FROM users user
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?
`).run(schoolId, schoolId);
}
export function synchronizeSqlitePartitions(connection) {
const now = new Date().toISOString();
const exams = connection.prepare('SELECT id FROM exams ORDER BY id').all();
const schools = connection.prepare('SELECT id FROM schools ORDER BY id').all();
for (const { id } of exams) {
const names = namesForExam(id);
sqliteExamTables(connection, names);
connection.prepare(`
INSERT INTO exam_data_partitions (
exam_id, partition_key, candidates_table, admissions_table, results_table, centers_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_id) DO UPDATE SET partition_key = excluded.partition_key,
candidates_table = excluded.candidates_table, admissions_table = excluded.admissions_table,
results_table = excluded.results_table, centers_table = excluded.centers_table,
updated_at = excluded.updated_at
`).run(id, names.key, names.candidates, names.admissions, names.results, names.centers, now, now);
syncSqliteExam(connection, id, names);
}
for (const { id } of schools) {
const names = namesForSchool(id);
sqliteSchoolTable(connection, names);
connection.prepare(`
INSERT INTO school_student_partitions (school_id, partition_key, students_table, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(school_id) DO UPDATE SET partition_key = excluded.partition_key,
students_table = excluded.students_table, updated_at = excluded.updated_at
`).run(id, names.key, names.students, now, now);
syncSqliteSchool(connection, id, names);
}
}
async function mysqlExamTables(connection, names) {
const candidates = quoteMysql(names.candidates);
const admissions = quoteMysql(names.admissions);
const results = quoteMysql(names.results);
const centers = quoteMysql(names.centers);
await connection.query(`CREATE TABLE IF NOT EXISTS ${candidates} (
registration_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL,
candidate_number VARCHAR(120) NULL, candidate_name VARCHAR(120) NOT NULL, school_id VARCHAR(64) NULL,
class_id VARCHAR(64) NULL, registration_status VARCHAR(20) NOT NULL, payment_status VARCHAR(20) NOT NULL,
registered_at VARCHAR(35) NOT NULL, PRIMARY KEY (registration_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${admissions} (
registration_id VARCHAR(64) NOT NULL, subject_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL,
candidate_number VARCHAR(120) NULL, admission_number VARCHAR(120) NULL, test_center VARCHAR(200) NULL,
center_code VARCHAR(60) NULL, center_address VARCHAR(500) NULL, room_id VARCHAR(64) NULL,
room_name VARCHAR(120) NULL, room_code VARCHAR(60) NULL, exam_room_code VARCHAR(120) NULL,
building VARCHAR(120) NULL, floor VARCHAR(60) NULL, seat VARCHAR(60) NULL, generated_at VARCHAR(35) NULL,
PRIMARY KEY (registration_id, subject_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${results} (
result_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, registration_id VARCHAR(64) NOT NULL,
subject_id VARCHAR(64) NOT NULL, candidate_number VARCHAR(120) NULL, score DOUBLE NOT NULL,
grade VARCHAR(30) NOT NULL, published BOOLEAN NOT NULL, updated_at VARCHAR(35) NULL,
published_at VARCHAR(35) NULL, PRIMARY KEY (result_id), UNIQUE KEY uq_registration_subject (registration_id, subject_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${centers} (
center_key VARCHAR(160) NOT NULL, exam_id VARCHAR(64) NOT NULL, center_id VARCHAR(64) NULL,
center_code VARCHAR(60) NULL, center_name VARCHAR(200) NOT NULL, center_address VARCHAR(500) NOT NULL,
candidate_count INT UNSIGNED NOT NULL, room_count INT UNSIGNED NOT NULL, PRIMARY KEY (center_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
}
async function mysqlSchoolTable(connection, names) {
const students = quoteMysql(names.students);
await connection.query(`CREATE TABLE IF NOT EXISTS ${students} (
user_id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL, candidate_number VARCHAR(120) NULL,
candidate_name VARCHAR(120) NOT NULL, id_number VARCHAR(30) NULL, gender VARCHAR(20) NULL,
class_id VARCHAR(64) NULL, grade VARCHAR(60) NULL, phone VARCHAR(60) NULL, email VARCHAR(160) NULL,
profile_status VARCHAR(20) NULL, active BOOLEAN NOT NULL, updated_at VARCHAR(35) NULL,
PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
}
async function syncMysqlExam(connection, examId, names) {
const candidates = quoteMysql(names.candidates);
const admissions = quoteMysql(names.admissions);
const results = quoteMysql(names.results);
const centers = quoteMysql(names.centers);
await connection.query(`DELETE FROM ${candidates}`);
await connection.execute(`INSERT INTO ${candidates} (
registration_id, exam_id, user_id, candidate_number, candidate_name, school_id, class_id,
registration_status, payment_status, registered_at
) SELECT registration.id, registration.exam_id, registration.user_id, user.candidate_number,
COALESCE(profile.name, user.display_name), COALESCE(profile.school_id, user.school_id),
COALESCE(profile.class_id, user.class_id), registration.status, registration.payment_status,
registration.created_at FROM registrations registration JOIN users user ON user.id = registration.user_id
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${admissions}`);
await connection.execute(`INSERT INTO ${admissions} (
registration_id, subject_id, exam_id, candidate_number, admission_number, test_center,
center_code, center_address, room_id, room_name, room_code, exam_room_code,
building, floor, seat, generated_at
) SELECT registration.id, selected.subject_id, registration.exam_id, user.candidate_number,
card.card_number, card.test_center, card.center_code, card.center_address,
assignment.room_id, assignment.room, assignment.room_code, assignment.exam_room_code,
assignment.building, assignment.floor, assignment.seat, card.generated_at
FROM registrations registration JOIN users user ON user.id = registration.user_id
JOIN registration_subjects selected ON selected.registration_id = registration.id
LEFT JOIN admit_cards card ON card.registration_id = registration.id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = registration.id
AND assignment.subject_id = selected.subject_id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${results}`);
await connection.execute(`INSERT INTO ${results} (
result_id, exam_id, registration_id, subject_id, candidate_number, score, grade,
published, updated_at, published_at
) SELECT result.id, registration.exam_id, result.registration_id, result.subject_id,
user.candidate_number, result.score, result.grade, result.published, result.updated_at, result.published_at
FROM results result JOIN registrations registration ON registration.id = result.registration_id
JOIN users user ON user.id = registration.user_id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${centers}`);
await connection.execute(`INSERT INTO ${centers} (
center_key, exam_id, center_id, center_code, center_name, center_address, candidate_count, room_count
) SELECT COALESCE(card.center_id, CONCAT('snapshot:', card.center_code)), registration.exam_id,
card.center_id, card.center_code, MAX(card.test_center), MAX(card.center_address),
COUNT(DISTINCT card.registration_id), COUNT(DISTINCT assignment.room_id)
FROM admit_cards card JOIN registrations registration ON registration.id = card.registration_id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = card.registration_id
WHERE registration.exam_id = ? GROUP BY COALESCE(card.center_id, CONCAT('snapshot:', card.center_code)),
registration.exam_id, card.center_id, card.center_code`, [examId]);
}
async function syncMysqlSchool(connection, schoolId, names) {
const students = quoteMysql(names.students);
await connection.query(`DELETE FROM ${students}`);
await connection.execute(`INSERT INTO ${students} (
user_id, school_id, candidate_number, candidate_name, id_number, gender, class_id,
grade, phone, email, profile_status, active, updated_at
) SELECT user.id, ?, user.candidate_number, COALESCE(profile.name, user.display_name),
profile.id_number, profile.gender, COALESCE(profile.class_id, user.class_id), profile.grade,
profile.phone, profile.email, profile.status, user.active, profile.updated_at
FROM users user LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?`, [schoolId, schoolId]);
}
export async function synchronizeMysqlPartitions(connection) {
const now = new Date().toISOString();
const [exams] = await connection.query('SELECT id FROM exams ORDER BY id');
const [schools] = await connection.query('SELECT id FROM schools ORDER BY id');
for (const { id } of exams) {
const names = namesForExam(id);
await mysqlExamTables(connection, names);
await connection.execute(`INSERT INTO exam_data_partitions (
exam_id, partition_key, candidates_table, admissions_table, results_table, centers_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE partition_key = VALUES(partition_key),
candidates_table = VALUES(candidates_table), admissions_table = VALUES(admissions_table),
results_table = VALUES(results_table), centers_table = VALUES(centers_table), updated_at = VALUES(updated_at)`,
[id, names.key, names.candidates, names.admissions, names.results, names.centers, now, now]);
await syncMysqlExam(connection, id, names);
}
for (const { id } of schools) {
const names = namesForSchool(id);
await mysqlSchoolTable(connection, names);
await connection.execute(`INSERT INTO school_student_partitions (
school_id, partition_key, students_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE partition_key = VALUES(partition_key),
students_table = VALUES(students_table), updated_at = VALUES(updated_at)`,
[id, names.key, names.students, now, now]);
await syncMysqlSchool(connection, id, names);
}
}
+47
View File
@@ -34,6 +34,14 @@ export const sqliteSchema = `
UNIQUE (school_id, name)
) 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,
+9 -1
View File
@@ -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');
+43
View File
@@ -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, '只有校级管理员可以维护本校组织');