数据库
This commit is contained in:
+43
-51
@@ -1,11 +1,12 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
|
||||
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 dbPath = resolve(process.env.EXAM_DB_PATH || join(root, 'data', 'db.json'));
|
||||
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 = {
|
||||
@@ -108,24 +109,8 @@ function seedDatabase() {
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureDatabase() {
|
||||
try {
|
||||
await access(dbPath);
|
||||
} catch {
|
||||
await mkdir(resolve(dbPath, '..'), { recursive: true });
|
||||
await writeFile(dbPath, JSON.stringify(seedDatabase(), null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function readDb() {
|
||||
await ensureDatabase();
|
||||
return JSON.parse(await readFile(dbPath, 'utf8'));
|
||||
}
|
||||
|
||||
async function writeDb(db) {
|
||||
await mkdir(resolve(dbPath, '..'), { recursive: true });
|
||||
await writeFile(dbPath, JSON.stringify(db, null, 2), 'utf8');
|
||||
}
|
||||
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 });
|
||||
@@ -213,8 +198,10 @@ function examRegistrationView(db, registration) {
|
||||
}
|
||||
|
||||
function logAction(db, user, action, detail) {
|
||||
db.auditLogs.unshift({ id: uid('log'), actorId: user.id, actorName: user.displayName, action, detail, createdAt: nowIso() });
|
||||
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) {
|
||||
@@ -265,9 +252,7 @@ async function handleAuth(request, response, pathname) {
|
||||
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() };
|
||||
db.users.push(user);
|
||||
db.candidateProfiles.push(profile);
|
||||
await writeDb(db);
|
||||
await database.createCandidate(user, profile);
|
||||
return sendJson(response, 201, { ok: true, message: '注册成功,请等待管理员审核资料' });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/login') {
|
||||
@@ -310,9 +295,7 @@ async function handleCandidate(request, response, pathname) {
|
||||
profile.status = 'pending';
|
||||
profile.reviewNote = '';
|
||||
profile.updatedAt = nowIso();
|
||||
const persistedUser = db.users.find(item => item.id === user.id);
|
||||
if (persistedUser) persistedUser.displayName = profile.name;
|
||||
await writeDb(db);
|
||||
await database.updateCandidateProfile(profile, profile.name);
|
||||
return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/exams') {
|
||||
@@ -334,8 +317,7 @@ async function handleCandidate(request, response, pathname) {
|
||||
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 };
|
||||
db.registrations.push(registration);
|
||||
await writeDb(db);
|
||||
await database.createRegistration(registration);
|
||||
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/results') {
|
||||
@@ -390,8 +372,8 @@ async function handleAdmin(request, response, pathname) {
|
||||
profile.reviewNote = cleanText(body.reviewNote, 300);
|
||||
profile.reviewedAt = nowIso();
|
||||
profile.reviewerId = user.id;
|
||||
logAction(db, user, body.status === 'approved' ? '通过考生资料' : '退回考生资料', `${profile.name}:${profile.reviewNote || '无备注'}`);
|
||||
await writeDb(db);
|
||||
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') {
|
||||
@@ -412,8 +394,8 @@ async function handleAdmin(request, response, pathname) {
|
||||
registration.reviewedAt = nowIso();
|
||||
if (body.status === 'approved') registration.paymentStatus = 'paid';
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
logAction(db, user, body.status === 'approved' ? '通过考试报名' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`);
|
||||
await writeDb(db);
|
||||
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$/);
|
||||
@@ -425,15 +407,15 @@ async function handleAdmin(request, response, pathname) {
|
||||
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(/\D/g, '').slice(-4) || '2026'}-${sequence}`,
|
||||
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);
|
||||
logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`);
|
||||
await writeDb(db);
|
||||
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 });
|
||||
}
|
||||
@@ -446,9 +428,8 @@ async function handleAdmin(request, response, pathname) {
|
||||
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() };
|
||||
db.exams.push(exam);
|
||||
logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`);
|
||||
await writeDb(db);
|
||||
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\/([^/]+)$/);
|
||||
@@ -458,8 +439,8 @@ async function handleAdmin(request, response, pathname) {
|
||||
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); });
|
||||
logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`);
|
||||
await writeDb(db);
|
||||
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)) });
|
||||
@@ -469,9 +450,8 @@ async function handleAdmin(request, response, pathname) {
|
||||
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 };
|
||||
db.notices.push(notice);
|
||||
logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
|
||||
await writeDb(db);
|
||||
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\/([^/]+)$/);
|
||||
@@ -485,8 +465,8 @@ async function handleAdmin(request, response, pathname) {
|
||||
notice.status = body.status;
|
||||
if (body.status === 'published' && !notice.publishAt) notice.publishAt = nowIso();
|
||||
}
|
||||
logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
||||
await writeDb(db);
|
||||
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') {
|
||||
@@ -508,6 +488,7 @@ async function handleAdmin(request, response, pathname) {
|
||||
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);
|
||||
@@ -515,8 +496,8 @@ async function handleAdmin(request, response, pathname) {
|
||||
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);
|
||||
logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
|
||||
await writeDb(db);
|
||||
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, '管理功能接口不存在');
|
||||
@@ -554,7 +535,18 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
});
|
||||
|
||||
await ensureDatabase();
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`衡准考试信息管理系统:http://127.0.0.1:${port}`);
|
||||
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'));
|
||||
|
||||
Reference in New Issue
Block a user