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'; import { buildWorkbook, hasExcelResource, parseWorkbook } from './excel.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: 6, 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: '教学楼 A:001、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, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, { id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, { id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatPlan: '按终端编号编排', roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' }, { id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴编排', roomType: 'standard', status: 'active', notes: '' }, { id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '无障碍席位优先编排', 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 }); } } async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 }); chunks.push(chunk); } if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 }); return Buffer.concat(chunks); } function sendWorkbook(response, buffer, filename) { response.writeHead(200, { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`, 'Content-Length': buffer.length, 'Cache-Control': 'no-store' }); response.end(buffer); } 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); if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1) { throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋或容量无效`), { status: 400 }); } if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 }); roomCodes.add(roomCode); return { id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName, building, floor: cleanText(room.floor, 30), capacity, seatPlan: cleanText(room.seatPlan, 500), seatStart: 1, seatEnd: capacity, roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard', status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300) }; }); return { center: { schoolId, code, name, 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; } const excelResourceNames = { classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额', account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账' }; function excelRowsForResource(db, user, resource, searchParams) { const schools = user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId); if (resource === 'classes') return db.classes.filter(item => schools.some(school => school.id === item.schoolId)).map(item => ({ schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', grade: item.grade, name: item.name, status: item.active ? '启用' : '停用' })); if (resource === 'class_admins') return db.users.filter(item => item.role === 'admin' && item.adminLevel === 'class' && schools.some(school => school.id === item.schoolId)).map(item => ({ schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', displayName: item.displayName, username: item.username, initialPassword: '', status: item.active ? '启用' : '停用' })); if (resource === 'account_quotas') return db.classes.filter(item => item.active && schools.some(school => school.id === item.schoolId)).map(item => ({ className: item.name, count: 0 })); if (resource === 'account_results') { const batchId = cleanText(searchParams.get('batchId'), 64); const batch = db.candidateAccountBatches.find(item => item.id === batchId && schools.some(school => school.id === item.schoolId)); if (!batch) throw Object.assign(new Error('批次不存在或不在当前学校范围内'), { status: 404 }); return db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position).map(item => ({ batchId: batch.id, className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', candidateNumber: item.candidateNumber, initialPassword: item.initialPassword })); } if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({ candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender, idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email, nativePlace: profile.nativePlace, address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade, ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone })); if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => { const rooms = db.testRooms.filter(room => room.centerId === center.id); return (rooms.length ? rooms : [{}]).map(room => ({ schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name, address: center.address, managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact, emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport, centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes, roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '', seatPlan: room.seatPlan || '', roomType: ({ standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' })[room.roomType] || '', roomStatus: room.status === 'inactive' ? '停用' : '启用', roomNotes: room.notes || '' })); }); if (resource === 'results') { const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item)); return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => { const registration = db.registrations.find(item => item.id === result.registrationId); const exam = db.exams.find(item => item.id === registration?.examId); return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: exam?.subjects.find(item => item.id === result.subjectId)?.name || '', score: result.score, grade: result.grade, published: result.published ? '发布' : '不发布' }; }); } return []; } function excelImportError(row, message) { return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 }); } async function importExcelResource(db, user, resource, rows) { if (resource === 'classes') { if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 }); for (const row of rows) { const school = db.schools.find(item => item.code.toUpperCase() === String(row.schoolCode).toUpperCase()); if (!school || (user.adminLevel === 'school' && school.id !== user.schoolId)) throw excelImportError(row, '学校代码无效或不在管理范围内'); const name = cleanText(row.name, 100); const grade = cleanText(row.grade, 60); if (!name || !grade) throw excelImportError(row, '年级和班级名称不能为空'); const existing = db.classes.find(item => item.schoolId === school.id && item.name === name); const schoolClass = existing || { id: uid('class'), schoolId: school.id }; Object.assign(schoolClass, { name, grade, active: row.status !== '停用' }); await database.saveSchoolClass(schoolClass, !existing, logAction(db, user, existing ? 'Excel 更新班级' : 'Excel 新增班级', `${school.name} · ${name}`)); if (!existing) db.classes.push(schoolClass); } return { count: rows.length }; } if (resource === 'class_admins') { if (user.adminLevel !== 'school') throw Object.assign(new Error('班级管理员 Excel 导入由校级管理员执行'), { status: 403 }); for (const row of rows) { const school = db.schools.find(item => item.id === user.schoolId && item.code.toUpperCase() === String(row.schoolCode).toUpperCase()); const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100)); if (!school || !schoolClass) throw excelImportError(row, '学校代码或班级名称无效'); const username = cleanText(row.username, 50); const displayName = cleanText(row.displayName, 50); const password = String(row.initialPassword || ''); if (!username || !displayName) throw excelImportError(row, '管理员姓名和登录账号不能为空'); const existing = db.users.find(item => item.username.toLowerCase() === username.toLowerCase()); if (existing && (existing.adminLevel !== 'class' || existing.schoolId !== user.schoolId)) throw excelImportError(row, '登录账号已被其他用户占用'); if (!existing && password.length < 8) throw excelImportError(row, '新建管理员的初始密码至少 8 位'); if (existing) { Object.assign(existing, { displayName, classId: schoolClass.id, active: row.status !== '停用' }); if (password) existing.passwordHash = hashPassword(password); await database.updateAdmin(existing, Boolean(password), logAction(db, user, 'Excel 更新班级管理员', `${displayName} · ${schoolClass.name}`)); } else { const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel: 'class', schoolId: user.schoolId, classId: schoolClass.id, displayName, active: row.status !== '停用', createdAt: nowIso() }; await database.createAdmin(created, logAction(db, user, 'Excel 创建班级管理员', `${displayName} · ${schoolClass.name}`)); db.users.push(created); } } return { count: rows.length }; } if (resource === 'account_quotas') { if (user.adminLevel !== 'school') throw Object.assign(new Error('班级配额模板仅供校级管理员使用'), { status: 403 }); const quotas = rows.filter(row => Number(row.count) > 0).map(row => { const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100) && item.active); if (!schoolClass || !Number.isInteger(Number(row.count)) || Number(row.count) < 1 || Number(row.count) > 200) throw excelImportError(row, '班级不存在,或申领数量不在 1—200 之间'); return { classId: schoolClass.id, className: schoolClass.name, count: Number(row.count) }; }); if (!quotas.length) throw Object.assign(new Error('模板中没有大于 0 的申领数量'), { status: 400 }); return { count: quotas.length, quotas }; } if (resource === 'candidates') { if (!hasPermission(user, 'candidates.write')) throw Object.assign(new Error('当前账号不能导入考生资料'), { status: 403 }); for (const row of rows) { const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120)); const profile = db.candidateProfiles.find(item => item.userId === account?.id); if (!account || !profile || !profileInScope(user, profile)) throw excelImportError(row, '报名号不存在或不在数据范围内'); if (pendingWorkflow(db, 'profile_change', profile.id)) throw excelImportError(row, '该考生已有待审批资料流程'); const schoolClass = db.classes.find(item => item.schoolId === profile.schoolId && item.name === cleanText(row.className, 100)); if (!schoolClass) throw excelImportError(row, '班级名称无效'); const required = ['name', 'gender', 'idNumber', 'phone']; if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填'); Object.assign(profile, { name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30), email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), address: cleanText(row.address, 200), classId: schoolClass.id, grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20), guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso() }); const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id); await database.updateCandidateProfile(profile, profile.name, instance, action); } return { count: rows.length }; } if (resource === 'centers') { if (!hasPermission(user, 'centers.write')) throw Object.assign(new Error('当前账号不能导入考点考场'), { status: 403 }); const groups = Map.groupBy(rows, row => cleanText(row.centerCode, 30).toUpperCase()); for (const [centerCode, centerRows] of groups) { const first = centerRows[0]; const school = db.schools.find(item => item.code.toUpperCase() === String(first.schoolCode).toUpperCase() && (user.adminLevel === 'super' || item.id === user.schoolId)); if (!school || !centerCode) throw excelImportError(first, '学校代码或考点代码无效'); const existing = db.testCenters.find(item => item.code.toUpperCase() === centerCode); if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校'); if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更'); const body = { schoolId: school.id, code: centerCode, name: first.centerName, address: first.address, managerName: first.managerName, managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime, transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes, rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan, roomType: ({ 标准考场: 'standard', 机考考场: 'computer', 无障碍考场: 'accessible', 备用考场: 'spare' })[row.roomType] || row.roomType, status: row.roomStatus === '停用' ? 'inactive' : 'active', notes: row.roomNotes })) }; const parsed = parseCenterChange(db, body, school.id, existing || null); const change = { id: uid('center_change'), centerId: existing?.id || null, schoolId: school.id, requestType: existing ? 'update' : 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null }; const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, school.id), user.id); await database.createCenterChangeRequest(change, parsed.rooms, instance, action, logAction(db, user, 'Excel 提交考点考场审批', `${change.name} · ${parsed.rooms.length} 个考场`)); } return { count: groups.size }; } if (resource === 'results') { if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以导入成绩'), { status: 403 }); for (const row of rows) { const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120)); const exam = db.exams.find(item => item.code === cleanText(row.examCode, 60)); const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved'); const subject = exam?.subjects.find(item => item.name === cleanText(row.subjectName, 50)); const score = Number(row.score); if (!registration || !subject || !registration.subjectIds.includes(subject.id) || !Number.isFinite(score) || score < 0 || score > 150) throw excelImportError(row, '报名号、考试、科目或成绩无效'); let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id); const isNew = !result; if (!result) result = { id: uid('result'), registrationId: registration.id, subjectId: subject.id }; Object.assign(result, { score, grade: cleanText(row.grade, 10) || (score >= 120 ? 'A' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: row.published === '发布', updatedAt: nowIso(), publishedAt: row.published === '发布' ? nowIso() : null }); await database.saveResult(result, isNew, logAction(db, user, 'Excel 导入成绩', `${row.candidateNumber} · ${exam.name} · ${subject.name}`)); if (isNew) db.results.push(result); } return { count: rows.length }; } throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 }); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function admitCardHtml(db, user, profile, registration) { const exam = db.exams.find(item => item.id === registration.examId); const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id)); const rows = subjects.map(subject => `${escapeHtml(subject.name)}${escapeHtml(subject.date)}${escapeHtml(subject.start)}—${escapeHtml(subject.end)}${escapeHtml(registration.admitCard.room)}`).join(''); return `${escapeHtml(exam.name)}准考证
衡准 · 准考证

${escapeHtml(exam.name)}

${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
考点${escapeHtml(registration.admitCard.testCenter)}
考场 / 座位${escapeHtml(registration.admitCard.room)} / ${escapeHtml(registration.admitCard.seat)}
${rows}
科目日期时间考场
考试须知:请携带本人有效身份证件及本准考证,至少提前 40 分钟到达考点。严禁携带手机、智能手表等通讯设备进入考场。
`; } async function handlePublic(pathname, response) { const db = await readDb(); if (pathname === '/api/public/home') { const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)); const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })); return sendJson(response, 200, { ok: true, organization: db.organization, 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 }); } const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|centers|results)$/); if (excelMatch && request.method === 'GET') { const resource = excelMatch[1]; if (!hasExcelResource(resource)) return sendError(response, 404, 'Excel 数据类型不存在'); if (['classes', 'class_admins', 'account_quotas', 'account_results'].includes(resource) && !['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能导出该数据'); if (resource === 'centers' && !hasPermission(user, 'centers.read')) return sendError(response, 403, '当前账号不能导出考点考场'); if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料'); if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩'); const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`); const template = requestUrl.searchParams.get('template') === '1'; const rows = template ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams); const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user); const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle })); return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`); } if (excelMatch && request.method === 'POST') { const resource = excelMatch[1]; if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出'); const rows = await parseWorkbook(resource, await readBodyBuffer(request)); const result = await importExcelResource(db, user, resource, rows); return sendJson(response, 200, { ok: true, ...result }); } if (pathname === '/api/admin/school-organization' && request.method === 'GET') { if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织'); const school = db.schools.find(item => item.id === user.schoolId); const classes = db.classes.filter(item => item.schoolId === user.schoolId).map(item => ({ ...item, candidateCount: db.candidateProfiles.filter(profile => profile.classId === item.id).length, admins: db.users.filter(admin => admin.role === 'admin' && admin.adminLevel === 'class' && admin.classId === item.id).map(admin => ({ ...safeUser(admin), active: admin.active })) })); return sendJson(response, 200, { ok: true, school, classes }); } if (pathname === '/api/admin/classes' && request.method === 'POST') { if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级'); const body = await readJson(request); const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60); if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空'); if (db.classes.some(item => item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级'); const schoolClass = { id: uid('class'), schoolId: user.schoolId, name, grade, active: body.active !== false }; await database.saveSchoolClass(schoolClass, true, logAction(db, user, '新增本校班级', `${grade} · ${name}`)); return sendJson(response, 201, { ok: true, schoolClass }); } const classMatch = pathname.match(/^\/api\/admin\/classes\/([^/]+)$/); if (classMatch && request.method === 'PATCH') { if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级'); const body = await readJson(request); const schoolClass = db.classes.find(item => item.id === classMatch[1] && item.schoolId === user.schoolId); if (!schoolClass) return sendError(response, 404, '班级不存在'); const name = cleanText(body.name ?? schoolClass.name, 100); const grade = cleanText(body.grade ?? schoolClass.grade, 60); if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空'); if (db.classes.some(item => item.id !== schoolClass.id && item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级'); Object.assign(schoolClass, { name, grade, active: body.active == null ? schoolClass.active : Boolean(body.active) }); await database.saveSchoolClass(schoolClass, false, logAction(db, user, '更新本校班级', `${grade} · ${name} · ${schoolClass.active ? '启用' : '停用'}`)); return sendJson(response, 200, { ok: true, schoolClass }); } if (pathname === '/api/admin/admins' && request.method === 'GET') { if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能管理管理员'); const admins = db.users.filter(item => item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))).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') { const body = await readJson(request); const username = cleanText(body.username, 50); const password = String(body.password || ''); const displayName = cleanText(body.displayName, 50); const adminLevel = user.adminLevel === 'school' ? 'class' : cleanText(body.adminLevel, 20); if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能创建管理员'); 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 : user.adminLevel === 'school' ? user.schoolId : 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) }); } const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/); if (adminMatch && request.method === 'PATCH') { if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员'); const body = await readJson(request); const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId); if (!target) return sendError(response, 404, '班级管理员不存在'); const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId); if (!schoolClass) return sendError(response, 400, '请选择本校有效班级'); const password = String(body.password || ''); if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位'); Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass.id, active: body.active == null ? target.active : Boolean(body.active) }); if (password) target.passwordHash = hashPassword(password); await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护班级管理员', `${target.displayName} · ${schoolClass.name}`)); return sendJson(response, 200, { ok: true, admin: safeUser(target) }); } 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'));