import { noticeForClient } from '../security/notice-content.mjs'; export function createCandidateRoutes(context) { const { database, cache, resultsCacheTtlSeconds, readDb, sendJson, sendError, readJson, readBodyBuffer, sendWorkbook, currentUser, safeUser, requireUser, hasPermission, requirePermission, profileInScope, registrationInScope, adminScopeLabel, adminsForStep, activeWorkflow, createWorkflowSubmission, workflowView, pendingWorkflow, candidateSequence, generateCandidateNumber, cleanText, centerScopeProfile, workflowScopeProfile, candidateAccountBatchView, centerChangeView, parseCenterChange, maskId, publicExam, examRegistrationView, examResultSummary, subjectPassText, resultRankInfo, subjectPassEvaluation, logAction, excelResourceNames, excelRowsForResource, importExcelResource, admitCardHtml, hashPassword, verifyPassword, uid, nowIso, sessions, buildWorkbook, hasExcelResource, parseWorkbook, adminLevelNames, resolveRegion } = context; 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).map(noticeForClient); 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 region = resolveRegion(body); if (!region) return sendError(response, 400, '请选择有效的省、市和区县'); Object.assign(profile, region); 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' && !item.archivedAt).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' && !item.archivedAt); 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', paidAt: null, paidBy: null, 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 payload = await cache.remember('results', `candidate:${encodeURIComponent(user.id)}`, async () => { 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); const appealInstance = db.workflowInstances.find(item => item.businessType === 'score_appeal' && item.businessId === result.id); const appeal = appealInstance ? workflowView(db, appealInstance) : null; const rank = resultRankInfo(db, result); const pass = subjectPassEvaluation(db, result, subject); return { ...result, ...rank, grade: rank.grade, examId: exam.id, examName: exam.name, examCode: exam.code, examStart: exam.examStart, archivedAt: exam.archivedAt || null, subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore, passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject), qualified: pass.qualified, appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null }; }); const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects); return { ok: true, results, summaries }; }, { ttlSeconds: resultsCacheTtlSeconds }); return sendJson(response, 200, payload); } const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/); if (request.method === 'POST' && scoreAppealMatch) { const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published); const registration = db.registrations.find(item => item.id === result?.registrationId && item.userId === user.id); if (!result || !registration) return sendError(response, 404, '已发布成绩不存在或不属于当前考生'); const exam = db.exams.find(item => item.id === registration.examId); if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩及复议入口已永久锁定'); if (pendingWorkflow(db, 'score_appeal', result.id)) return sendError(response, 409, '该科成绩已有待处理复议,请勿重复提交'); const body = await readJson(request); const reason = cleanText(body.reason, 500); if (reason.length < 5) return sendError(response, 400, '请至少填写 5 个字的复议理由'); const { instance, action } = createWorkflowSubmission(db, 'score_appeal', result.id, profile, user.id); action.note = reason; const subject = exam?.subjects.find(item => item.id === result.subjectId); const log = logAction(db, user, '提交成绩复议', `${exam?.name || ''} · ${subject?.name || ''}`); await database.createWorkflow(instance, action, log); return sendJson(response, 201, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); } 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, '考生功能接口不存在'); } return handleCandidate; }