Files
Exam-Information-System/server.mjs
T

1309 lines
96 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 { 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 schoolAdminId = 'usr_school_admin';
const schoolAdmin2Id = 'usr_school_admin_2';
const candidateId = 'usr_demo';
const examId = 'exam_autumn_2026';
const registrationId = 'reg_demo_2026';
return {
meta: { version: 5, createdAt: nowIso() },
settings: { selfRegistrationEnabled: false },
organization: {
name: '海州市教育考试中心',
code: 'HZ-EDU-032',
phone: '0518-8602 3158',
address: '海州市清河区文教路 18 号'
},
schools: [
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '海州市清河区学府路 8 号', active: true },
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '海州市滨河区育才路 16 号', active: true }
],
classes: [
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
{ id: 'class_hz1_302', schoolId: 'school_hz1', name: '高三(2)班', grade: '高三', active: true },
{ id: 'class_hz3_301', schoolId: 'school_hz3', name: '高三(1)班', grade: '高三', active: true }
],
users: [
{ id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() },
{ id: 'usr_supervisor', username: 'supervisor', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() },
{ id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() },
{ id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() },
{ id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() },
{ id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: true, createdAt: nowIso() }
],
candidateProfiles: [
{
id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821',
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000',
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false,
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', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default',
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' }
],
testCenters: [
{ id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', address: '海州市清河区学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A001、002;实验楼:机考 01', updatedAt: nowIso() },
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
],
testRooms: [
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' },
{ id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'standard', status: 'active', notes: '' },
{ id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatStart: 1, seatEnd: 40, roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' },
{ id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' },
{ id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' }
],
centerChangeRequests: [],
centerChangeRooms: [],
candidateAccountBatches: [],
candidateAccountBatchItems: [],
numberRules: [
{ id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [
{ id: 'segment_year', position: 1, type: 'year', value: '', width: 4 },
{ id: 'segment_school', position: 2, type: 'school_code', value: '', width: 0 },
{ id: 'segment_gender', position: 3, type: 'gender', value: '', width: 0 },
{ id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 }
] }
],
workflows: [
{ id: 'workflow_profile', businessType: 'profile_change', name: '考生信息修改审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
{ id: 'workflow_profile_step_1', position: 1, name: '学校学籍复核', adminLevel: 'school' },
{ id: 'workflow_profile_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' }
] },
{ id: 'workflow_registration', businessType: 'registration_review', name: '考试报名审核', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
{ id: 'workflow_registration_step_1', position: 1, name: '学校报名初审', adminLevel: 'school' },
{ id: 'workflow_registration_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' }
] },
{ id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
{ id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' }
] },
{ id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
{ id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' }
] }
],
workflowInstances: [],
workflowActions: [],
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();
const user = db.users.find(item => item.id === session.userId) || null;
return user?.active === false ? null : user;
}
function safeUser(user) {
return {
id: user.id,
username: user.username,
role: user.role,
adminLevel: user.adminLevel || null,
schoolId: user.schoolId || null,
classId: user.classId || null,
displayName: user.displayName,
candidateNumber: user.candidateNumber || null,
mustChangePassword: Boolean(user.mustChangePassword)
};
}
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;
}
const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' };
const permissionsByLevel = {
super: ['*'],
school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'],
class: ['dashboard.read', 'candidates.read', 'registrations.read', 'results.read']
};
function hasPermission(user, permission) {
if (user?.role !== 'admin') return false;
const permissions = permissionsByLevel[user.adminLevel || 'super'] || [];
return permissions.includes('*') || permissions.includes(permission);
}
function requirePermission(user, response, permission) {
if (hasPermission(user, permission)) return true;
sendError(response, 403, '当前管理员层级无权执行此操作');
return false;
}
function profileInScope(user, profile) {
if (user.adminLevel === 'super') return true;
if (user.adminLevel === 'school') return Boolean(user.schoolId && profile.schoolId === user.schoolId);
return Boolean(user.classId && profile.classId === user.classId);
}
function registrationInScope(db, user, registration) {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
return Boolean(profile && profileInScope(user, profile));
}
function adminScopeLabel(db, user) {
if (user.adminLevel === 'super') return '全部学校与班级';
const school = db.schools.find(item => item.id === user.schoolId)?.name || '未绑定学校';
if (user.adminLevel === 'school') return school;
const schoolClass = db.classes.find(item => item.id === user.classId)?.name || '未绑定班级';
return `${school} · ${schoolClass}`;
}
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);
return Boolean(profile?.classId && item.classId === profile.classId);
});
}
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 = adminsForStep(db, firstStep.adminLevel, profile)[0];
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;
}
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 rooms = Array.isArray(body.rooms) ? body.rooms : [];
if (!code || !name || !address) 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);
const seatStart = Number(room.seatStart);
const seatEnd = Number(room.seatEnd);
if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1 || !Number.isInteger(seatStart) || !Number.isInteger(seatEnd) || seatStart < 1 || seatEnd < seatStart) {
throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋、容量或座位号范围无效`), { status: 400 });
}
if (seatEnd - seatStart + 1 > capacity) 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, seatStart, seatEnd,
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, 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,
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));
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;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[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 => `<tr><td>${escapeHtml(subject.name)}</td><td>${escapeHtml(subject.date)}</td><td>${escapeHtml(subject.start)}${escapeHtml(subject.end)}</td><td>${escapeHtml(registration.admitCard.room)}</td></tr>`).join('');
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(exam.name)}准考证</title><style>body{font-family:"Microsoft YaHei",sans-serif;color:#182552;padding:32px;background:#f2f4f8}.card{max-width:800px;margin:auto;background:#fff;border:2px solid #182552;padding:32px;box-shadow:0 12px 35px #18255218}.head{display:flex;justify-content:space-between;border-bottom:3px double #182552;padding-bottom:18px}.brand{font-size:28px;font-weight:800}.code{font-family:monospace}.meta{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:24px 0}.meta div{border-bottom:1px solid #ddd;padding:8px}.meta span{color:#777;font-size:12px;margin-right:12px}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccd2df;padding:12px;text-align:left}th{background:#f3f5f9}.notice{margin-top:20px;padding:15px;background:#fff7e6;color:#704b12;font-size:13px}.footer{display:flex;justify-content:space-between;margin-top:22px;color:#777;font-size:12px}@media print{body{background:#fff;padding:0}.card{box-shadow:none}}</style></head><body><main class="card"><header class="head"><div><div class="brand">衡准 · 准考证</div><p>${escapeHtml(exam.name)}</p></div><div class="code">${escapeHtml(registration.admitCard.number)}</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(registration.admitCard.testCenter)}</strong></div><div><span>考场 / 座位</span><strong>${escapeHtml(registration.admitCard.room)} / ${escapeHtml(registration.admitCard.seat)}</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考场</th></tr></thead><tbody>${rows}</tbody></table><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></body></html>`;
}
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, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, 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, ...(user.role === 'admin' ? { permissions: permissionsByLevel[user.adminLevel || 'super'], scopeLabel: adminScopeLabel(db, user) } : {}) });
}
if (request.method === 'POST' && pathname === '/api/auth/register') {
const body = await readJson(request);
const password = String(body.password || '');
const name = cleanText(body.name, 30);
const gender = cleanText(body.gender, 10);
if (!name || !['男', '女'].includes(gender)) return sendError(response, 400, '请填写姓名并选择性别');
if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位');
const db = await readDb();
if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录');
const schoolId = cleanText(body.schoolId, 64);
const classId = cleanText(body.classId, 64);
const school = db.schools.find(item => item.id === schoolId && item.active);
const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active);
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
const draftProfile = { schoolId, classId, gender };
const generated = generateCandidateNumber(db, draftProfile);
const userId = uid('usr');
const user = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(password), role: 'candidate', displayName: name, active: true, mustChangePassword: false, createdAt: nowIso() };
const profile = { id: uid('profile'), userId, name, idNumber: `PENDING-${userId}`, phone: '', gender, email: '', school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() };
await database.createCandidate(user, profile, null, null);
return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' });
}
if (request.method === 'POST' && pathname === '/api/auth/login') {
const body = await readJson(request);
const db = await readDb();
const account = cleanText(body.username, 120).toLowerCase();
const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account);
if (!user || user.active === false || !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/change-password') {
const user = await requireUser(request, response);
if (!user) return true;
const body = await readJson(request);
const currentPassword = String(body.currentPassword || '');
const newPassword = String(body.newPassword || '');
if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确');
if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位');
if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与初始密码相同');
user.passwordHash = hashPassword(newPassword);
user.mustChangePassword = false;
const db = await readDb();
const log = logAction(db, user, '修改登录密码', user.role === 'candidate' ? `报名号 ${user.candidateNumber}` : user.username);
await database.changePassword(user, log);
return sendJson(response, 200, { ok: true, user: safeUser(user) });
}
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 (user.mustChangePassword) return sendError(response, 428, '首次登录必须先修改初始密码');
const profileRoute = pathname === '/api/candidate/profile';
if (!profile.profileCompleted && !profileRoute) return sendError(response, 428, '请先补全个人信息并提交审核');
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);
const profileInstance = pendingWorkflow(db, 'profile_change', profile.id)
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices });
}
if (request.method === 'GET' && pathname === '/api/candidate/profile') {
const instance = pendingWorkflow(db, 'profile_change', profile.id)
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active) });
}
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
const body = await readJson(request);
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone'];
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
profile.schoolId = school.id;
profile.classId = schoolClass.id;
profile.school = school.name;
profile.grade = schoolClass.name;
if (!profile.name || !['男', '女'].includes(profile.gender) || !profile.idNumber || profile.idNumber.startsWith('PENDING-') || !profile.nativePlace || !profile.address || !profile.phone || !profile.email || !profile.school || !profile.classId) 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.profileCompleted = true;
profile.reviewNote = '';
profile.updatedAt = nowIso();
const existingWorkflow = pendingWorkflow(db, 'profile_change', profile.id);
const submission = existingWorkflow ? null : createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id);
await database.updateCandidateProfile(profile, profile.name, submission?.instance, submission?.action);
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(), registrationNumber: user.candidateNumber, numberRuleId: db.numberRules.find(item => item.active)?.id || null, admitCard: null };
const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id);
await database.createRegistration(registration, instance, action);
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/context') {
return sendJson(response, 200, {
ok: true,
admin: safeUser(user),
adminLevelName: adminLevelNames[user.adminLevel || 'super'],
permissions: permissionsByLevel[user.adminLevel || 'super'],
scopeLabel: adminScopeLabel(db, user),
schools: db.schools,
classes: db.classes
});
}
if (pathname === '/api/admin/admins' && request.method === 'GET') {
if (!requirePermission(user, response, '*')) return true;
const admins = db.users.filter(item => item.role === 'admin').map(item => ({
...safeUser(item),
active: item.active,
levelName: adminLevelNames[item.adminLevel],
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || ''
}));
return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
}
if (pathname === '/api/admin/admins' && request.method === 'POST') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const username = cleanText(body.username, 50);
const password = String(body.password || '');
const displayName = cleanText(body.displayName, 50);
const adminLevel = cleanText(body.adminLevel, 20);
if (!username || !displayName || password.length < 8 || !['super', 'school', 'class'].includes(adminLevel)) return sendError(response, 400, '请完整填写管理员账号、姓名、层级和至少 8 位密码');
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
const schoolId = adminLevel === 'super' ? null : cleanText(body.schoolId, 64);
const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null;
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校');
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() };
const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`);
await database.createAdmin(created, log);
return sendJson(response, 201, { ok: true, admin: safeUser(created) });
}
if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const enabled = Boolean(body.enabled);
const log = logAction(db, user, enabled ? '开启自主注册' : '关闭自主注册', enabled ? '考生可从公开入口申请报名号' : '仅允许使用学校下发的报名号登录');
await database.updateRegistrationSetting(enabled, log);
return sendJson(response, 200, { ok: true, enabled });
}
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'GET') {
if (!requirePermission(user, response, 'candidates.write')) return true;
if (!['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '只有校级管理员可以申领批量报名号');
const batches = db.candidateAccountBatches
.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.map(item => candidateAccountBatchView(db, item));
const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId));
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active) });
}
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') {
if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领');
const body = await readJson(request);
const requestedQuotas = Array.isArray(body.quotas) ? body.quotas : [];
const quotas = requestedQuotas.map(item => ({ classId: cleanText(item.classId, 64), count: Number(item.count) })).filter(item => item.count > 0);
if (!quotas.length) return sendError(response, 400, '请至少为一个班级填写申领数量');
if (new Set(quotas.map(item => item.classId)).size !== quotas.length) return sendError(response, 400, '同一班级只能填写一次申领数量');
if (quotas.some(item => !Number.isInteger(item.count) || item.count < 1 || item.count > 200)) return sendError(response, 400, '每个班级一次可申领 1—200 个报名号');
if (quotas.some(item => !db.classes.some(schoolClass => schoolClass.id === item.classId && schoolClass.schoolId === user.schoolId && schoolClass.active))) return sendError(response, 400, '只能为本校有效班级申领报名号');
const totalCount = quotas.reduce((sum, item) => sum + item.count, 0);
if (totalCount > 500) return sendError(response, 400, '单个批次最多申领 500 个报名号');
const batch = { id: uid('account_batch'), schoolId: user.schoolId, requestedBy: user.id, status: 'pending', reviewNote: '', createdAt: nowIso(), reviewedAt: null };
const items = [];
let position = 1;
for (const quota of quotas) for (let index = 0; index < quota.count; index += 1) {
items.push({ id: uid('account_batch_item'), batchId: batch.id, classId: quota.classId, position, candidateNumber: '', initialPassword: '', userId: null, createdAt: null });
position += 1;
}
const { instance, action } = createWorkflowSubmission(db, 'candidate_account_batch', batch.id, centerScopeProfile(db, user.schoolId), user.id);
const quotaSummary = quotas.map(item => `${db.classes.find(entry => entry.id === item.classId)?.name} ${item.count} 人`).join('');
const log = logAction(db, user, '提交批量报名号申领', `${totalCount} 个账户 · ${quotaSummary}`);
await database.createCandidateAccountBatch(batch, items, instance, action, log);
const fresh = await readDb();
return sendJson(response, 202, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) });
}
const accountBatchMatch = pathname.match(/^\/api\/admin\/candidate-account-batches\/([^/]+)$/);
if (accountBatchMatch && request.method === 'PATCH') {
if (!requirePermission(user, response, 'candidates.write')) return true;
const body = await readJson(request);
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效');
const batch = db.candidateAccountBatches.find(item => item.id === accountBatchMatch[1] && item.status === 'pending');
if (!batch) return sendError(response, 404, '待审批的批量报名号申请不存在');
const instance = pendingWorkflow(db, 'candidate_account_batch', batch.id);
const workflow = instance && db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (!instance || !workflow || !step) return sendError(response, 409, '批量报名号审批流程状态异常');
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
const log = logAction(db, user, body.status === 'approved' ? '审批批量报名号申领' : '退回批量报名号申领', `${db.schools.find(item => item.id === batch.schoolId)?.name} · ${note || '无备注'}`);
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
batch.status = 'rejected'; batch.reviewNote = note; batch.reviewedAt = nowIso();
await database.processWorkflow(instance, action, batch, log);
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = adminsForStep(db, nextStep.adminLevel, centerScopeProfile(db, batch.schoolId))[0];
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
batch.reviewNote = note;
await database.processWorkflow(instance, action, batch, log);
} else {
const batchItems = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position);
if (!batchItems.length || batchItems.some(item => item.userId || item.candidateNumber)) return sendError(response, 409, '批次明细异常或已经生成过账号');
const generationDb = { ...db, users: [...db.users] };
const users = [];
const profiles = [];
for (const [index, item] of batchItems.entries()) {
const schoolClass = db.classes.find(entry => entry.id === item.classId && entry.schoolId === batch.schoolId);
if (!schoolClass) return sendError(response, 409, '批次包含无效班级,无法生成账号');
const generated = generateCandidateNumber(generationDb, { schoolId: batch.schoolId, classId: item.classId, gender: '' });
const userId = uid('usr');
const initialPassword = `Init-${randomBytes(6).toString('base64url')}`;
const displayName = `待补录考生 ${String(index + 1).padStart(3, '0')}`;
const candidateUser = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(initialPassword), role: 'candidate', displayName, schoolId: batch.schoolId, classId: item.classId, active: true, mustChangePassword: true, createdAt: nowIso() };
const profile = { id: uid('profile'), userId, name: displayName, gender: '', idNumber: `PENDING-${userId}`, phone: '', email: '', school: db.schools.find(entry => entry.id === batch.schoolId)?.name || '', grade: schoolClass.name, schoolId: batch.schoolId, classId: item.classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() };
item.candidateNumber = generated.number; item.initialPassword = initialPassword; item.userId = userId; item.createdAt = nowIso();
users.push(candidateUser); profiles.push(profile); generationDb.users.push(candidateUser);
}
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
batch.status = 'approved'; batch.reviewNote = note; batch.reviewedAt = nowIso();
await database.completeCandidateAccountBatch(batch, batchItems, users, profiles, instance, action, log);
}
const fresh = await readDb();
return sendJson(response, 200, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) });
}
if (pathname === '/api/admin/centers' && request.method === 'GET') {
if (!requirePermission(user, response, 'centers.read')) return true;
const centers = db.testCenters.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId).map(item => ({
...item,
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
rooms: db.testRooms.filter(room => room.centerId === item.id),
totalCapacity: db.testRooms.filter(room => room.centerId === item.id && room.status === 'active').reduce((sum, room) => sum + Number(room.capacity || 0), 0),
pendingChange: db.centerChangeRequests.some(change => change.centerId === item.id && change.status === 'pending')
}));
const changeRequests = db.centerChangeRequests
.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.map(item => centerChangeView(db, item));
return sendJson(response, 200, { ok: true, centers, changeRequests, schools: user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId) });
}
if (pathname === '/api/admin/centers' && request.method === 'POST') {
if (!requirePermission(user, response, 'centers.write')) return true;
const body = await readJson(request);
const schoolId = user.adminLevel === 'super' ? cleanText(body.schoolId, 64) : user.schoolId;
if (!db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '考点必须归属有效学校');
const parsed = parseCenterChange(db, body, schoolId);
const change = { id: uid('center_change'), centerId: null, schoolId, requestType: 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, schoolId), user.id);
const log = logAction(db, user, '提交新增考点审批', `${change.name} · ${parsed.rooms.length} 个考场`);
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log);
return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } });
}
const centerMatch = pathname.match(/^\/api\/admin\/centers\/([^/]+)$/);
if (centerMatch && request.method === 'PATCH') {
if (!requirePermission(user, response, 'centers.write')) return true;
const body = await readJson(request);
const center = db.testCenters.find(item => item.id === centerMatch[1]);
if (!center) return sendError(response, 404, '考点不存在');
if (user.adminLevel !== 'super' && center.schoolId !== user.schoolId) return sendError(response, 403, '只能维护本校考点');
if (db.centerChangeRequests.some(item => item.centerId === center.id && item.status === 'pending')) return sendError(response, 409, '该考点已有待审批变更,请处理完成后再提交');
const parsed = parseCenterChange(db, body, center.schoolId, center);
const change = { id: uid('center_change'), centerId: center.id, schoolId: center.schoolId, requestType: 'update', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, center.schoolId), user.id);
const log = logAction(db, user, '提交考点变更审批', `${change.name} · ${parsed.rooms.length} 个考场`);
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log);
return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } });
}
const centerChangeMatch = pathname.match(/^\/api\/admin\/center-change-requests\/([^/]+)$/);
if (centerChangeMatch && request.method === 'PATCH') {
if (!requirePermission(user, response, 'centers.write')) return true;
const body = await readJson(request);
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效');
const change = db.centerChangeRequests.find(item => item.id === centerChangeMatch[1] && item.status === 'pending');
if (!change) return sendError(response, 404, '待审批的考点变更不存在');
if (user.adminLevel !== 'super' && change.schoolId !== user.schoolId) return sendError(response, 403, '该变更不在你的学校范围内');
const instance = pendingWorkflow(db, 'center_change', change.id);
const workflow = instance && db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (!instance || !workflow || !step) return sendError(response, 409, '考点变更审批流程状态异常');
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
const log = logAction(db, user, body.status === 'approved' ? '审批考点变更' : '退回考点变更', `${change.name} · ${note || '无备注'}`);
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
change.status = 'rejected'; change.reviewNote = note; change.reviewedAt = nowIso();
await database.applyCenterChange(change, instance, action, null, [], log);
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = adminsForStep(db, nextStep.adminLevel, centerScopeProfile(db, change.schoolId))[0];
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
change.reviewNote = note;
await database.processWorkflow(instance, action, change, log);
} else {
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
change.status = 'approved'; change.reviewNote = note; change.reviewedAt = nowIso();
const centerId = change.centerId || uid('center');
const proposedRooms = db.centerChangeRooms.filter(item => item.requestId === change.id);
const rooms = proposedRooms.map(room => ({ ...room, id: room.roomId || uid('room'), centerId }));
const center = {
id: centerId, schoolId: change.schoolId, code: change.code, name: change.name, address: change.address,
contact: change.contact, managerName: change.managerName, managerPhone: change.managerPhone,
emergencyPhone: change.emergencyPhone, gateOpenTime: change.gateOpenTime, transport: change.transport,
status: change.centerStatus, notes: change.notes,
rooms: rooms.map(room => `${room.building} ${room.name}`).join(''), updatedAt: nowIso()
};
await database.applyCenterChange(change, instance, action, center, rooms, log);
}
return sendJson(response, 200, { ok: true, changeRequest: centerChangeView({ ...db, workflowActions: [...db.workflowActions, action] }, change) });
}
if (pathname === '/api/admin/number-rules' && request.method === 'GET') {
if (!requirePermission(user, response, '*')) return true;
const rule = db.numberRules.find(item => item.active) || null;
const previewProfile = db.candidateProfiles[0] || { gender: '女', schoolId: db.schools[0]?.id };
let preview = '';
if (rule) preview = generateCandidateNumber(db, previewProfile).number;
return sendJson(response, 200, { ok: true, rules: db.numberRules, activeRule: rule, preview });
}
if (pathname === '/api/admin/number-rules' && request.method === 'POST') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const allowedTypes = ['year', 'school_code', 'gender', 'sequence', 'literal'];
const requested = Array.isArray(body.segments) ? body.segments : [];
if (!requested.length || requested.some(item => !allowedTypes.includes(item.type)) || !requested.some(item => item.type === 'sequence')) return sendError(response, 400, '报名号规则至少包含一个流水号段');
const existing = db.numberRules.find(item => item.id === body.id);
const rule = {
id: existing?.id || uid('rule'), name: cleanText(body.name, 80) || '自定义报名号规则', separator: cleanText(body.separator, 3),
active: true, createdBy: user.id, updatedAt: nowIso(), segments: requested.map((item, index) => ({
id: uid('segment'), position: index + 1, type: item.type, value: cleanText(item.value, 20), width: Math.min(12, Math.max(0, Number(item.width || 0)))
}))
};
const log = logAction(db, user, '更新报名号规则', `${rule.name} · ${rule.segments.map(item => item.type).join(' + ')}`);
await database.saveNumberRule(rule, !existing, log);
return sendJson(response, 200, { ok: true, rule });
}
if (pathname === '/api/admin/workflows' && request.method === 'GET') {
if (!requirePermission(user, response, '*')) return true;
return sendJson(response, 200, { ok: true, workflows: db.workflows });
}
const workflowDefinitionMatch = pathname.match(/^\/api\/admin\/workflows\/(profile_change|registration_review|center_change|candidate_account_batch)$/);
if (workflowDefinitionMatch && request.method === 'PUT') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const workflow = activeWorkflow(db, workflowDefinitionMatch[1]);
if (!workflow) return sendError(response, 404, '审批流程不存在');
const steps = Array.isArray(body.steps) ? body.steps : [];
if (!steps.length || steps.some(item => !['school', 'super'].includes(item.adminLevel))) return sendError(response, 400, '流程至少需要一个校级或超级管理员审批步骤');
if (workflowDefinitionMatch[1] === 'candidate_account_batch' && steps.at(-1)?.adminLevel !== 'super') return sendError(response, 400, '批量报名号申领的最终步骤必须由超级管理员审批');
workflow.name = cleanText(body.name, 80) || workflow.name;
workflow.updatedBy = user.id;
workflow.updatedAt = nowIso();
workflow.steps = steps.map((item, index) => ({ id: uid('workflow_step'), position: index + 1, name: cleanText(item.name, 80) || `第 ${index + 1} 步`, adminLevel: item.adminLevel }));
const log = logAction(db, user, '修改审批流程', `${workflow.name} · ${workflow.steps.length} 个步骤`);
await database.saveWorkflow(workflow, log);
return sendJson(response, 200, { ok: true, workflow });
}
if (pathname === '/api/admin/workflow-instances' && request.method === 'GET') {
if (user.adminLevel === 'class') return sendError(response, 403, '班级管理员只读查看考生、成绩和报名状态');
const instances = db.workflowInstances.filter(instance => {
if (user.adminLevel === 'super') return true;
const profile = workflowScopeProfile(db, instance);
return Boolean(profile && profileInScope(user, profile));
}).map(instance => {
const profile = workflowScopeProfile(db, instance);
const registration = instance.businessType === 'registration_review' ? db.registrations.find(item => item.id === instance.businessId) : null;
const centerChange = instance.businessType === 'center_change' ? db.centerChangeRequests.find(item => item.id === instance.businessId) : null;
const accountBatch = instance.businessType === 'candidate_account_batch' ? db.candidateAccountBatches.find(item => item.id === instance.businessId) : null;
return {
...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '',
examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '',
centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null,
accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null,
batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0
};
});
const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser);
return sendJson(response, 200, { ok: true, instances, availableAdmins, canSupervise: user.adminLevel === 'super' });
}
const transferMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/transfer$/);
if (transferMatch && request.method === 'PATCH') {
const body = await readJson(request);
const instance = db.workflowInstances.find(item => item.id === transferMatch[1] && item.status === 'pending');
if (!instance) return sendError(response, 404, '待处理流程不存在');
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (user.adminLevel !== 'super' && instance.assigneeId !== user.id) return sendError(response, 403, '只有当前处理人可以转交该流程');
const target = db.users.find(item => item.id === body.assigneeId && item.role === 'admin' && item.active && item.adminLevel === step?.adminLevel);
if (!target) return sendError(response, 400, '只能转交给当前步骤同级管理员');
const profile = workflowScopeProfile(db, instance);
if (step.adminLevel === 'school' && target.schoolId !== profile?.schoolId) return sendError(response, 400, '校级流程只能转交给本校同级管理员');
const previous = instance.assigneeId;
instance.assigneeId = target.id;
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: 'transfer', note: cleanText(body.note, 300), fromAssigneeId: previous, toAssigneeId: target.id, createdAt: nowIso() };
const log = logAction(db, user, '转交审批流程', `${workflow.name}${target.displayName}`);
await database.transferWorkflow(instance, action, log);
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
const superviseMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/supervise$/);
if (superviseMatch && request.method === 'PATCH') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const instance = db.workflowInstances.find(item => item.id === superviseMatch[1]);
if (!instance) return sendError(response, 404, '流程不存在');
if (instance.businessType === 'candidate_account_batch' && db.candidateAccountBatchItems.some(item => item.batchId === instance.businessId && item.userId)) return sendError(response, 409, '已生成账号的批次不可重新打开,避免重复建号');
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const requestedStep = Math.min(workflow.steps.length, Math.max(1, Number(body.currentStep || instance.currentStep)));
const step = workflow.steps.find(item => item.position === requestedStep);
const profile = workflowScopeProfile(db, instance);
const eligible = adminsForStep(db, step.adminLevel, profile);
const assignee = eligible.find(item => item.id === body.assigneeId) || eligible[0];
if (!assignee) return sendError(response, 409, '目标步骤没有可用管理员');
const previous = instance.assigneeId;
const previousStep = instance.currentStep;
instance.status = 'pending'; instance.completedAt = null; instance.currentStep = requestedStep; instance.assigneeId = assignee.id;
const note = cleanText(body.note, 300) || `超级管理员将流程调整到第 ${requestedStep} 步`;
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: requestedStep < previousStep ? 'return' : 'supervise', note, fromAssigneeId: previous, toAssigneeId: assignee.id, createdAt: nowIso() };
const business = instance.businessType === 'profile_change'
? profile
: instance.businessType === 'registration_review'
? db.registrations.find(item => item.id === instance.businessId)
: instance.businessType === 'center_change'
? db.centerChangeRequests.find(item => item.id === instance.businessId)
: db.candidateAccountBatches.find(item => item.id === instance.businessId);
business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null;
const log = logAction(db, user, '监督调整审批流程', `${workflow.name} · 第 ${requestedStep} 步 · ${assignee.displayName}`);
await database.processWorkflow(instance, action, business, log);
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
if (request.method === 'GET' && pathname === '/api/admin/dashboard') {
const profiles = db.candidateProfiles.filter(item => profileInScope(user, item));
const registrations = db.registrations.filter(item => registrationInScope(db, user, item));
const visibleFlows = db.workflowInstances.filter(instance => {
if (user.adminLevel === 'super') return true;
if (user.adminLevel === 'class') return false;
const business = workflowScopeProfile(db, instance);
return business && profileInScope(user, business) && (instance.assigneeId === user.id || instance.status !== 'pending');
});
const pendingCandidates = profiles.filter(item => item.status === 'pending').length;
const pendingRegistrations = registrations.filter(item => item.status === 'pending').length;
return sendJson(response, 200, {
ok: true,
admin: safeUser(user),
scopeLabel: adminScopeLabel(db, user),
permissions: permissionsByLevel[user.adminLevel || 'super'],
metrics: { candidates: profiles.length, pendingCandidates, registrations: registrations.length, pendingRegistrations, pendingFlows: visibleFlows.filter(item => item.status === 'pending').length, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length },
logs: user.adminLevel === 'super' ? db.auditLogs.slice(0, 8) : db.auditLogs.filter(log => log.actorId === user.id).slice(0, 8)
});
}
if (request.method === 'GET' && pathname === '/api/admin/candidates') {
if (!requirePermission(user, response, 'candidates.read')) return true;
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
const account = db.users.find(item => item.id === profile.userId);
return { ...profile, idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber), username: account?.username, candidateNumber: account?.candidateNumber || '', mustChangePassword: Boolean(account?.mustChangePassword), workflow: workflowView(db, instance) };
});
return sendJson(response, 200, { ok: true, candidates, schools: user.adminLevel === 'super' ? db.schools.filter(item => item.active) : db.schools.filter(item => item.id === user.schoolId && item.active), classes: db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)) });
}
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
if (request.method === 'PATCH' && candidateMatch) {
if (!requirePermission(user, response, 'candidates.review')) return true;
const body = await readJson(request);
const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]);
if (!profile) return sendError(response, 404, '考生资料不存在');
if (!profileInScope(user, profile) && user.adminLevel !== 'super') return sendError(response, 403, '该考生不在你的数据范围内');
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
const instance = pendingWorkflow(db, 'profile_change', profile.id);
if (!instance) return sendError(response, 409, '当前没有待处理的考生信息流程');
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
profile.status = 'rejected'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0];
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
profile.status = 'pending'; profile.reviewNote = note;
} else {
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
profile.status = 'approved'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
}
const log = logAction(db, user, body.status === 'approved' ? '处理考生信息流程' : '退回考生信息', `${profile.name}${note || '无备注'}`);
await database.processWorkflow(instance, action, profile, log);
return sendJson(response, 200, { ok: true, profile, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
if (request.method === 'GET' && pathname === '/api/admin/registrations') {
if (!requirePermission(user, response, 'registrations.read')) return true;
const registrations = db.registrations.filter(registration => registrationInScope(db, user, registration)).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) {
if (!requirePermission(user, response, 'registrations.review')) return true;
const body = await readJson(request);
const registration = db.registrations.find(item => item.id === registrationMatch[1]);
if (!registration) return sendError(response, 404, '报名记录不存在');
if (!registrationInScope(db, user, registration) && user.adminLevel !== 'super') return sendError(response, 403, '该报名不在你的数据范围内');
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const instance = pendingWorkflow(db, 'registration_review', registration.id);
if (!instance) return sendError(response, 409, '当前没有待处理的报名审核流程');
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso();
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0];
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
registration.status = 'pending'; registration.reviewNote = note;
} else {
const account = db.users.find(item => item.id === registration.userId);
if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号,请先在报名号管理中完成分配');
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
registration.status = 'approved'; registration.paymentStatus = 'paid'; registration.reviewNote = note; registration.reviewedAt = nowIso();
registration.registrationNumber = account.candidateNumber;
registration.numberRuleId = db.numberRules.find(item => item.active)?.id || registration.numberRuleId;
}
const log = logAction(db, user, body.status === 'approved' ? '处理报名审核流程' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`);
await database.processWorkflow(instance, action, registration, log);
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
if (request.method === 'POST' && admitMatch) {
if (!requirePermission(user, response, '*')) return true;
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') {
if (!requirePermission(user, response, '*')) return true;
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') {
if (!requirePermission(user, response, '*')) return true;
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) {
if (!requirePermission(user, response, '*')) return true;
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') {
if (!requirePermission(user, response, '*')) return true;
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') {
if (!requirePermission(user, response, '*')) return true;
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) {
if (!requirePermission(user, response, '*')) return true;
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') {
if (!requirePermission(user, response, 'results.read')) return true;
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).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: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] });
}
if (request.method === 'POST' && pathname === '/api/admin/results') {
if (!requirePermission(user, response, '*')) return true;
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'));