Refactor exam information workflow

This commit is contained in:
2026-07-20 10:07:14 +08:00 Unverified
parent d4e085b6c5
commit 7094e9b916
22 changed files with 3233 additions and 2793 deletions
+146
View File
@@ -0,0 +1,146 @@
export function createCandidateRoutes(context) {
const {
database,
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,
logAction,
excelResourceNames,
excelRowsForResource,
importExcelResource,
admitCardHtml,
hashPassword,
verifyPassword,
uid,
nowIso,
sessions,
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames
} = 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);
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, '考生功能接口不存在');
}
return handleCandidate;
}