This commit is contained in:
2026-07-21 12:49:59 +08:00 Unverified
parent 97bbe07789
commit ae528b20e9
24 changed files with 770 additions and 30 deletions
+85
View File
@@ -0,0 +1,85 @@
import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.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))),
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, requireUser, cleanText, maskId, uid, nowIso, logAction } = context;
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 = await readDb();
const school = db.schools.find(item => item.id === user.schoolId && item.active);
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
if (request.method === 'GET' && pathname === '/api/admission/context') {
return sendJson(response, 200, { ok: true, school, 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) }));
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt) });
}
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 (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)))) return sendError(response, 400, '指标分配中包含无效的生源学校 ID');
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/placements') {
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).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 === item.examId && entry.userId === item.userId);
const results = db.results.filter(entry => entry.registrationId === registration?.id && entry.published).map(result => {
const exam = db.exams.find(entry => entry.id === item.examId);
return { subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score };
});
return { ...item, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyTypes: profile.specialtyTypes || [], specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, results };
});
return sendJson(response, 200, { ok: true, school, placements });
}
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;
}