Files
Exam-Information-System/server.mjs
T
2026-07-20 16:44:49 +08:00

885 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer } from 'node:http';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize, resolve } from 'node:path';
import { loadEnvFile } from 'node:process';
import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto';
import { createDatabase } from './database.mjs';
import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs';
import { createAdminRoutes } from './src/routes/admin.routes.mjs';
import { createCandidateRoutes } from './src/routes/candidate.routes.mjs';
import { createAuthRoutes } from './src/routes/auth.routes.mjs';
import { createPublicRoutes } from './src/routes/public.routes.mjs';
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs';
import { createSessionManager } from './src/security/session.mjs';
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
import { createSeedDatabase } from './src/data/seed.mjs';
import { resolveRegion } from './src/data/region-service.mjs';
const root = resolve(process.cwd());
const envPath = join(root, '.env');
if (existsSync(envPath)) loadEnvFile(envPath);
const port = Number(process.env.PORT || 4173);
const host = process.env.HOST || '127.0.0.1';
const sessions = new Map();
const staticFiles = new Set([
'/index.html',
'/styles.css',
'/app.js',
'/src/client/api.mjs',
'/src/client/admin-views.mjs',
'/src/client/candidate-views.mjs',
'/src/client/public-views.mjs',
'/src/client/state.mjs',
'/src/client/ui.mjs',
'/src/client/region-select.mjs',
'/src/data/china-regions.mjs'
]);
const mimeTypes = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.svg': 'image/svg+xml'
};
function nowIso() {
return new Date().toISOString();
}
function uid(prefix) {
return `${prefix}_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`;
}
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
return `${salt}:${hash}`;
}
function verifyPassword(password, stored) {
const [salt, expected] = String(stored).split(':');
if (!salt || !expected) return false;
const actual = pbkdf2Sync(password, salt, 120000, 32, 'sha256');
const expectedBuffer = Buffer.from(expected, 'hex');
return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer);
}
const seedDatabase = () => createSeedDatabase({ nowIso, hashPassword });
const database = await createDatabase({ root, seed: seedDatabase });
const readDb = () => database.read();
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
const requirePermission = createPermissionGuard(sendError);
function adminsForStep(db, adminLevel, profile) {
return db.users.filter(item => {
if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false;
if (adminLevel === 'super') return true;
if (adminLevel === 'school') return Boolean(profile?.schoolId && item.schoolId === profile.schoolId);
if (adminLevel === 'class') return Boolean(
profile?.schoolId && profile?.classId
&& item.schoolId === profile.schoolId && item.classId === profile.classId
);
return false;
});
}
function selectAdminForStep(db, adminLevel, profile) {
const pendingByAdmin = new Map();
for (const instance of db.workflowInstances) {
if (instance.status !== 'pending' || !instance.assigneeId) continue;
pendingByAdmin.set(instance.assigneeId, (pendingByAdmin.get(instance.assigneeId) || 0) + 1);
}
const assignedByAdmin = new Map();
for (const action of db.workflowActions) {
if (!action.toAssigneeId) continue;
assignedByAdmin.set(action.toAssigneeId, (assignedByAdmin.get(action.toAssigneeId) || 0) + 1);
}
return adminsForStep(db, adminLevel, profile).sort((left, right) =>
(pendingByAdmin.get(left.id) || 0) - (pendingByAdmin.get(right.id) || 0)
|| (assignedByAdmin.get(left.id) || 0) - (assignedByAdmin.get(right.id) || 0)
|| String(left.createdAt || '').localeCompare(String(right.createdAt || ''))
|| left.id.localeCompare(right.id)
)[0] || null;
}
function activeWorkflow(db, businessType) {
return db.workflows.find(item => item.businessType === businessType && item.active);
}
function createWorkflowSubmission(db, businessType, businessId, profile, actorId = null) {
const workflow = activeWorkflow(db, businessType);
if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 });
const firstStep = workflow.steps[0];
const assignee = selectAdminForStep(db, firstStep.adminLevel, profile);
if (!assignee) throw Object.assign(new Error(`没有可承接“${firstStep.name}”的${adminLevelNames[firstStep.adminLevel]}`), { status: 409 });
const instance = {
id: uid('flow'), workflowId: workflow.id, businessType, businessId, status: 'pending', currentStep: 1,
assigneeId: assignee.id, createdAt: nowIso(), completedAt: null
};
const action = {
id: uid('flow_action'), instanceId: instance.id, actorId, action: 'submit', note: '提交审批',
fromAssigneeId: null, toAssigneeId: assignee.id, createdAt: nowIso()
};
return { workflow, instance, action };
}
function workflowView(db, instance) {
if (!instance) return null;
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const assignee = db.users.find(item => item.id === instance.assigneeId);
const actions = db.workflowActions.filter(item => item.instanceId === instance.id).map(item => ({
...item,
actorName: db.users.find(user => user.id === item.actorId)?.displayName || '系统',
fromAssigneeName: db.users.find(user => user.id === item.fromAssigneeId)?.displayName || '',
toAssigneeName: db.users.find(user => user.id === item.toAssigneeId)?.displayName || ''
}));
return {
...instance,
workflowName: workflow?.name || '未命名流程',
steps: workflow?.steps || [],
currentStepDetail: workflow?.steps.find(step => step.position === instance.currentStep) || null,
assignee: assignee ? safeUser(assignee) : null,
actions
};
}
function pendingWorkflow(db, businessType, businessId) {
return db.workflowInstances.find(item => item.businessType === businessType && item.businessId === businessId && item.status === 'pending');
}
function candidateSequence(db, rule, schoolId, year) {
const prefixParts = rule.segments.filter(item => item.type !== 'sequence').map(segment => segment.type === 'year' ? year : segment.type === 'school_code' ? db.schools.find(school => school.id === schoolId)?.code || '' : '').filter(Boolean);
const prefix = prefixParts.join(rule.separator);
return db.users.filter(item => item.role === 'candidate' && item.candidateNumber && (!prefix || item.candidateNumber.startsWith(prefix))).length + 1;
}
function generateCandidateNumber(db, profile, year = String(new Date().getFullYear())) {
const rule = db.numberRules.find(item => item.active);
if (!rule?.segments.length) throw Object.assign(new Error('尚未配置可用的报名号生成规则'), { status: 409 });
const school = db.schools.find(item => item.id === profile.schoolId);
const sequence = candidateSequence(db, rule, profile.schoolId, year);
const parts = rule.segments.map(segment => {
if (segment.type === 'year') return year.slice(-Math.max(2, segment.width || 4));
if (segment.type === 'school_code') return school?.code || 'NOSCHOOL';
if (segment.type === 'gender') return profile.gender === '男' ? 'M' : profile.gender === '女' ? 'F' : 'X';
if (segment.type === 'sequence') return String(sequence).padStart(Math.max(1, segment.width || 4), '0');
return cleanText(segment.value, 20).toUpperCase();
});
return { number: parts.join(rule.separator), ruleId: rule.id };
}
function cleanText(value, max = 200) {
return String(value ?? '').trim().slice(0, max);
}
function centerScopeProfile(db, schoolId) {
const school = db.schools.find(item => item.id === schoolId);
return { schoolId, classId: null, school: school?.name || '', grade: '' };
}
function workflowScopeProfile(db, instance) {
if (instance.businessType === 'profile_change') return db.candidateProfiles.find(item => item.id === instance.businessId) || null;
if (instance.businessType === 'registration_review') {
const registration = db.registrations.find(item => item.id === instance.businessId);
return db.candidateProfiles.find(item => item.userId === registration?.userId) || null;
}
if (instance.businessType === 'center_change') {
const change = db.centerChangeRequests.find(item => item.id === instance.businessId);
return change ? centerScopeProfile(db, change.schoolId) : null;
}
if (instance.businessType === 'candidate_account_batch') {
const batch = db.candidateAccountBatches.find(item => item.id === instance.businessId);
return batch ? centerScopeProfile(db, batch.schoolId) : null;
}
if (instance.businessType === 'score_appeal') {
const result = db.results.find(item => item.id === instance.businessId);
const registration = db.registrations.find(item => item.id === result?.registrationId);
return db.candidateProfiles.find(item => item.userId === registration?.userId) || null;
}
return null;
}
function candidateAccountBatchView(db, batch) {
const items = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position);
const quotaMap = new Map();
for (const item of items) quotaMap.set(item.classId, (quotaMap.get(item.classId) || 0) + 1);
const instance = db.workflowInstances.find(item => item.businessType === 'candidate_account_batch' && item.businessId === batch.id);
return {
...batch,
schoolName: db.schools.find(item => item.id === batch.schoolId)?.name || '',
requesterName: db.users.find(item => item.id === batch.requestedBy)?.displayName || '原提交人',
totalCount: items.length,
quotas: [...quotaMap.entries()].map(([classId, count]) => {
const schoolClass = db.classes.find(item => item.id === classId);
return { classId, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '', count };
}),
items: items.map(item => {
const schoolClass = db.classes.find(entry => entry.id === item.classId);
return { ...item, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '' };
}),
workflow: workflowView(db, instance)
};
}
function centerChangeView(db, change) {
const instance = db.workflowInstances.find(item => item.businessType === 'center_change' && item.businessId === change.id);
return {
...change,
schoolName: db.schools.find(item => item.id === change.schoolId)?.name || '',
rooms: db.centerChangeRooms.filter(item => item.requestId === change.id),
workflow: workflowView(db, instance)
};
}
function parseCenterChange(db, body, schoolId, center = null) {
const code = cleanText(body.code, 30).toUpperCase();
const name = cleanText(body.name, 100);
const address = cleanText(body.address, 200);
const region = resolveRegion(body);
const rooms = Array.isArray(body.rooms) ? body.rooms : [];
if (!code || !name || !address || !region) throw Object.assign(new Error('请填写考点代码、名称、省市区县和详细地址'), { status: 400 });
if (!rooms.length) throw Object.assign(new Error('请至少配置一个结构化考场'), { status: 400 });
const duplicateCenter = db.testCenters.some(item => item.code.toUpperCase() === code && item.id !== center?.id)
|| db.centerChangeRequests.some(item => item.status === 'pending' && item.code.toUpperCase() === code && item.centerId !== center?.id);
if (duplicateCenter) throw Object.assign(new Error('考点代码已被正式档案或待审批申请占用'), { status: 409 });
const roomCodes = new Set();
const normalizedRooms = rooms.map((room, index) => {
const roomCode = cleanText(room.code, 30).toUpperCase();
const roomName = cleanText(room.name, 80);
const building = cleanText(room.building, 80);
const capacity = Number(room.capacity);
if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1) {
throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋或容量无效`), { status: 400 });
}
if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 });
roomCodes.add(roomCode);
return {
id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName,
building, floor: cleanText(room.floor, 30), capacity, seatPlan: cleanText(room.seatPlan, 500), seatStart: 1, seatEnd: capacity,
roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard',
status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300)
};
});
return {
center: {
schoolId, code, name, ...region, address, contact: cleanText(body.contact, 80), managerName: cleanText(body.managerName, 50),
managerPhone: cleanText(body.managerPhone, 30), emergencyPhone: cleanText(body.emergencyPhone, 30),
gateOpenTime: cleanText(body.gateOpenTime, 20), transport: cleanText(body.transport, 500),
centerStatus: body.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(body.notes, 1000)
},
rooms: normalizedRooms
};
}
function maskId(value) {
const text = String(value || '');
return text.length > 8 ? `${text.slice(0, 4)}********${text.slice(-4)}` : text;
}
function publicExam(exam) {
const now = Date.now();
const start = new Date(exam.registrationStart).getTime();
const end = new Date(exam.registrationEnd).getTime();
return {
...exam,
totalScore: exam.subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0),
registrationState: now < start ? 'upcoming' : now > end ? 'closed' : 'open'
};
}
function subjectPassThreshold(subject) {
const rule = subject?.passRule || 'fixed_score';
if (rule !== 'fixed_score') return null;
const value = Number(subject?.passValue ?? subject?.passScore ?? 0);
return Number(value.toFixed(2));
}
function subjectPassText(subject) {
const rule = subject?.passRule || 'fixed_score';
if (rule === 'none') return '不设单科线';
if (rule === 'rank_percent') return `本科排名前 ${Number(subject.passValue ?? 60)}% 达线`;
return `固定 ${subjectPassThreshold(subject)} 分`;
}
function gradeForRank(rank, cohortSize) {
const cutoff = ratio => Math.max(1, Math.ceil(cohortSize * ratio));
if (rank <= cutoff(.1)) return 'A+';
if (rank <= cutoff(.25)) return 'A';
if (rank <= cutoff(.5)) return 'B+';
if (rank <= cutoff(.7)) return 'B';
if (rank <= cutoff(.9)) return 'C';
return 'D';
}
function resultRankInfo(db, result, scoreOverride = result?.score) {
if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' };
const score = Number(scoreOverride);
const peers = db.results.filter(item => item.id !== result.id && item.subjectId === result.subjectId && item.published);
const cohortSize = peers.length + 1;
const rank = 1 + peers.filter(item => Number(item.score) > score).length;
const rankPercent = Number((rank / cohortSize * 100).toFixed(2));
return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) };
}
function subjectPassEvaluation(db, result, subject, scoreOverride = result?.score) {
const rule = subject?.passRule || 'fixed_score';
if (rule === 'none') return { qualified: null, passScore: null, cutoffRank: null, ...resultRankInfo(db, result, scoreOverride) };
if (rule === 'rank_percent') {
const rankInfo = resultRankInfo(db, result, scoreOverride);
const cutoffRank = Math.max(1, Math.ceil(rankInfo.cohortSize * Number(subject.passValue ?? 60) / 100));
return { ...rankInfo, qualified: rankInfo.rank <= cutoffRank, passScore: null, cutoffRank };
}
const passScore = subjectPassThreshold(subject);
return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null };
}
function examResultSummary(db, registration) {
const exam = db.exams.find(item => item.id === registration.examId);
if (!exam) return null;
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
const published = db.results.filter(result => result.registrationId === registration.id && result.published);
const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
const scoreRatio = fullScore ? total / fullScore * 100 : 0;
const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent');
const value = Number(exam.passValue ?? 60);
let qualified = null;
let rank = null;
let cohortSize = null;
if (complete && policy === 'fixed_score') qualified = total >= value;
if (complete && policy === 'subject_scores') qualified = subjects.every(subject => {
const subjectResult = resultsBySubject.get(subject.id);
return subjectPassEvaluation(db, subjectResult, subject).qualified !== false;
});
if (complete && policy === 'none') qualified = null;
if (complete && policy === 'rank_percent') {
const subjectKey = [...registration.subjectIds].sort().join('|');
const totals = db.registrations
.filter(item => item.examId === exam.id && item.status === 'approved' && [...item.subjectIds].sort().join('|') === subjectKey)
.map(item => {
const itemResults = db.results.filter(result => result.registrationId === item.id && result.published);
if (!item.subjectIds.every(id => itemResults.some(result => result.subjectId === id))) return null;
return itemResults.filter(result => item.subjectIds.includes(result.subjectId)).reduce((sum, result) => sum + Number(result.score), 0);
})
.filter(item => item != null);
cohortSize = totals.length;
rank = 1 + totals.filter(item => item > total).length;
qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100));
}
return {
examId: exam.id,
complete,
publishedSubjects: published.length,
subjectCount: subjects.length,
total,
fullScore,
scoreRatio: Number(scoreRatio.toFixed(2)),
passPolicy: policy,
passValue: value,
qualified,
rank,
cohortSize
};
}
function examRegistrationView(db, registration) {
const exam = db.exams.find(item => item.id === registration.examId);
const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id));
const instance = db.workflowInstances.find(item => item.businessType === 'registration_review' && item.businessId === registration.id && item.status === 'pending')
|| db.workflowInstances.filter(item => item.businessType === 'registration_review' && item.businessId === registration.id)[0];
return { ...registration, exam, subjects, workflow: workflowView(db, instance) };
}
function logAction(db, user, action, detail) {
const log = { id: uid('log'), actorId: user.id, actorName: user.displayName, action, detail, createdAt: nowIso() };
db.auditLogs.unshift(log);
db.auditLogs = db.auditLogs.slice(0, 200);
return log;
}
const excelResourceNames = {
classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额',
account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账',
admit_cards: '准考证信息台账'
};
function admissionRowsForRegistrations(db, registrations) {
return registrations.flatMap(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {};
const account = db.users.find(item => item.id === registration.userId) || {};
const exam = db.exams.find(item => item.id === registration.examId) || { subjects: [] };
const school = db.schools.find(item => item.id === profile.schoolId);
const schoolClass = db.classes.find(item => item.id === profile.classId);
const card = registration.admitCard;
if (!card) return [];
const center = db.testCenters.find(item => item.id === card.centerId) || {};
return (card.assignments || []).map(assignment => {
const subject = exam.subjects.find(item => item.id === assignment.subjectId) || {};
const room = db.testRooms.find(item => item.id === assignment.roomId) || {};
return {
schoolName: school?.name || profile.school || '', className: schoolClass?.name || profile.grade || '',
candidateNumber: account.candidateNumber || registration.registrationNumber || '', candidateName: profile.name || account.displayName || '', idNumber: profile.idNumber || '',
examCode: exam.code || '', examName: exam.name || '', cardNumber: card.number || '',
centerCode: card.centerCode || center.code || '', centerName: card.testCenter || center.name || '',
centerAddress: card.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '),
subjectName: subject.name || '', subjectDate: subject.date || '', subjectTime: [subject.start, subject.end].filter(Boolean).join('—'),
examRoomCode: assignment.examRoomCode || '', roomName: assignment.roomName || assignment.room || room.name || '',
roomCode: assignment.roomCode || room.code || '', building: assignment.building || room.building || '',
floor: assignment.floor || room.floor || '', seat: assignment.seat || ''
};
});
});
}
function centerMaterialRows(db, schoolId, examId) {
const centerIds = new Set(db.testCenters.filter(item => item.schoolId === schoolId).map(item => item.id));
const registrations = db.registrations.filter(item => item.admitCard && item.examId === examId && centerIds.has(item.admitCard.centerId));
return admissionRowsForRegistrations(db, registrations);
}
function excelRowsForResource(db, user, resource, searchParams) {
const schools = user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId);
if (resource === 'classes') return db.classes.filter(item => schools.some(school => school.id === item.schoolId)).map(item => ({
schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', grade: item.grade, name: item.name, status: item.active ? '启用' : '停用'
}));
if (resource === 'class_admins') return db.users.filter(item => item.role === 'admin' && item.adminLevel === 'class' && schools.some(school => school.id === item.schoolId)).map(item => ({
schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '',
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', displayName: item.displayName,
username: item.username, initialPassword: '', status: item.active ? '启用' : '停用'
}));
if (resource === 'account_quotas') return db.classes.filter(item => item.active && schools.some(school => school.id === item.schoolId)).map(item => ({ className: item.name, count: 0 }));
if (resource === 'account_results') {
const batchId = cleanText(searchParams.get('batchId'), 64);
const batch = db.candidateAccountBatches.find(item => item.id === batchId && schools.some(school => school.id === item.schoolId));
if (!batch) throw Object.assign(new Error('批次不存在或不在当前学校范围内'), { status: 404 });
return db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position).map(item => ({
batchId: batch.id, className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', candidateNumber: item.candidateNumber, initialPassword: item.initialPassword
}));
}
if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({
candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender,
idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email,
nativePlace: profile.nativePlace, provinceCode: profile.provinceCode, provinceName: profile.provinceName,
cityCode: profile.cityCode, cityName: profile.cityName, districtCode: profile.districtCode, districtName: profile.districtName,
address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade,
ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone
}));
if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => {
const rooms = db.testRooms.filter(room => room.centerId === center.id);
return (rooms.length ? rooms : [{}]).map(room => ({
schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name,
provinceCode: center.provinceCode, provinceName: center.provinceName, cityCode: center.cityCode, cityName: center.cityName,
districtCode: center.districtCode, districtName: center.districtName, address: center.address,
managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact,
emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport,
centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes,
roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '',
seatPlan: room.seatPlan || '', roomType: ({ standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' })[room.roomType] || '',
roomStatus: room.status === 'inactive' ? '停用' : '启用', roomNotes: room.notes || ''
}));
});
if (resource === 'results') {
const examId = cleanText(searchParams.get('examId'), 64);
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId && (!examId || item.examId === examId))).map(result => {
const registration = db.registrations.find(item => item.id === result.registrationId);
const exam = db.exams.find(item => item.id === registration?.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
const account = db.users.find(item => item.id === registration?.userId);
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
const evaluation = subjectPassEvaluation(db, result, subject);
const rank = resultRankInfo(db, result);
return {
candidateNumber: account?.candidateNumber || '', candidateName: profile?.name || account?.displayName || '',
examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '',
fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation.passScore ?? '', score: result.score,
rank: rank.rank, rankPercent: rank.rankPercent,
qualified: evaluation.qualified == null ? '不判定' : evaluation.qualified ? '达线' : '未达线',
grade: result.published ? resultRankInfo(db, result).grade : '待发布', published: result.published ? '发布' : '不发布', updatedAt: result.updatedAt || result.publishedAt || ''
};
});
}
if (resource === 'admit_cards') {
const examId = cleanText(searchParams.get('examId'), 64);
const scoped = db.registrations.filter(item => item.admitCard && registrationInScope(db, user, item) && (!examId || item.examId === examId));
return admissionRowsForRegistrations(db, scoped);
}
return [];
}
function excelImportError(row, message) {
return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 });
}
function prepareResultImport(db, rows) {
const normalized = [];
const errors = [];
const seen = new Set();
for (const source of Array.isArray(rows) ? rows : []) {
const sourceRow = Number(source.__row || source.sourceRow || normalized.length + 3);
const candidateNumber = cleanText(source.candidateNumber, 120);
const examCode = cleanText(source.examCode, 60);
const subjectName = cleanText(source.subjectName, 50);
const account = db.users.find(item => item.candidateNumber === candidateNumber);
const exam = db.exams.find(item => item.code.toUpperCase() === examCode.toUpperCase());
const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved');
const subject = exam?.subjects.find(item => item.name.toLowerCase() === subjectName.toLowerCase());
const score = source.score == null || String(source.score).trim() === '' ? Number.NaN : Number(source.score);
const rowErrors = [];
if (!candidateNumber) rowErrors.push('报名号不能为空');
else if (!account) rowErrors.push('报名号不存在');
if (!examCode) rowErrors.push('考试代码不能为空');
else if (!exam) rowErrors.push('考试代码不存在');
if (!subjectName) rowErrors.push('科目不能为空');
else if (exam && !subject) rowErrors.push('该考试中不存在此科目');
if (exam && account && !registration) rowErrors.push('该考生没有已通过的本场考试报名');
if (registration && subject && !registration.subjectIds.includes(subject.id)) rowErrors.push('该考生未报考此科目');
if (!Number.isFinite(score) || score < 0 || (subject && score > Number(subject.fullScore))) rowErrors.push(`成绩须在 0—${subject?.fullScore ?? 0} 之间`);
const publishText = typeof source.published === 'boolean' ? (source.published ? '发布' : '不发布') : cleanText(source.published, 20) || '不发布';
if (!['发布', '不发布'].includes(publishText)) rowErrors.push('发布状态只能是“发布”或“不发布”');
const key = registration && subject ? `${registration.id}|${subject.id}` : `${candidateNumber}|${examCode}|${subjectName}`;
if (seen.has(key)) rowErrors.push('同一考生、考试和科目在文件中重复');
seen.add(key);
const existing = registration && subject ? db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id) : null;
const evaluation = subject ? subjectPassEvaluation(db, existing || { id: `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, subject, score) : null;
const profile = db.candidateProfiles.find(item => item.userId === account?.id);
const schoolClass = db.classes.find(item => item.id === profile?.classId);
const rank = subject && Number.isFinite(score) ? resultRankInfo(db, { id: existing?.id || `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, score) : null;
const row = {
sourceRow, candidateNumber, candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '',
examId: exam?.id || '', examCode, examName: exam?.name || '', registrationId: registration?.id || '',
subjectId: subject?.id || '', subjectName, fullScore: subject?.fullScore ?? null,
passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore ?? null,
passScore: evaluation?.passScore ?? null, passText: subject ? subjectPassText(subject) : '', score,
rank: rank?.rank ?? null, cohortSize: rank?.cohortSize ?? null, rankPercent: rank?.rankPercent ?? null,
qualified: !Number.isFinite(score) ? null : evaluation?.qualified ?? null,
grade: rank?.grade || '',
published: publishText === '发布', existingResultId: existing?.id || '', mode: existing ? 'update' : 'create', errors: rowErrors
};
normalized.push(row);
errors.push(...rowErrors.map(message => ({ row: sourceRow, message })));
}
const previewResults = db.results.map(item => ({ ...item }));
for (const row of normalized.filter(item => !item.errors.length)) {
const existingIndex = row.existingResultId ? previewResults.findIndex(item => item.id === row.existingResultId) : -1;
const previewResult = {
...(existingIndex >= 0 ? previewResults[existingIndex] : {}),
id: row.existingResultId || `preview-${row.sourceRow}`,
registrationId: row.registrationId,
subjectId: row.subjectId,
score: row.score,
published: row.published
};
if (existingIndex >= 0) previewResults[existingIndex] = previewResult;
else previewResults.push(previewResult);
}
const previewDb = { ...db, results: previewResults };
for (const row of normalized.filter(item => !item.errors.length)) {
const previewResult = previewResults.find(item => item.id === (row.existingResultId || `preview-${row.sourceRow}`));
const subject = db.exams.find(item => item.id === row.examId)?.subjects.find(item => item.id === row.subjectId);
const rank = resultRankInfo(previewDb, previewResult, row.score);
const evaluation = subjectPassEvaluation(previewDb, previewResult, subject, row.score);
Object.assign(row, { rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent, grade: row.published ? rank.grade : '待发布', passScore: evaluation.passScore, cutoffRank: evaluation.cutoffRank, qualified: evaluation.qualified });
}
return {
rows: normalized,
errors,
summary: {
total: normalized.length,
valid: normalized.filter(item => !item.errors.length).length,
invalid: normalized.filter(item => item.errors.length).length,
create: normalized.filter(item => !item.errors.length && item.mode === 'create').length,
update: normalized.filter(item => !item.errors.length && item.mode === 'update').length,
publish: normalized.filter(item => !item.errors.length && item.published).length
}
};
}
async function commitResultImport(db, user, rows) {
if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以批量提交成绩'), { status: 403 });
const prepared = prepareResultImport(db, rows);
if (prepared.errors.length) {
const first = prepared.errors[0];
throw Object.assign(new Error(`第 ${first.row} 行:${first.message};请返回预览修正后重试`), { status: 400 });
}
const entries = prepared.rows.map(row => {
const existing = row.existingResultId ? db.results.find(item => item.id === row.existingResultId) : null;
const result = existing || { id: uid('result'), registrationId: row.registrationId, subjectId: row.subjectId };
Object.assign(result, {
score: row.score, grade: row.grade, published: row.published, updatedAt: nowIso(),
publishedAt: row.published ? (existing?.publishedAt || nowIso()) : null
});
const log = logAction(db, user, row.published ? 'Excel 批量发布成绩' : 'Excel 批量保存成绩', `${row.candidateNumber} · ${row.examName} · ${row.subjectName} · ${row.score}`);
return { result, isNew: !existing, log };
});
await database.saveResults(entries);
for (const entry of entries) if (entry.isNew) db.results.push(entry.result);
return { count: entries.length, summary: prepared.summary };
}
async function importExcelResource(db, user, resource, rows) {
if (resource === 'classes') {
if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 });
for (const row of rows) {
const school = db.schools.find(item => item.code.toUpperCase() === String(row.schoolCode).toUpperCase());
if (!school || (user.adminLevel === 'school' && school.id !== user.schoolId)) throw excelImportError(row, '学校代码无效或不在管理范围内');
const name = cleanText(row.name, 100); const grade = cleanText(row.grade, 60);
if (!name || !grade) throw excelImportError(row, '年级和班级名称不能为空');
const existing = db.classes.find(item => item.schoolId === school.id && item.name === name);
const schoolClass = existing || { id: uid('class'), schoolId: school.id };
Object.assign(schoolClass, { name, grade, active: row.status !== '停用' });
await database.saveSchoolClass(schoolClass, !existing, logAction(db, user, existing ? 'Excel 更新班级' : 'Excel 新增班级', `${school.name} · ${name}`));
if (!existing) db.classes.push(schoolClass);
}
return { count: rows.length };
}
if (resource === 'class_admins') {
if (user.adminLevel !== 'school') throw Object.assign(new Error('班级管理员 Excel 导入由校级管理员执行'), { status: 403 });
for (const row of rows) {
const school = db.schools.find(item => item.id === user.schoolId && item.code.toUpperCase() === String(row.schoolCode).toUpperCase());
const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100));
if (!school || !schoolClass) throw excelImportError(row, '学校代码或班级名称无效');
const username = cleanText(row.username, 50); const displayName = cleanText(row.displayName, 50); const password = String(row.initialPassword || '');
if (!username || !displayName) throw excelImportError(row, '管理员姓名和登录账号不能为空');
const existing = db.users.find(item => item.username.toLowerCase() === username.toLowerCase());
if (existing && (existing.adminLevel !== 'class' || existing.schoolId !== user.schoolId)) throw excelImportError(row, '登录账号已被其他用户占用');
if (!existing && password.length < 8) throw excelImportError(row, '新建管理员的初始密码至少 8 位');
if (existing) {
Object.assign(existing, { displayName, classId: schoolClass.id, active: row.status !== '停用' });
if (password) existing.passwordHash = hashPassword(password);
await database.updateAdmin(existing, Boolean(password), logAction(db, user, 'Excel 更新班级管理员', `${displayName} · ${schoolClass.name}`));
} else {
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel: 'class', schoolId: user.schoolId, classId: schoolClass.id, displayName, active: row.status !== '停用', createdAt: nowIso() };
await database.createAdmin(created, logAction(db, user, 'Excel 创建班级管理员', `${displayName} · ${schoolClass.name}`));
db.users.push(created);
}
}
return { count: rows.length };
}
if (resource === 'account_quotas') {
if (user.adminLevel !== 'school') throw Object.assign(new Error('班级配额模板仅供校级管理员使用'), { status: 403 });
const quotas = rows.filter(row => Number(row.count) > 0).map(row => {
const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100) && item.active);
if (!schoolClass || !Number.isInteger(Number(row.count)) || Number(row.count) < 1 || Number(row.count) > 200) throw excelImportError(row, '班级不存在,或申领数量不在 1—200 之间');
return { classId: schoolClass.id, className: schoolClass.name, count: Number(row.count) };
});
if (!quotas.length) throw Object.assign(new Error('模板中没有大于 0 的申领数量'), { status: 400 });
return { count: quotas.length, quotas };
}
if (resource === 'candidates') {
if (!hasPermission(user, 'candidates.write')) throw Object.assign(new Error('当前账号不能导入考生资料'), { status: 403 });
for (const row of rows) {
const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120));
const profile = db.candidateProfiles.find(item => item.userId === account?.id);
if (!account || !profile || !profileInScope(user, profile)) throw excelImportError(row, '报名号不存在或不在数据范围内');
if (pendingWorkflow(db, 'profile_change', profile.id)) throw excelImportError(row, '该考生已有待审批资料流程');
const schoolClass = db.classes.find(item => item.schoolId === profile.schoolId && item.name === cleanText(row.className, 100));
if (!schoolClass) throw excelImportError(row, '班级名称无效');
const required = ['name', 'gender', 'idNumber', 'phone'];
if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填');
const region = resolveRegion(row);
if (!region) throw excelImportError(row, '省、市或区县代码无效,或上下级不匹配');
Object.assign(profile, {
name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30),
email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), ...region,
address: cleanText(row.address, 200), classId: schoolClass.id,
grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20),
guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso()
});
const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id);
await database.updateCandidateProfile(profile, profile.name, instance, action);
}
return { count: rows.length };
}
if (resource === 'centers') {
if (!hasPermission(user, 'centers.write')) throw Object.assign(new Error('当前账号不能导入考点考场'), { status: 403 });
const groups = Map.groupBy(rows, row => cleanText(row.centerCode, 30).toUpperCase());
for (const [centerCode, centerRows] of groups) {
const first = centerRows[0];
const school = db.schools.find(item => item.code.toUpperCase() === String(first.schoolCode).toUpperCase() && (user.adminLevel === 'super' || item.id === user.schoolId));
if (!school || !centerCode) throw excelImportError(first, '学校代码或考点代码无效');
const existing = db.testCenters.find(item => item.code.toUpperCase() === centerCode);
if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校');
if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更');
const body = {
schoolId: school.id, code: centerCode, name: first.centerName,
provinceCode: first.provinceCode, cityCode: first.cityCode, districtCode: first.districtCode,
address: first.address, managerName: first.managerName,
managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime,
transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes,
rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan,
roomType: ({ 标准考场: 'standard', 机考考场: 'computer', 无障碍考场: 'accessible', 备用考场: 'spare' })[row.roomType] || row.roomType,
status: row.roomStatus === '停用' ? 'inactive' : 'active', notes: row.roomNotes }))
};
const parsed = parseCenterChange(db, body, school.id, existing || null);
const change = { id: uid('center_change'), centerId: existing?.id || null, schoolId: school.id, requestType: existing ? 'update' : 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, school.id), user.id);
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, logAction(db, user, 'Excel 提交考点考场审批', `${change.name} · ${parsed.rooms.length} 个考场`));
}
return { count: groups.size };
}
if (resource === 'results') {
return commitResultImport(db, user, rows);
}
throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 });
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));
}
function admitCardSection(db, user, profile, registration) {
const exam = db.exams.find(item => item.id === registration.examId);
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
const assignments = new Map((registration.admitCard.assignments || []).map(item => [item.subjectId, item]));
const center = db.testCenters.find(item => item.id === registration.admitCard.centerId) || {};
const centerCode = registration.admitCard.centerCode || center.code || '';
const centerAddress = registration.admitCard.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' ');
const rows = subjects.map(subject => {
const assignment = assignments.get(subject.id) || {};
const room = db.testRooms.find(item => item.id === assignment.roomId) || {};
return `<tr><td>${escapeHtml(subject.name)}</td><td>${escapeHtml(subject.date)}</td><td>${escapeHtml(subject.start)}${escapeHtml(subject.end)}</td><td class="room-no">${escapeHtml(assignment.examRoomCode || '待定')}</td><td><strong>${escapeHtml(assignment.roomName || assignment.room || room.name || '待定')}</strong><small>场地代码:${escapeHtml(assignment.roomCode || room.code || '—')}</small></td><td>${escapeHtml(assignment.building || room.building || '—')}<small>${escapeHtml(assignment.floor || room.floor || '楼层待定')}</small></td><td>${escapeHtml(assignment.seat || '—')}</td></tr>`;
}).join('');
return `<main class="card"><header class="head"><div><div class="brand">衡准 · 准考证</div><p>${escapeHtml(exam.name)}</p></div><div><small>准考证号</small><div class="code">${escapeHtml(registration.admitCard.number)}</div></div></header><section class="meta"><div><span>姓名</span><strong>${escapeHtml(profile.name || user.displayName)}</strong></div><div><span>证件号码</span><strong>${escapeHtml(maskId(profile.idNumber))}</strong></div><div><span>报名号</span><strong>${escapeHtml(user.candidateNumber || registration.registrationNumber || '—')}</strong></div></section><section class="center"><div><span>固定考点</span><strong>${escapeHtml(registration.admitCard.testCenter)}</strong><small>考点代码:${escapeHtml(centerCode || '—')}</small></div><div><span>考点详细地址</span><strong>${escapeHtml(centerAddress || '地址待公布')}</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考试考场序号</th><th>考场通用名称 / 场地代码</th><th>楼栋 / 楼层</th><th>座位号</th></tr></thead><tbody>${rows}</tbody></table><div class="explain"><strong>编号说明</strong>“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。</div><div class="notice">所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。</div><footer class="footer"><span>${escapeHtml(db.organization.name)}</span><span>生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}</span></footer></main>`;
}
function admitCardsDocument(db, items, title) {
const cards = items.map(item => admitCardSection(db, item.user, item.profile, item.registration)).join('');
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>@page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{font-family:"Microsoft YaHei",sans-serif;color:#182552;margin:0;background:#f2f4f8}.card{max-width:1120px;min-height:720px;margin:24px auto;background:#fff;border:2px solid #182552;padding:26px;box-shadow:0 12px 35px #18255218;break-after:page}.card:last-child{break-after:auto}.head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px double #182552;padding-bottom:14px}.head>div:last-child{text-align:right}.head small{color:#64748b}.brand{font-size:27px;font-weight:800}.head p{margin:6px 0}.code{font:700 22px Consolas,monospace;margin-top:5px}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.meta div,.center>div{border:1px solid #d9e0e7;padding:9px 12px}.meta span,.center span{display:block;color:#64748b;font-size:11px;margin-bottom:4px}.center{display:grid;grid-template-columns:1fr 1.5fr;gap:10px;margin-bottom:16px}.center small,td small{display:block;color:#64748b;margin-top:4px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{border:1px solid #b9c5d2;padding:9px;text-align:left;vertical-align:middle}th{background:#eaf1f5}.room-no{font:700 15px Consolas,monospace;text-align:center}.explain,.notice{margin-top:12px;padding:10px 12px;font-size:12px}.explain{background:#eaf4f6;color:#245365}.explain strong{margin-right:8px}.notice{background:#fff7e6;color:#704b12}.footer{display:flex;justify-content:space-between;margin-top:14px;color:#64748b;font-size:11px}@media print{body{background:#fff}.card{margin:0;box-shadow:none}}</style></head><body>${cards}</body></html>`;
}
function admitCardHtml(db, user, profile, registration) {
const exam = db.exams.find(item => item.id === registration.examId);
return admitCardsDocument(db, [{ user, profile, registration }], `${exam.name}-${profile.name}-准考证`);
}
function admitCardsHtml(db, registrations, title) {
const items = registrations.map(registration => ({
registration,
user: db.users.find(item => item.id === registration.userId) || {},
profile: db.candidateProfiles.find(item => item.userId === registration.userId) || {}
}));
return admitCardsDocument(db, items, title);
}
const routeContext = {
database,
readDb,
sendJson,
sendError,
readJson,
readBodyBuffer,
sendWorkbook,
currentUser,
parseCookies,
safeUser,
requireUser,
hasPermission,
requirePermission,
profileInScope,
registrationInScope,
adminScopeLabel,
adminsForStep,
selectAdminForStep,
activeWorkflow,
createWorkflowSubmission,
workflowView,
pendingWorkflow,
candidateSequence,
generateCandidateNumber,
cleanText,
centerScopeProfile,
workflowScopeProfile,
candidateAccountBatchView,
centerChangeView,
parseCenterChange,
maskId,
publicExam,
examRegistrationView,
examResultSummary,
subjectPassText,
subjectPassEvaluation,
resultRankInfo,
logAction,
excelResourceNames,
excelRowsForResource,
admissionRowsForRegistrations,
centerMaterialRows,
importExcelResource,
prepareResultImport,
commitResultImport,
admitCardHtml,
admitCardsHtml,
hashPassword,
verifyPassword,
randomBytes,
uid,
nowIso,
sessions,
buildWorkbook,
buildCenterMaterialsWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames,
permissionsByLevel,
resolveRegion
};
const handlePublic = createPublicRoutes(routeContext);
const handleAuth = createAuthRoutes(routeContext);
const handleCandidate = createCandidateRoutes(routeContext);
const handleAdmin = createAdminRoutes(routeContext);
async function serveStatic(response, pathname) {
const requestPath = pathname === '/' ? '/index.html' : pathname;
if (!staticFiles.has(requestPath)) return false;
const filePath = normalize(join(root, requestPath.replace(/^\/+/, '')));
const body = await readFile(filePath);
response.writeHead(200, { 'Content-Type': mimeTypes[extname(filePath)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
response.end(body);
return true;
}
const server = createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
const pathname = decodeURIComponent(url.pathname);
try {
if (pathname.startsWith('/api/public/')) {
const handled = await handlePublic(pathname, response);
if (handled !== false) return;
}
const authHandled = await handleAuth(request, response, pathname);
if (authHandled !== false) return;
const candidateHandled = await handleCandidate(request, response, pathname);
if (candidateHandled !== false) return;
const adminHandled = await handleAdmin(request, response, pathname);
if (adminHandled !== false) return;
if (await serveStatic(response, pathname)) return;
sendError(response, 404, '页面或接口不存在');
} catch (error) {
console.error(error);
sendError(response, error.status || 500, error.status ? error.message : '服务器处理请求时发生错误');
}
});
server.listen(port, host, () => {
console.log(`衡准考试信息管理系统:http://${host}:${port}`);
console.log(`数据库:${database.client}${database.location}`);
});
async function shutdown(signal) {
console.log(`收到 ${signal},正在关闭服务...`);
server.close(async () => {
await database.close();
process.exit(0);
});
}
process.once('SIGINT', () => shutdown('SIGINT'));
process.once('SIGTERM', () => shutdown('SIGTERM'));