191 lines
12 KiB
JavaScript
191 lines
12 KiB
JavaScript
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;
|
|
}
|