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)}准考证
衡准 · 准考证

${escapeHtml(exam.name)}

${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
考点${escapeHtml(registration.admitCard.testCenter)}
考场 / 座位${escapeHtml(registration.admitCard.room)} / ${escapeHtml(registration.admitCard.seat)}
${rows}
科目日期时间考场
考试须知:请携带本人有效身份证件及本准考证,至少提前 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, '考试不存在'); if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status; ['name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'].forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], 500); }); const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`); await database.updateExam(exam, log); 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'));