项目迁移
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,356 @@
|
||||
import { admissionPlanProgress, admissionRecords, admissionReportingRecord, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||
|
||||
function normalizeCategories(input, cleanText) {
|
||||
const source = Array.isArray(input) ? input : [];
|
||||
return source.map((item, index) => ({
|
||||
code: cleanText(item.code || `category_${index + 1}`, 40),
|
||||
name: cleanText(item.name, 80),
|
||||
quota: Math.max(0, Math.trunc(Number(item.quota || 0))),
|
||||
specialtyCategory: cleanText(item.specialtyCategory, 30),
|
||||
specialtyType: cleanText(item.specialtyType, 80),
|
||||
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
||||
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
||||
})).filter(item => item.sourceSchoolId && item.quota > 0)
|
||||
})).filter(item => item.code && item.name && item.quota > 0);
|
||||
}
|
||||
|
||||
export function createAdmissionRoutes(context) {
|
||||
const { database, readDb, sendJson, sendError, readJson, readBodyBuffer, sendWorkbook, buildWorkbook, parseWorkbook, requireUser, cleanText, maskId, uid, nowIso, logAction, documentVerificationSecret, admissionNoticeCode, safeCodeEqual } = context;
|
||||
|
||||
const reportingStatusByCode = { Y: 'reported', N: 'not_reported', P: 'pending' };
|
||||
const reportingCodeByStatus = { reported: 'Y', not_reported: 'N', pending: 'P' };
|
||||
|
||||
function reportingRows(db, plan, record) {
|
||||
const exam = db.exams.find(item => item.id === plan.examId) || {};
|
||||
const school = db.schools.find(item => item.id === plan.schoolId) || {};
|
||||
const rowByPlacement = new Map((record?.payload?.rows || []).map(item => [item.placementId, item]));
|
||||
const placementIds = new Set((record?.payload?.rows || []).map(item => item.placementId));
|
||||
const round = Number(record?.payload?.round || 1);
|
||||
const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.status === 'final' && (placementIds.has(item.id) || (!record && Number(item.payload?.finalizedRound || 1) === round)));
|
||||
return placements.map(placement => {
|
||||
const account = db.users.find(item => item.id === placement.userId) || {};
|
||||
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
||||
const row = rowByPlacement.get(placement.id) || {};
|
||||
return {
|
||||
placementId: placement.id,
|
||||
noticeNumber: placement.payload?.noticeNumber || '',
|
||||
candidateNumber: account.candidateNumber || '',
|
||||
name: profile.name || account.displayName || '',
|
||||
idNumberMasked: maskId(profile.idNumber),
|
||||
examCode: exam.code || '',
|
||||
schoolCode: school.code || '',
|
||||
categoryName: placement.payload?.categoryName || '',
|
||||
status: row.status || 'pending',
|
||||
statusCode: reportingCodeByStatus[row.status] || 'P',
|
||||
note: row.note || '',
|
||||
updatedAt: row.updatedAt || null
|
||||
};
|
||||
}).sort((left, right) => left.candidateNumber.localeCompare(right.candidateNumber));
|
||||
}
|
||||
|
||||
function reportingBatch(db, plan, record) {
|
||||
const exam = db.exams.find(item => item.id === plan.examId) || {};
|
||||
return { id: record?.id || '', exam: { id: exam.id, code: exam.code, name: exam.name }, round: Number(record?.payload?.round || 1), status: record?.status || 'not_started', rows: reportingRows(db, plan, record), progress: admissionPlanProgress(db, plan), supplementDecision: record?.payload?.supplementDecision || '', decisionNote: record?.payload?.decisionNote || '', approvalNote: record?.payload?.approvalNote || '', updatedAt: record?.updatedAt || null };
|
||||
}
|
||||
|
||||
function editableReportingRecord(db, examId, schoolId) {
|
||||
const setting = admissionRecords(db, 'setting', examId)[0];
|
||||
const record = admissionReportingRecord(db, examId, schoolId, Number(setting?.payload?.round || 1)) || admissionReportingRecord(db, examId, schoolId);
|
||||
return { setting, record };
|
||||
}
|
||||
|
||||
function reportingScanTarget(db, school, rawCode) {
|
||||
const match = String(rawCode || '').toUpperCase().match(/AN-[A-F0-9]{24}/);
|
||||
if (!match) return { error: [400, '未识别到有效的录取通知书防伪码'] };
|
||||
const code = match[0];
|
||||
const placement = admissionRecords(db, 'placement').find(item => item.schoolId === school.id && item.status === 'final' && safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, item, db.exams.find(exam => exam.id === item.examId) || {})));
|
||||
if (!placement) return { error: [404, '该二维码不属于本校有效录取通知书'] };
|
||||
const plan = approvedPlans(db, placement.examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, placement.examId, school.id);
|
||||
if (!plan || !record || !['draft', 'rejected'].includes(record.status) || !(record.payload?.rows || []).some(item => item.placementId === placement.id)) return { error: [409, '该考生不在当前可维护的报到批次'] };
|
||||
return { code, placement, plan, record };
|
||||
}
|
||||
|
||||
async function handleAdmission(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/admission/')) return false;
|
||||
const user = await requireUser(request, response, 'admission_school');
|
||||
if (!user) return true;
|
||||
const db = request.authDb || await readDb();
|
||||
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool);
|
||||
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
||||
|
||||
if (request.method === 'GET' && pathname === '/api/admission/context') {
|
||||
const plans = approvedPlans(db).filter(item => item.schoolId === school.id).map(item => ({ ...item, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', progress: admissionPlanProgress(db, item) }));
|
||||
const notifications = systemNotificationItems(db).filter(item => item.visible && (!item.schoolId || item.schoolId === school.id)).slice(0, 6).map(item => ({ ...item, id: item.noticeId }));
|
||||
return sendJson(response, 200, { ok: true, school, plans, notifications, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/plans') {
|
||||
const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
|
||||
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/plans') {
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
|
||||
const categories = normalizeCategories(body.categories, cleanText);
|
||||
if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数');
|
||||
if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复');
|
||||
if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应');
|
||||
if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.length)) return sendError(response, 400, '同一招生类别不能重复分配同一生源校指标');
|
||||
if (categories.some(item => item.indicatorAllocations.reduce((sum, entry) => sum + entry.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过该类别计划人数');
|
||||
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校');
|
||||
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
||||
if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整');
|
||||
const now = nowIso();
|
||||
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, userId: user.id, schoolId: school.id, createdAt: now };
|
||||
Object.assign(plan, { status: 'pending', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewNote: '' } });
|
||||
await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
|
||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/notice-template') {
|
||||
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template');
|
||||
const template = record?.payload?.template || {
|
||||
eyebrow: 'ADMISSION NOTICE', title: '录 取 通 知 书',
|
||||
body: '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。',
|
||||
footer: '请妥善保管本通知书,报到时出示。', primaryColor: '#8d2028', accentColor: '#c9a45b'
|
||||
};
|
||||
return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt), template, updatedAt: record?.updatedAt || null });
|
||||
}
|
||||
if (request.method === 'PUT' && pathname === '/api/admission/notice-template') {
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64)) || db.exams.find(item => !item.archivedAt) || db.exams[0];
|
||||
if (!exam) return sendError(response, 409, '系统中还没有可关联的考试,暂时无法保存模板');
|
||||
const template = {
|
||||
eyebrow: cleanText(body.eyebrow || 'ADMISSION NOTICE', 60),
|
||||
title: cleanText(body.title || '录 取 通 知 书', 80),
|
||||
body: cleanText(body.body, 1600), footer: cleanText(body.footer, 300),
|
||||
primaryColor: /^#[0-9a-f]{6}$/i.test(body.primaryColor) ? body.primaryColor : '#8d2028',
|
||||
accentColor: /^#[0-9a-f]{6}$/i.test(body.accentColor) ? body.accentColor : '#c9a45b'
|
||||
};
|
||||
if (!template.body) return sendError(response, 400, '请填写录取通知书正文');
|
||||
const now = nowIso();
|
||||
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template')
|
||||
|| { id: uid('notice_template'), kind: 'notification', examId: exam.id, userId: null, schoolId: school.id, status: 'template', createdAt: now };
|
||||
Object.assign(record, { examId: exam.id, updatedAt: now, payload: { template, updatedBy: user.displayName } });
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '保存录取通知书模板', school.name));
|
||||
return sendJson(response, 200, { ok: true, template, updatedAt: now });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/reporting') {
|
||||
const plans = approvedPlans(db).filter(item => item.schoolId === school.id);
|
||||
const batches = plans.map(plan => reportingBatch(db, plan, admissionReportingRecord(db, plan.examId, school.id)));
|
||||
return sendJson(response, 200, { ok: true, school, batches });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/reporting/export') {
|
||||
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record) return sendError(response, 404, '当前考试还没有可维护的报到批次');
|
||||
const rows = reportingRows(db, plan, record).map(item => ({
|
||||
noticeNumber: item.noticeNumber, candidateNumber: item.candidateNumber, name: item.name,
|
||||
examCode: item.examCode, schoolCode: item.schoolCode, categoryName: item.categoryName,
|
||||
reportingStatusCode: item.statusCode, reportingNote: item.note
|
||||
}));
|
||||
const buffer = Buffer.from(await buildWorkbook('admission_reporting', rows, { subtitle: `${record.payload?.round || 1} 轮|${school.name}` }));
|
||||
return sendWorkbook(response, buffer, `${record.payload?.round || 1}轮-${school.name}-考生报到状态.xlsx`);
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/import') {
|
||||
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能导入暂存数据');
|
||||
const imported = await parseWorkbook('admission_reporting', await readBodyBuffer(request));
|
||||
const available = reportingRows(db, plan, record);
|
||||
const byNotice = new Map(available.map(item => [item.noticeNumber, item]));
|
||||
const byCandidate = new Map(available.map(item => [item.candidateNumber, item]));
|
||||
const seen = new Set();
|
||||
const updates = [];
|
||||
const changes = [];
|
||||
let unchangedCount = 0;
|
||||
const importedAt = nowIso();
|
||||
for (const item of imported) {
|
||||
const noticeNumber = cleanText(item.noticeNumber, 100);
|
||||
const candidateNumber = cleanText(item.candidateNumber, 100);
|
||||
const target = byNotice.get(noticeNumber);
|
||||
if (!target || byCandidate.get(candidateNumber)?.placementId !== target.placementId) return sendError(response, 400, `Excel 第 ${item.__row} 行的通知书编号与报名号不属于本校当前报到批次`);
|
||||
if (seen.has(target.placementId)) return sendError(response, 400, `Excel 第 ${item.__row} 行重复填写同一考生`);
|
||||
const code = String(item.reportingStatusCode || '').trim().toUpperCase();
|
||||
if (!reportingStatusByCode[code]) return sendError(response, 400, `Excel 第 ${item.__row} 行报到状态码只能填写 Y、N 或 P`);
|
||||
seen.add(target.placementId);
|
||||
const status = reportingStatusByCode[code];
|
||||
const note = cleanText(item.reportingNote, 300);
|
||||
if (target.status === status && target.note === note) { unchangedCount += 1; continue; }
|
||||
updates.push({ placementId: target.placementId, status, note, updatedAt: importedAt, source: 'excel' });
|
||||
changes.push({ placementId: target.placementId, name: target.name, candidateNumber: target.candidateNumber, noticeNumber: target.noticeNumber, from: target.status, to: status, fromCode: target.statusCode, toCode: code, noteChanged: target.note !== note });
|
||||
}
|
||||
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||
updates.forEach(item => merged.set(item.placementId, item));
|
||||
if (updates.length) {
|
||||
record.status = 'draft'; record.updatedAt = importedAt; record.payload = { ...record.payload, rows: [...merged.values()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · 实际更新 ${updates.length} 人`));
|
||||
}
|
||||
const nextDb = updates.length ? { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) } : db;
|
||||
return sendJson(response, 200, { ok: true, count: imported.length, changedCount: updates.length, unchangedCount, changes, batch: reportingBatch(nextDb, plan, record) });
|
||||
}
|
||||
if (request.method === 'PUT' && pathname === '/api/admission/reporting/draft') {
|
||||
const body = await readJson(request);
|
||||
const examId = cleanText(body.examId, 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能修改暂存状态');
|
||||
const available = new Set(reportingRows(db, plan, record).map(item => item.placementId));
|
||||
const updates = (Array.isArray(body.rows) ? body.rows : []).map(item => ({ placementId: cleanText(item.placementId, 64), status: cleanText(item.status, 30), note: cleanText(item.note, 300), updatedAt: nowIso(), source: 'manual' }));
|
||||
if (!updates.length || updates.some(item => !available.has(item.placementId) || !['pending', 'reported', 'not_reported'].includes(item.status))) return sendError(response, 400, '报到暂存数据无效');
|
||||
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||
updates.forEach(item => merged.set(item.placementId, item));
|
||||
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '暂存考生报到状态', `${school.name} · ${updates.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan-preview') {
|
||||
const body = await readJson(request);
|
||||
const target = reportingScanTarget(db, school, body.code);
|
||||
if (target.error) return sendError(response, ...target.error);
|
||||
const { code, placement, plan, record } = target;
|
||||
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||||
const row = reportingRows(db, plan, record).find(item => item.placementId === placement.id);
|
||||
return sendJson(response, 200, { ok: true, code, examId: placement.examId, row });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan') {
|
||||
const body = await readJson(request);
|
||||
const target = reportingScanTarget(db, school, body.code);
|
||||
if (target.error) return sendError(response, ...target.error);
|
||||
const { placement, plan, record } = target;
|
||||
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||||
const status = cleanText(body.status, 30);
|
||||
if (!['reported', 'not_reported', 'pending'].includes(status)) return sendError(response, 400, '请选择有效的报到确认状态');
|
||||
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||
const fallbackNote = status === 'reported' ? '扫描录取通知书二维码确认报到' : status === 'not_reported' ? '扫描录取通知书二维码确认未报到' : '扫描录取通知书二维码后暂待确认';
|
||||
merged.set(placement.id, { placementId: placement.id, status, note: cleanText(body.note, 300) || fallbackNote, updatedAt: nowIso(), source: 'qr_scan' });
|
||||
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '扫码确认并暂存考生报到', `${school.name} · ${placement.payload?.noticeNumber || placement.id} · ${reportingCodeByStatus[status]}`));
|
||||
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||
return sendJson(response, 200, { ok: true, row: reportingRows(nextDb, plan, record).find(item => item.placementId === placement.id), batch: reportingBatch(nextDb, plan, record) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/submit') {
|
||||
const body = await readJson(request);
|
||||
const examId = cleanText(body.examId, 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能提交');
|
||||
const rows = reportingRows(db, plan, record);
|
||||
if (rows.some(item => item.status === 'pending')) return sendError(response, 409, `仍有 ${rows.filter(item => item.status === 'pending').length} 名考生待确认,请全部标记后提交`);
|
||||
record.status = 'submitted'; record.updatedAt = nowIso(); record.payload = { ...record.payload, submittedAt: record.updatedAt, submittedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '提交考生报到情况', `${school.name} · ${rows.length} 人`));
|
||||
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||
return sendJson(response, 200, { ok: true, batch: reportingBatch(nextDb, plan, record) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/decision') {
|
||||
const body = await readJson(request);
|
||||
const examId = cleanText(body.examId, 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record || record.status !== 'submitted') return sendError(response, 409, '请先提交本轮考生报到情况');
|
||||
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||
const progress = admissionPlanProgress(nextDb, plan);
|
||||
const supplement = body.supplement === true && progress.reportingGap > 0;
|
||||
const decisionNote = cleanText(body.decisionNote, 500);
|
||||
if (supplement && decisionNote.length < 4) return sendError(response, 400, '申请补录时请填写至少 4 个字的补录说明');
|
||||
record.status = 'pending_approval'; record.updatedAt = nowIso(); record.payload = { ...record.payload, supplementDecision: supplement ? 'supplement' : 'no_supplement', decisionNote: decisionNote || (progress.reportingGap ? '经学校研究决定,本轮不进行补录。' : '本校招生计划已完成。'), decisionSubmittedAt: record.updatedAt, decisionSubmittedBy: user.displayName, statistics: progress };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, supplement ? '提交补录申请' : '提交不补录决定', `${school.name} · 缺额 ${progress.reportingGap} 人`));
|
||||
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/placements') {
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const registrationByExamUser = new Map(db.registrations.map(item => [`${item.examId}\u0000${item.userId}`, item]));
|
||||
const publishedResultsByRegistration = new Map();
|
||||
for (const result of db.results) {
|
||||
if (!result.published) continue;
|
||||
const rows = publishedResultsByRegistration.get(result.registrationId) || [];
|
||||
rows.push(result);
|
||||
publishedResultsByRegistration.set(result.registrationId, rows);
|
||||
}
|
||||
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
|
||||
const account = accountById.get(item.userId) || {};
|
||||
const profile = profileByUserId.get(item.userId) || {};
|
||||
const exam = examById.get(item.examId);
|
||||
const registration = registrationByExamUser.get(`${item.examId}\u0000${item.userId}`);
|
||||
const results = (publishedResultsByRegistration.get(registration?.id) || []).map(result => ({ subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
return { ...item, examName: exam?.name || item.examId, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
|
||||
});
|
||||
const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final'));
|
||||
return sendJson(response, 200, { ok: true, school, placements, completedExams });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/placements/export') {
|
||||
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||
const exam = db.exams.find(item => item.id === examId);
|
||||
const setting = admissionRecords(db, 'setting', examId)[0];
|
||||
if (!exam || setting?.status !== 'completed') return sendError(response, 409, '录取工作结束后才能下载正式录取名单');
|
||||
const rows = admissionRecords(db, 'placement', examId).filter(item => item.schoolId === school.id && item.status === 'final').map(item => {
|
||||
const account = db.users.find(entry => entry.id === item.userId) || {};
|
||||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
||||
const registration = db.registrations.find(entry => entry.examId === examId && entry.userId === item.userId) || {};
|
||||
const sourceSchool = db.schools.find(entry => entry.id === profile.schoolId) || {};
|
||||
const schoolClass = db.classes.find(entry => entry.id === profile.classId) || {};
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
const scoreRows = db.results.filter(entry => entry.registrationId === registration.id && entry.published).map(result => ({ name: exam.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||||
return {
|
||||
candidateNumber: account.candidateNumber || registration.registrationNumber || '', name: profile.name || account.displayName || '', gender: profile.gender || '',
|
||||
idNumber: profile.idNumber || '', phone: profile.phone || '', email: profile.email || '', birthDate: profile.birthDate || '', ethnicity: profile.ethnicity || '', nativePlace: profile.nativePlace || '',
|
||||
sourceSchool: sourceSchool.name || profile.school || '', sourceSchoolCode: sourceSchool.code || '', className: schoolClass.name || profile.grade || '',
|
||||
address: [profile.provinceName, profile.cityName, profile.districtName, profile.address].filter(Boolean).join(' '), guardianName: profile.guardianName || profile.emergencyContact || '', guardianPhone: profile.guardianPhone || profile.emergencyPhone || '',
|
||||
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生', specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '',
|
||||
featureScore: Number(registration.featureScore || 0), subjectScores: scoreRows.map(score => `${score.name} ${score.score}`).join(';'), totalScore: Number(item.payload?.totalScore || 0),
|
||||
admittedSchool: school.name, categoryName: item.payload?.categoryName || '', preferenceOrder: Number(item.payload?.preferenceOrder || 0)
|
||||
};
|
||||
});
|
||||
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` }));
|
||||
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/placements/bulk') {
|
||||
const body = await readJson(request);
|
||||
const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).map(id => cleanText(id, 64)).filter(Boolean))];
|
||||
const decision = cleanText(body.decision, 30);
|
||||
const note = cleanText(body.note, 500);
|
||||
if (!ids.length) return sendError(response, 400, '请至少选择一名待审核考生');
|
||||
if (!['accept', 'withdraw'].includes(decision)) return sendError(response, 400, '请选择接收或申请退档');
|
||||
if (decision === 'withdraw' && note.length < 8) return sendError(response, 400, '批量申请退档必须填写至少 8 个字的特殊理由');
|
||||
const placements = admissionRecords(db, 'placement').filter(item => ids.includes(item.id) && item.schoolId === school.id && item.status === 'school_review');
|
||||
if (placements.length !== ids.length) return sendError(response, 409, '所选记录中包含已处理或不属于本校的投档记录,请刷新后重试');
|
||||
const now = nowIso();
|
||||
for (const placement of placements) {
|
||||
placement.status = decision === 'accept' ? 'admitted' : 'withdrawal_pending';
|
||||
placement.payload.schoolDecisionNote = note;
|
||||
if (decision === 'withdraw') placement.payload.withdrawalReason = note;
|
||||
placement.updatedAt = now;
|
||||
}
|
||||
await database.saveAdmissionRecords(placements, logAction(db, user, decision === 'accept' ? '批量接收投档考生' : '批量申请退档', `${school.name} · ${placements.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, count: placements.length, decision });
|
||||
}
|
||||
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && placementMatch) {
|
||||
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
|
||||
if (!placement || placement.status !== 'school_review') return sendError(response, 404, '待审核投档记录不存在');
|
||||
const body = await readJson(request);
|
||||
const decision = cleanText(body.decision, 30);
|
||||
const note = cleanText(body.note, 500);
|
||||
if (decision === 'accept') placement.status = 'admitted';
|
||||
else if (decision === 'withdraw') {
|
||||
if (note.length < 8) return sendError(response, 400, '申请退档必须填写至少 8 个字的特殊理由');
|
||||
placement.status = 'withdrawal_pending';
|
||||
placement.payload.withdrawalReason = note;
|
||||
} else return sendError(response, 400, '请选择接收或申请退档');
|
||||
placement.payload.schoolDecisionNote = note;
|
||||
placement.updatedAt = nowIso();
|
||||
await database.saveAdmissionRecord(placement, logAction(db, user, decision === 'accept' ? '接收投档考生' : '申请退档', `${school.name} · ${placement.id}`));
|
||||
return sendJson(response, 200, { ok: true, placement });
|
||||
}
|
||||
return sendError(response, 404, '招生学校功能接口不存在');
|
||||
}
|
||||
|
||||
return handleAdmission;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
assertTotpConfiguration,
|
||||
buildOtpAuthUri,
|
||||
consumeRecoveryCode,
|
||||
createRecoveryCodes,
|
||||
createTotpSecret,
|
||||
decryptTotpSecret,
|
||||
encryptTotpSecret,
|
||||
hashRecoveryCode,
|
||||
verifyTotp
|
||||
} from '../security/totp.mjs';
|
||||
|
||||
export function createAuthRoutes(context) {
|
||||
assertTotpConfiguration();
|
||||
const {
|
||||
database,
|
||||
readDb,
|
||||
sendJson,
|
||||
sendError,
|
||||
readJson,
|
||||
readBodyBuffer,
|
||||
sendWorkbook,
|
||||
currentUser,
|
||||
parseCookies,
|
||||
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,
|
||||
randomBytes,
|
||||
uid,
|
||||
nowIso,
|
||||
authState,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
adminLevelNames,
|
||||
permissionsByLevel
|
||||
} = context;
|
||||
|
||||
async function issueSession(user) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await authState.createSession(token, user.id);
|
||||
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
||||
return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=${authState.sessionTtlSeconds}` };
|
||||
}
|
||||
|
||||
function sessionToken(request) {
|
||||
return parseCookies(request).hz_session || '';
|
||||
}
|
||||
|
||||
function verifySecondFactor(user, code) {
|
||||
if (!user.totpEnabled || !user.totpSecretEncrypted) return null;
|
||||
const normalized = String(code || '').trim();
|
||||
if (/^\d{6}$/.test(normalized)) {
|
||||
const step = verifyTotp(normalized, decryptTotpSecret(user.totpSecretEncrypted), { lastUsedStep: user.totpLastUsedStep });
|
||||
return step == null ? null : { type: 'totp', step };
|
||||
}
|
||||
const recoveryCodes = consumeRecoveryCode(normalized, user.totpRecoveryCodes || []);
|
||||
return recoveryCodes ? { type: 'recovery', recoveryCodes } : null;
|
||||
}
|
||||
|
||||
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 && item.isSourceSchool);
|
||||
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 || user.archivedAt || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
|
||||
if (user.totpEnabled) {
|
||||
const challenge = randomBytes(32).toString('base64url');
|
||||
await authState.createLoginChallenge(challenge, user.id);
|
||||
return sendJson(response, 200, { ok: true, requiresTotp: true, challenge });
|
||||
}
|
||||
const session = await issueSession(user);
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': session.cookie });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/login/totp') {
|
||||
const body = await readJson(request);
|
||||
const challengeKey = String(body.challenge || '');
|
||||
const challenge = await authState.getLoginChallenge(challengeKey);
|
||||
if (!challenge || challenge.attempts >= 5) {
|
||||
await authState.deleteLoginChallenge(challengeKey);
|
||||
return sendError(response, 401, '验证请求已过期,请重新输入账号和密码');
|
||||
}
|
||||
const db = await readDb();
|
||||
const user = db.users.find(item => item.id === challenge.userId);
|
||||
if (!user || !user.totpEnabled || user.active === false || user.archivedAt) {
|
||||
await authState.deleteLoginChallenge(challengeKey);
|
||||
return sendError(response, 401, '验证请求已失效,请重新登录');
|
||||
}
|
||||
let verified = null;
|
||||
try { verified = verifySecondFactor(user, body.code); } catch {}
|
||||
if (!verified) {
|
||||
const failure = await authState.recordLoginChallengeFailure(challengeKey, 5);
|
||||
return sendError(response, 401, failure?.exhausted ? '验证失败次数过多,请重新登录' : failure ? '验证码或恢复码不正确' : '验证请求已过期,请重新输入账号和密码');
|
||||
}
|
||||
if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
|
||||
else user.totpRecoveryCodes = verified.recoveryCodes;
|
||||
const log = verified.type === 'recovery' ? logAction(db, user, '使用 TOTP 恢复码登录', user.username) : null;
|
||||
await database.updateTotpSecurity(user, log);
|
||||
await authState.deleteLoginChallenge(challengeKey);
|
||||
const session = await issueSession(user);
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user), usedRecoveryCode: verified.type === 'recovery' }, { 'Set-Cookie': session.cookie });
|
||||
}
|
||||
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 === 'GET' && pathname === '/api/auth/totp') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
enabled: Boolean(user.totpEnabled),
|
||||
recoveryCodesRemaining: user.totpEnabled ? (user.totpRecoveryCodes || []).length : 0
|
||||
});
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/setup') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
if (user.mustChangePassword) return sendError(response, 400, '请先修改初始密码,再启用二次验证');
|
||||
if (user.totpEnabled) return sendError(response, 409, '当前账号已经启用 TOTP 二次验证');
|
||||
const body = await readJson(request);
|
||||
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
const db = await readDb();
|
||||
const issuer = cleanText(db.organization?.name || '考试服务平台', 80);
|
||||
const secret = createTotpSecret();
|
||||
const uri = buildOtpAuthUri({ secret, account: user.candidateNumber || user.username, issuer });
|
||||
const token = sessionToken(request);
|
||||
await authState.createTotpSetup(token, user.id, secret);
|
||||
const qrCode = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
|
||||
return sendJson(response, 200, { ok: true, secret, uri, qrCode, expiresIn: 600 });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/enable') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
const token = sessionToken(request);
|
||||
const setup = await authState.getTotpSetup(token);
|
||||
if (!setup || setup.userId !== user.id) {
|
||||
await authState.deleteTotpSetup(token);
|
||||
return sendError(response, 400, '绑定信息已过期,请重新开始');
|
||||
}
|
||||
const body = await readJson(request);
|
||||
const step = verifyTotp(body.code, setup.secret);
|
||||
if (step == null) return sendError(response, 400, '动态验证码不正确,请确认设备时间准确后重试');
|
||||
const recoveryCodes = createRecoveryCodes();
|
||||
user.totpEnabled = true;
|
||||
user.totpSecretEncrypted = encryptTotpSecret(setup.secret);
|
||||
user.totpRecoveryCodes = recoveryCodes.map(hashRecoveryCode);
|
||||
user.totpLastUsedStep = step;
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '启用 TOTP 二次验证', user.username);
|
||||
await database.updateTotpSecurity(user, log);
|
||||
await authState.deleteTotpSetup(token);
|
||||
return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
if (!user.totpEnabled) return sendError(response, 400, '当前账号尚未启用 TOTP 二次验证');
|
||||
const body = await readJson(request);
|
||||
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
let verified = null;
|
||||
try { verified = verifySecondFactor(user, body.code); } catch {}
|
||||
if (!verified) return sendError(response, 400, '动态验证码或恢复码不正确');
|
||||
const recoveryCodes = createRecoveryCodes();
|
||||
user.totpRecoveryCodes = recoveryCodes.map(hashRecoveryCode);
|
||||
if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '重新生成 TOTP 恢复码', user.username);
|
||||
await database.updateTotpSecurity(user, log);
|
||||
return sendJson(response, 200, { ok: true, recoveryCodes });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/disable') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
if (!user.totpEnabled) return sendError(response, 400, '当前账号尚未启用 TOTP 二次验证');
|
||||
const body = await readJson(request);
|
||||
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
let verified = null;
|
||||
try { verified = verifySecondFactor(user, body.code); } catch {}
|
||||
if (!verified) return sendError(response, 400, '动态验证码或恢复码不正确');
|
||||
user.totpEnabled = false;
|
||||
user.totpSecretEncrypted = null;
|
||||
user.totpRecoveryCodes = [];
|
||||
user.totpLastUsedStep = null;
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '关闭 TOTP 二次验证', user.username);
|
||||
await database.updateTotpSecurity(user, log);
|
||||
await authState.deleteTotpSetup(sessionToken(request));
|
||||
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) await authState.deleteSession(token);
|
||||
return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return handleAuth;
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota, supplementarySchoolIds } from '../services/volunteer-admission.mjs';
|
||||
import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs';
|
||||
import QRCode from 'qrcode';
|
||||
import { systemNotificationItems } from '../services/system-notifications.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,
|
||||
documentVerificationSecret,
|
||||
scoreReportCode,
|
||||
admissionNoticeCode,
|
||||
subjectPassEvaluation,
|
||||
logAction,
|
||||
excelResourceNames,
|
||||
excelRowsForResource,
|
||||
importExcelResource,
|
||||
admitCardHtml,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
uid,
|
||||
nowIso,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
adminLevelNames,
|
||||
resolveRegion
|
||||
} = context;
|
||||
|
||||
const verificationUrl = (request, code) => {
|
||||
const protocol = String(request.headers['x-forwarded-proto'] || '').split(',')[0].trim() || (process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
||||
const host = request.headers.host || `${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 4173}`;
|
||||
return `${protocol}://${host}/#verify/${encodeURIComponent(code)}`;
|
||||
};
|
||||
const verificationQr = (request, code) => QRCode.toDataURL(verificationUrl(request, code), { errorCorrectionLevel: 'M', margin: 1, width: 320 });
|
||||
|
||||
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').map(noticeForClient),
|
||||
...systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }))
|
||||
].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/notices') {
|
||||
const notices = [
|
||||
...db.notices.filter(item => item.status === 'published').map(noticeForClient),
|
||||
...systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }))
|
||||
].sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
|
||||
return sendJson(response, 200, { ok: true, 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 && item.isSourceSchool), 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', 'specialtyCertificate', 'policyEligibility'];
|
||||
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
|
||||
profile.specialtyCategory = cleanText(body.specialtyCategory, 30);
|
||||
profile.specialtyType = cleanText(body.specialtyType, 40);
|
||||
if (!isValidSpecialty(profile.specialtyCategory, profile.specialtyType)) return sendError(response, 400, '请选择对应的特长生大类和小类');
|
||||
profile.specialtyTypes = profile.specialtyType ? [profile.specialtyType] : [];
|
||||
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 && item.isSourceSchool);
|
||||
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 = await Promise.all(registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects).map(async summary => {
|
||||
const registration = registrations.find(item => item.examId === summary.examId);
|
||||
const exam = db.exams.find(item => item.id === summary.examId);
|
||||
const reportResults = db.results.filter(item => item.registrationId === registration?.id && item.published);
|
||||
const verificationCode = registration && exam ? scoreReportCode(documentVerificationSecret, registration, exam, reportResults) : '';
|
||||
return { ...summary, verificationCode, verificationQr: verificationCode ? await verificationQr(request, verificationCode) : '' };
|
||||
}));
|
||||
return { ok: true, results, summaries, candidate: { name: profile.name || user.displayName, candidateNumber: user.candidateNumber || '' } };
|
||||
}, { ttlSeconds: resultsCacheTtlSeconds });
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/admissions') {
|
||||
const settings = (await Promise.all(admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(async setting => {
|
||||
const exam = db.exams.find(item => item.id === setting.examId);
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const preference = activePreference(db, setting.examId, user.id, round);
|
||||
const preferenceView = preference ? {
|
||||
...preference,
|
||||
payload: {
|
||||
...preference.payload,
|
||||
choices: (preference.payload?.choices || []).map(choice => {
|
||||
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||
const categories = admissionRecords(db, 'plan', setting.examId)
|
||||
.filter(item => item.schoolId === choice.schoolId)
|
||||
.flatMap(item => item.payload?.categories || []);
|
||||
const category = categories.find(item => item.code === choice.categoryCode)
|
||||
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|
||||
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|
||||
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
|
||||
return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' };
|
||||
})
|
||||
}
|
||||
} : null;
|
||||
const qualification = indicatorQualification(db, setting.examId, user.id);
|
||||
const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
|
||||
const blockingPlacement = setting.status === 'supplementary'
|
||||
? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status))
|
||||
: null;
|
||||
const supplementEligible = !blockingPlacement;
|
||||
const supplementIneligibilityReason = blockingPlacement?.status === 'forfeited'
|
||||
? '因本轮未按规定完成报到,不能再次参加补录。'
|
||||
: blockingPlacement
|
||||
? '你已被录取,本轮补录无需且不能再次填报。'
|
||||
: '';
|
||||
const supplementarySchools = supplementarySchoolIds(db, setting);
|
||||
const plans = (supplementEligible ? approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId)) : []).map(plan => {
|
||||
const school = db.schools.find(item => item.id === plan.schoolId);
|
||||
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status));
|
||||
return {
|
||||
id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '',
|
||||
categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => {
|
||||
const indicatorAllocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
|
||||
const indicatorUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
|
||||
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
|
||||
const generalUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === 'general').length;
|
||||
const indicatorRemaining = Math.max(0, Number(indicatorAllocation?.quota || 0) - indicatorUsed);
|
||||
const generalRemaining = Math.max(0, generalQuota - generalUsed);
|
||||
const preferenceTypes = [generalRemaining > 0 ? 'general' : '', qualification?.payload?.eligible && indicatorRemaining > 0 ? 'indicator' : ''].filter(Boolean);
|
||||
return { ...category, generalRemaining, indicatorRemaining, preferenceTypes };
|
||||
}).filter(category => category.preferenceTypes.length)
|
||||
};
|
||||
}).filter(plan => plan.categories.length);
|
||||
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
|
||||
const submissionCount = Number(preference?.payload?.submissionCount || 0);
|
||||
const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3));
|
||||
const school = placement ? db.schools.find(item => item.id === placement.schoolId) : null;
|
||||
const templateRecord = placement ? admissionRecords(db, 'notification').find(item => item.schoolId === placement.schoolId && item.status === 'template') : null;
|
||||
const noticeTemplate = templateRecord?.payload?.template || null;
|
||||
const noticeVerificationCode = placement?.status === 'final' && exam ? admissionNoticeCode(documentVerificationSecret, placement, exam) : '';
|
||||
const noticeVerificationQr = noticeVerificationCode ? await verificationQr(request, noticeVerificationCode) : '';
|
||||
return { ...setting, exam: exam ? publicExam(exam) : null, preference: preferenceView, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, noticeVerificationQr, noticeNumber: placement?.payload?.noticeNumber || '', plans, supplementEligible, supplementIneligibilityReason, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
|
||||
}))).filter(item => item.exam);
|
||||
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id).map(item => {
|
||||
const placement = admissionRecords(db, 'placement', item.examId).find(entry => entry.id === item.payload?.placementId);
|
||||
const school = db.schools.find(entry => entry.id === (placement?.schoolId || item.schoolId));
|
||||
const exam = db.exams.find(entry => entry.id === item.examId);
|
||||
return { ...item, examName: exam?.name || '', schoolName: school?.name || '', schoolCode: school?.code || '', categoryName: placement?.payload?.categoryName || '', noticeNumber: placement?.payload?.noticeNumber || '', placementStatus: placement?.status || '' };
|
||||
}).sort((left, right) => new Date(right.createdAt) - new Date(left.createdAt));
|
||||
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
|
||||
}
|
||||
const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/);
|
||||
if (request.method === 'PUT' && preferenceMatch) {
|
||||
const setting = admissionSetting(db, preferenceMatch[1]);
|
||||
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报');
|
||||
if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段');
|
||||
if (setting.status === 'supplementary') {
|
||||
const blockingPlacement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status));
|
||||
if (blockingPlacement?.status === 'forfeited') return sendError(response, 403, '因未按规定完成报到,本轮不能再次参加补录');
|
||||
if (blockingPlacement) return sendError(response, 403, '你已被录取,本轮补录不能再次填报');
|
||||
}
|
||||
const now = Date.now();
|
||||
if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始');
|
||||
if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止');
|
||||
if (candidateTotalScore(db, setting.examId, user.id) == null) return sendError(response, 403, '本场考试成绩全部发布后才能填报志愿');
|
||||
const body = await readJson(request);
|
||||
const maxChoices = Math.max(1, Number(setting.payload.maxChoices || 5));
|
||||
const round = Number(setting.payload.round || 1);
|
||||
const currentPreference = activePreference(db, setting.examId, user.id, round);
|
||||
const maxSubmissions = Math.max(1, Number(setting.payload.maxSubmissions || 3));
|
||||
const submissionCount = Number(currentPreference?.payload?.submissionCount || 0);
|
||||
if (submissionCount >= maxSubmissions) return sendError(response, 409, `志愿已达到 ${maxSubmissions} 次提交上限,现已自动锁定`);
|
||||
const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices + 1).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40), preferenceType: item.preferenceType === 'indicator' ? 'indicator' : 'general' }));
|
||||
if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
|
||||
const indicatorChoices = choices.filter(item => item.preferenceType === 'indicator');
|
||||
const generalChoices = choices.filter(item => item.preferenceType === 'general');
|
||||
if (indicatorChoices.length > 1 || generalChoices.length > maxChoices) return sendError(response, 400, `本轮最多填报 1 个指标分配志愿和 ${maxChoices} 个普通志愿`);
|
||||
if (indicatorChoices.length && choices[0].preferenceType !== 'indicator') return sendError(response, 400, '指标分配志愿必须位于专用第一栏');
|
||||
if (new Set(choices.map(item => `${item.preferenceType}|${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同类志愿中同一学校和招生类别不能重复填报');
|
||||
const supplementarySchools = supplementarySchoolIds(db, setting);
|
||||
const plans = approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId));
|
||||
const indicator = indicatorQualification(db, setting.examId, user.id);
|
||||
const invalidChoice = choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => {
|
||||
if (category.code !== choice.categoryCode || !candidateEligibleForCategory(profile, category)) return false;
|
||||
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status));
|
||||
if (choice.preferenceType === 'indicator') {
|
||||
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
|
||||
const used = placements.filter(item => item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
|
||||
return indicator?.payload?.eligible === true && Number(allocation?.quota || 0) > used;
|
||||
}
|
||||
const quota = Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0);
|
||||
return quota > placements.filter(item => item.payload?.quotaBucket === 'general').length;
|
||||
})));
|
||||
if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别');
|
||||
const nowValue = nowIso();
|
||||
const preference = currentPreference || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
|
||||
const storedChoices = choices.map(choice => {
|
||||
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||
const category = plans.find(plan => plan.schoolId === choice.schoolId)?.payload?.categories?.find(item => item.code === choice.categoryCode);
|
||||
return { ...choice, schoolCode: school?.code || '', schoolName: school?.name || '', categoryName: category?.name || '' };
|
||||
});
|
||||
Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices: storedChoices, submittedAt: nowValue, submissionCount: submissionCount + 1 } });
|
||||
await database.saveAdmissionRecord(preference);
|
||||
return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' });
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
import { admissionRecords, admissionRoundPublications, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
||||
import { specialtyLabel } from '../data/specialty-types.mjs';
|
||||
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||
|
||||
export function createPublicRoutes(context) {
|
||||
const {
|
||||
database,
|
||||
cache,
|
||||
readDb,
|
||||
publicSiteConfig,
|
||||
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,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
adminLevelNames
|
||||
, documentVerificationSecret, scoreReportCode, admissionNoticeCode, safeCodeEqual
|
||||
} = context;
|
||||
|
||||
async function handlePublic(pathname, response) {
|
||||
const verificationMatch = pathname.match(/^\/api\/public\/verifications\/([^/]+)$/);
|
||||
if (verificationMatch) {
|
||||
const db = await readDb();
|
||||
const code = decodeURIComponent(verificationMatch[1]).toUpperCase();
|
||||
const hideName = value => value ? `${value.slice(0, 1)}${'*'.repeat(Math.max(1, value.length - 1))}` : '';
|
||||
if (code.startsWith('SR-')) {
|
||||
for (const registration of db.registrations) {
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
const results = db.results.filter(item => item.registrationId === registration.id && item.published);
|
||||
if (!exam || !results.length || !safeCodeEqual(code, scoreReportCode(documentVerificationSecret, registration, exam, results))) continue;
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {};
|
||||
const user = db.users.find(item => item.id === registration.userId) || {};
|
||||
return sendJson(response, 200, { ok: true, verified: true, document: { type: 'score-report', typeName: '考生成绩单', candidateName: hideName(profile.name || user.displayName), candidateNumber: String(user.candidateNumber || registration.registrationNumber || '').replace(/^(.{3}).+(.{3})$/, '$1****$2'), examName: exam.name, subjectCount: results.length, totalScore: Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2)), issuedAt: [...results].sort((a, b) => new Date(b.publishedAt || b.updatedAt) - new Date(a.publishedAt || a.updatedAt))[0]?.publishedAt } });
|
||||
}
|
||||
}
|
||||
if (code.startsWith('AN-')) {
|
||||
for (const placement of admissionRecords(db, 'placement').filter(item => item.status === 'final')) {
|
||||
const exam = db.exams.find(item => item.id === placement.examId);
|
||||
if (!exam || !safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, placement, exam))) continue;
|
||||
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
||||
const school = db.schools.find(item => item.id === placement.schoolId) || {};
|
||||
return sendJson(response, 200, { ok: true, verified: true, document: { type: 'admission-notice', typeName: '录取通知书', noticeNumber: placement.payload?.noticeNumber || '', candidateName: hideName(profile.name), examName: exam.name, schoolName: school.name, categoryName: placement.payload?.categoryName || '', issuedAt: placement.updatedAt } });
|
||||
}
|
||||
}
|
||||
return sendError(response, 404, '未查询到有效文书,请核对防伪码');
|
||||
}
|
||||
if (pathname === '/api/public/home') {
|
||||
const payload = await cache.remember('public', 'home', async () => {
|
||||
const db = await readDb();
|
||||
const manualNotices = db.notices.filter(item => item.status === 'published').map(noticeForClient);
|
||||
const automaticNotices = systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }));
|
||||
const publishedNotices = [...manualNotices, ...automaticNotices].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' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
|
||||
});
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
if (pathname === '/api/public/announcements') {
|
||||
const payload = await cache.remember('public', 'admission-announcements', async () => {
|
||||
const db = await readDb();
|
||||
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved' && item.payload?.publicVisible !== false).map(item => ({
|
||||
id: item.id,
|
||||
examId: item.examId,
|
||||
examName: db.exams.find(exam => exam.id === item.examId)?.name || '',
|
||||
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
|
||||
publishedAt: item.payload?.reviewedAt || item.updatedAt,
|
||||
note: item.payload?.note || '',
|
||||
rows: (item.payload?.categories || []).map(category => ({
|
||||
code: category.code,
|
||||
name: category.name,
|
||||
quota: Number(category.quota || 0),
|
||||
specialtyCategory: category.specialtyCategory || '',
|
||||
specialtyType: category.specialtyType || '',
|
||||
specialtyLabel: specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类',
|
||||
indicatorQuota: (category.indicatorAllocations || []).reduce((sum, allocation) => sum + Number(allocation.quota || 0), 0),
|
||||
indicatorAllocations: (category.indicatorAllocations || []).map(allocation => ({
|
||||
sourceSchoolName: db.schools.find(school => school.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId,
|
||||
quota: Number(allocation.quota || 0)
|
||||
}))
|
||||
}))
|
||||
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
|
||||
id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
|
||||
rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
|
||||
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||
const roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', round: item.round, title: `${db.exams.find(exam => exam.id === item.examId)?.name || ''}第 ${item.round} 轮录取名单公示`, publishedAt: item.publishedAt, rows: item.rows }));
|
||||
const admissions = [...roundAdmissions, ...admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', title: `${db.exams.find(item => item.id === setting.examId)?.name || ''}最终录取名单`, publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) }))].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||
const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting' && item.visible).map(item => ({ id: item.id, examId: item.examId, schoolId: item.schoolId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', title: item.title, summary: item.summary, publishedAt: item.publishAt, statistics: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.statistics || {}, supplementDecision: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.supplementDecision || '', decisionNote: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.decisionNote || '' }));
|
||||
return { ok: true, plans, qualifications, admissions, cutoffs, reports };
|
||||
});
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
|
||||
if (noticeMatch) {
|
||||
const notice = await cache.remember('public', `notice:${encodeURIComponent(noticeMatch[1])}`, async () => {
|
||||
const db = await readDb();
|
||||
const found = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
|
||||
if (found) return noticeForClient(found);
|
||||
const systemNotice = systemNotificationItems(db).find(item => item.noticeId === noticeMatch[1] && item.visible);
|
||||
return systemNotice ? { ...systemNotice, id: systemNotice.noticeId } : null;
|
||||
});
|
||||
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return handlePublic;
|
||||
}
|
||||
Reference in New Issue
Block a user