import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize, resolve } from 'node:path';
import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto';
import { createDatabase } from './database.mjs';
const root = resolve(process.cwd());
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']);
const mimeTypes = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': '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);
}
function seedDatabase() {
const adminId = 'usr_admin';
const candidateId = 'usr_demo';
const examId = 'exam_autumn_2026';
const registrationId = 'reg_demo_2026';
return {
meta: { version: 1, createdAt: nowIso() },
organization: {
name: '海州市教育考试中心',
code: 'HZ-EDU-032',
phone: '0518-8602 3158',
address: '海州市清河区文教路 18 号'
},
users: [
{ id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', displayName: '林老师', createdAt: nowIso() },
{ id: candidateId, username: '13800138000', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', createdAt: nowIso() }
],
candidateProfiles: [
{
id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821',
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班',
address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000',
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
}
],
notices: [
{ id: 'notice_1', title: '2026 年秋季统一考试报名安排', summary: '报名时间为 7 月 1 日至 7 月 31 日,请考生完成实名认证后选报科目。', content: '2026 年秋季统一考试报名现已开放。考生须在规定时间内登录平台,核对个人信息并选择报考科目。逾期不再补报。', category: '报名通知', pinned: true, status: 'published', publishAt: '2026-07-01T01:00:00.000Z', author: '考试中心' },
{ id: 'notice_2', title: '准考证下载与考场规则说明', summary: '准考证开放下载后,请使用 A4 纸打印并妥善保管。', content: '准考证下载时间为 7 月 20 日至 8 月 16 日。考生须携带身份证和纸质准考证入场,开考 15 分钟后不得进入考点。', category: '考试须知', pinned: false, status: 'published', publishAt: '2026-07-15T02:30:00.000Z', author: '考试中心' },
{ id: 'notice_3', title: '市第三中学考点交通提示', summary: '考试期间考点周边实行临时交通管制,请提前规划路线。', content: '建议考生至少提前 50 分钟到达考点。考点不提供停车位,请优先选择公共交通出行。', category: '考点公告', pinned: false, status: 'published', publishAt: '2026-07-18T06:00:00.000Z', author: '考务组' }
],
exams: [
{
id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。',
registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z',
examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z',
admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z',
location: '海州市各指定考点', status: 'published', createdAt: '2026-06-18T02:00:00.000Z',
subjects: [
{ id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30 },
{ id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30 },
{ id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 },
{ id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 },
{ id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30 },
{ id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25 },
{ id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25 }
]
},
{
id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。',
registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z',
examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z',
admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z',
location: '考点待公布', status: 'draft', createdAt: nowIso(), subjects: []
}
],
registrations: [
{
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z',
admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' }
}
],
results: [
{ id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' },
{ id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }
],
auditLogs: [
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
]
};
}
const database = await createDatabase({ root, seed: seedDatabase });
const readDb = () => database.read();
function sendJson(response, status, payload, headers = {}) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
response.end(JSON.stringify(payload));
}
function sendError(response, status, message, details) {
sendJson(response, status, { ok: false, message, ...(details ? { details } : {}) });
}
async function readJson(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 1024 * 1024) throw Object.assign(new Error('请求内容过大'), { status: 413 });
chunks.push(chunk);
}
if (!chunks.length) return {};
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw Object.assign(new Error('请求数据格式不正确'), { status: 400 });
}
}
function parseCookies(request) {
return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => {
const index = part.indexOf('=');
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))];
}));
}
async function currentUser(request) {
const token = parseCookies(request).hz_session;
const session = token && sessions.get(token);
if (!session || session.expiresAt < Date.now()) {
if (token) sessions.delete(token);
return null;
}
const db = await readDb();
return db.users.find(user => user.id === session.userId) || null;
}
function safeUser(user) {
return { id: user.id, username: user.username, role: user.role, displayName: user.displayName };
}
async function requireUser(request, response, role) {
const user = await currentUser(request);
if (!user) {
sendError(response, 401, '请先登录');
return null;
}
if (role && user.role !== role) {
sendError(response, 403, '当前账号无权执行此操作');
return null;
}
return user;
}
function cleanText(value, max = 200) {
return String(value ?? '').trim().slice(0, max);
}
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,
registrationState: now < start ? 'upcoming' : now > end ? 'closed' : 'open'
};
}
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));
return { ...registration, exam, subjects };
}
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;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
}
function admitCardHtml(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 rows = subjects.map(subject => `
| ${escapeHtml(subject.name)} | ${escapeHtml(subject.date)} | ${escapeHtml(subject.start)}—${escapeHtml(subject.end)} | ${escapeHtml(registration.admitCard.room)} |
`).join('');
return `${escapeHtml(exam.name)}准考证考试须知:请携带本人有效身份证件及本准考证,至少提前 40 分钟到达考点。严禁携带手机、智能手表等通讯设备进入考场。
`;
}
async function handlePublic(pathname, response) {
const db = await readDb();
if (pathname === '/api/public/home') {
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
return sendJson(response, 200, { ok: true, organization: db.organization, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } });
}
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
if (noticeMatch) {
const notice = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
}
return false;
}
async function handleAuth(request, response, pathname) {
if (request.method === 'GET' && pathname === '/api/auth/me') {
const user = await currentUser(request);
if (!user) return sendJson(response, 200, { ok: true, user: null });
const db = await readDb();
const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null;
return sendJson(response, 200, { ok: true, user: safeUser(user), profile });
}
if (request.method === 'POST' && pathname === '/api/auth/register') {
const body = await readJson(request);
const username = cleanText(body.username, 50);
const password = String(body.password || '');
const name = cleanText(body.name, 30);
const idNumber = cleanText(body.idNumber, 30);
const phone = cleanText(body.phone, 30);
if (!username || !name || !idNumber || !phone) return sendError(response, 400, '请完整填写账号和身份信息');
if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位');
const db = await readDb();
if (db.users.some(user => user.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该账号已注册');
if (db.candidateProfiles.some(profile => profile.idNumber === idNumber)) return sendError(response, 409, '该证件号码已注册');
const user = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'candidate', displayName: name, createdAt: nowIso() };
const profile = { id: uid('profile'), userId: user.id, name, idNumber, phone, gender: cleanText(body.gender, 10), email: cleanText(body.email, 80), school: cleanText(body.school, 80), grade: cleanText(body.grade, 50), address: '', emergencyContact: '', emergencyPhone: '', status: 'pending', reviewNote: '', updatedAt: nowIso() };
await database.createCandidate(user, profile);
return sendJson(response, 201, { ok: true, message: '注册成功,请等待管理员审核资料' });
}
if (request.method === 'POST' && pathname === '/api/auth/login') {
const body = await readJson(request);
const db = await readDb();
const user = db.users.find(item => item.username.toLowerCase() === cleanText(body.username, 50).toLowerCase());
if (!user || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
const token = randomBytes(32).toString('hex');
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` });
}
if (request.method === 'POST' && pathname === '/api/auth/logout') {
const token = parseCookies(request).hz_session;
if (token) sessions.delete(token);
return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' });
}
return false;
}
async function handleCandidate(request, response, pathname) {
if (!pathname.startsWith('/api/candidate/')) return false;
const user = await requireUser(request, response, 'candidate');
if (!user) return true;
const db = await readDb();
const profile = db.candidateProfiles.find(item => item.userId === user.id);
if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5);
return sendJson(response, 200, { ok: true, profile, registrations, results, notices });
}
if (request.method === 'GET' && pathname === '/api/candidate/profile') return sendJson(response, 200, { ok: true, profile });
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
const body = await readJson(request);
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'school', 'grade', 'address', 'emergencyContact', 'emergencyPhone'];
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
if (!profile.name || !profile.idNumber || !profile.phone || !profile.school) return sendError(response, 400, '姓名、证件号码、手机号和学校为必填项');
if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用');
profile.status = 'pending';
profile.reviewNote = '';
profile.updatedAt = nowIso();
await database.updateCandidateProfile(profile, profile.name);
return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' });
}
if (request.method === 'GET' && pathname === '/api/candidate/exams') {
const registrations = db.registrations.filter(item => item.userId === user.id);
const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registration: registrations.find(reg => reg.examId === exam.id) || null }));
return sendJson(response, 200, { ok: true, profileStatus: profile.status, exams });
}
if (request.method === 'GET' && pathname === '/api/candidate/registrations') {
return sendJson(response, 200, { ok: true, registrations: db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)) });
}
if (request.method === 'POST' && pathname === '/api/candidate/registrations') {
if (profile.status !== 'approved') return sendError(response, 403, '个人资料审核通过后才能报名考试');
const body = await readJson(request);
const exam = db.exams.find(item => item.id === body.examId && item.status === 'published');
if (!exam) return sendError(response, 404, '考试不存在或尚未发布');
const state = publicExam(exam).registrationState;
if (state !== 'open') return sendError(response, 400, state === 'upcoming' ? '报名尚未开始' : '报名已经截止');
if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试');
const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])];
if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目');
const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), admitCard: null };
await database.createRegistration(registration);
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
}
if (request.method === 'GET' && pathname === '/api/candidate/results') {
const registrations = db.registrations.filter(item => item.userId === user.id);
const results = db.results.filter(item => item.published && registrations.some(reg => reg.id === item.registrationId)).map(result => {
const registration = registrations.find(reg => reg.id === result.registrationId);
const exam = db.exams.find(item => item.id === registration.examId);
const subject = exam.subjects.find(item => item.id === result.subjectId);
return { ...result, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId };
});
return sendJson(response, 200, { ok: true, results });
}
const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/);
if (request.method === 'GET' && admitMatch) {
const registration = db.registrations.find(item => item.id === admitMatch[1] && item.userId === user.id);
if (!registration || !registration.admitCard) return sendError(response, 404, '准考证尚未生成');
const exam = db.exams.find(item => item.id === registration.examId);
const now = Date.now();
if (now < new Date(exam.admitDownloadStart).getTime()) return sendError(response, 403, '准考证下载尚未开放');
if (now > new Date(exam.admitDownloadEnd).getTime()) return sendError(response, 403, '准考证下载时间已结束');
const html = admitCardHtml(db, user, profile, registration);
const filename = encodeURIComponent(`${exam.name}-${profile.name}-准考证.html`);
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename*=UTF-8''${filename}`, 'Cache-Control': 'no-store' });
response.end(html);
return true;
}
return sendError(response, 404, '考生功能接口不存在');
}
async function handleAdmin(request, response, pathname) {
if (!pathname.startsWith('/api/admin/')) return false;
const user = await requireUser(request, response, 'admin');
if (!user) return true;
const db = await readDb();
if (request.method === 'GET' && pathname === '/api/admin/dashboard') {
const pendingCandidates = db.candidateProfiles.filter(item => item.status === 'pending').length;
const pendingRegistrations = db.registrations.filter(item => item.status === 'pending').length;
return sendJson(response, 200, { ok: true, metrics: { candidates: db.candidateProfiles.length, pendingCandidates, registrations: db.registrations.length, pendingRegistrations, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length }, logs: db.auditLogs.slice(0, 8) });
}
if (request.method === 'GET' && pathname === '/api/admin/candidates') {
const candidates = db.candidateProfiles.map(profile => ({ ...profile, idNumberMasked: maskId(profile.idNumber), username: db.users.find(item => item.id === profile.userId)?.username }));
return sendJson(response, 200, { ok: true, candidates });
}
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
if (request.method === 'PATCH' && candidateMatch) {
const body = await readJson(request);
const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]);
if (!profile) return sendError(response, 404, '考生资料不存在');
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
profile.status = body.status;
profile.reviewNote = cleanText(body.reviewNote, 300);
profile.reviewedAt = nowIso();
profile.reviewerId = user.id;
const log = logAction(db, user, body.status === 'approved' ? '通过考生资料' : '退回考生资料', `${profile.name}:${profile.reviewNote || '无备注'}`);
await database.reviewCandidate(profile, log);
return sendJson(response, 200, { ok: true, profile });
}
if (request.method === 'GET' && pathname === '/api/admin/registrations') {
const registrations = db.registrations.map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
});
return sendJson(response, 200, { ok: true, registrations });
}
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
if (request.method === 'PATCH' && registrationMatch) {
const body = await readJson(request);
const registration = db.registrations.find(item => item.id === registrationMatch[1]);
if (!registration) return sendError(response, 404, '报名记录不存在');
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
registration.status = body.status;
registration.reviewNote = cleanText(body.reviewNote, 300);
registration.reviewedAt = nowIso();
if (body.status === 'approved') registration.paymentStatus = 'paid';
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const log = logAction(db, user, body.status === 'approved' ? '通过考试报名' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`);
await database.reviewRegistration(registration, log);
return sendJson(response, 200, { ok: true, registration });
}
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
if (request.method === 'POST' && admitMatch) {
const registration = db.registrations.find(item => item.id === admitMatch[1]);
if (!registration) return sendError(response, 404, '报名记录不存在');
if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证');
if (!registration.admitCard) {
const exam = db.exams.find(item => item.id === registration.examId);
const sequence = String(db.registrations.filter(item => item.examId === exam.id && item.admitCard).length + 1).padStart(4, '0');
registration.admitCard = {
number: `${exam.code.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-12) || String(new Date().getFullYear())}-${sequence}`,
testCenter: cleanText((await readJson(request)).testCenter || '海州市第一中学', 80),
room: `0${Math.ceil(Number(sequence) / 30) || 1} 考场`,
seat: String(((Number(sequence) - 1) % 30) + 1).padStart(2, '0'),
generatedAt: nowIso()
};
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const log = logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`);
await database.createAdmitCard(registration.id, registration.admitCard, log);
}
return sendJson(response, 200, { ok: true, admitCard: registration.admitCard });
}
if (request.method === 'GET' && pathname === '/api/admin/exams') return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) });
if (request.method === 'POST' && pathname === '/api/admin/exams') {
const body = await readJson(request);
const name = cleanText(body.name, 100);
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,,]/);
const subjects = subjectNames.map(name => cleanText(typeof name === 'string' ? name : name.name, 30)).filter(Boolean).map((name, index) => ({ id: uid('sub'), name, date: cleanText(body.examStart, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
if (!subjects.length) return sendError(response, 400, '请至少添加一个考试科目');
const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() };
const log = logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`);
await database.createExam(exam, log);
return sendJson(response, 201, { ok: true, exam });
}
const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/);
if (request.method === 'PATCH' && examMatch) {
const body = await readJson(request);
const exam = db.exams.find(item => item.id === examMatch[1]);
if (!exam) return sendError(response, 404, '考试不存在');
const originalStatus = exam.status;
const detailFields = ['code', 'name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'];
const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null;
if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑');
if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status;
detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); });
let replaceSubjects = false;
if (body.subjects != null) {
if (db.registrations.some(registration => registration.examId === exam.id)) return sendError(response, 409, '已有报名记录,不能修改考试科目');
const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,,]/);
const names = subjectNames.map(item => cleanText(typeof item === 'string' ? item : item.name, 30)).filter(Boolean);
if (!names.length) return sendError(response, 400, '请至少添加一个考试科目');
exam.subjects = names.map((name, index) => ({ id: uid('sub'), name, date: String(exam.examStart).slice(0, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
replaceSubjects = true;
}
if (!exam.name || !exam.registrationStart || !exam.registrationEnd || !exam.examStart || !exam.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
if (exam.status === 'published' && !exam.subjects.length) return sendError(response, 400, '请先配置考试科目再发布');
const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`);
await database.updateExam(exam, log, replaceSubjects);
return sendJson(response, 200, { ok: true, exam });
}
if (request.method === 'GET' && pathname === '/api/admin/notices') return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) });
if (request.method === 'POST' && pathname === '/api/admin/notices') {
const body = await readJson(request);
const title = cleanText(body.title, 120);
const content = cleanText(body.content, 5000);
if (!title || !content) return sendError(response, 400, '通知标题和正文不能为空');
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || content.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
await database.createNotice(notice, log);
return sendJson(response, 201, { ok: true, notice });
}
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
if (request.method === 'PATCH' && noticeMatch) {
const body = await readJson(request);
const notice = db.notices.find(item => item.id === noticeMatch[1]);
if (!notice) return sendError(response, 404, '通知不存在');
['title', 'summary', 'content', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], field === 'content' ? 5000 : 260); });
if (body.pinned != null) notice.pinned = Boolean(body.pinned);
if (body.status && ['draft', 'published'].includes(body.status)) {
notice.status = body.status;
if (body.status === 'published' && !notice.publishAt) notice.publishAt = nowIso();
}
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
await database.updateNotice(notice, log);
return sendJson(response, 200, { ok: true, notice });
}
if (request.method === 'GET' && pathname === '/api/admin/results') {
const results = db.results.map(result => {
const registration = db.registrations.find(item => item.id === result.registrationId);
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
const exam = db.exams.find(item => item.id === registration?.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name };
});
return sendJson(response, 200, { ok: true, results, registrations: db.registrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) });
}
if (request.method === 'POST' && pathname === '/api/admin/results') {
const body = await readJson(request);
const registration = db.registrations.find(item => item.id === body.registrationId && item.status === 'approved');
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
const exam = db.exams.find(item => item.id === registration.examId);
if (!registration.subjectIds.includes(body.subjectId) || !exam.subjects.some(item => item.id === body.subjectId)) return sendError(response, 400, '该考生未报名此科目');
const score = Number(body.score);
if (!Number.isFinite(score) || score < 0 || score > 150) return sendError(response, 400, '成绩必须在 0—150 之间');
let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === body.subjectId);
const isNew = !result;
if (!result) {
result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId };
db.results.push(result);
}
Object.assign(result, { score, grade: cleanText(body.grade, 10) || (score >= 135 ? 'A+' : score >= 120 ? 'A' : score >= 105 ? 'B+' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null });
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const subject = exam.subjects.find(item => item.id === body.subjectId);
const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
await database.saveResult(result, isNew, log);
return sendJson(response, 200, { ok: true, result });
}
return sendError(response, 404, '管理功能接口不存在');
}
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'));