项目迁移

This commit is contained in:
biss committed 2026-07-22 18:22:52 +08:00
commit ecb3dc63ed
68 files changed
+21894

No files matched your search

File diff suppressed because it is too large. Load diff
+356
View File
@@ -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;
}
+270
View File
@@ -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