项目迁移
This commit is contained in:
commit
ecb3dc63ed
68 files changed
+21894
No files matched your search
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;
|
||||
}
|
||||
Loaded 3 of 68 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user