Files
EIS-dotnet/src/routes/admin.routes.mjs
T
2026-07-22 18:22:52 +08:00

1882 lines
151 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
import { activePreference, admissionCutoffRows, admissionPhases, admissionPlanProgress, admissionRecords, admissionReportingRecord, admissionRoundPublications, admissionSetting, assignAdmissionNoticeNumbers, approvedPlans, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
import { systemNotificationItems } from '../services/system-notifications.mjs';
export function createAdminRoutes(context) {
const {
database,
cache,
readDb,
sendJson,
sendError,
readJson,
readBodyBuffer,
sendWorkbook,
currentUser,
safeUser,
requireUser,
hasPermission,
requirePermission,
profileInScope,
registrationInScope,
adminScopeLabel,
adminsForStep,
selectAdminForStep,
activeWorkflow,
createWorkflowSubmission,
workflowView,
pendingWorkflow,
candidateSequence,
generateCandidateNumber,
cleanText,
centerScopeProfile,
workflowScopeProfile,
candidateAccountBatchView,
centerChangeView,
parseCenterChange,
maskId,
publicExam,
examResultSummary,
subjectPassText,
resultRankInfo,
subjectPassEvaluation,
examRegistrationView,
logAction,
excelResourceNames,
excelRowsForResource,
admissionRowsForRegistrations,
centerMaterialRows,
importExcelResource,
prepareResultImport,
commitResultImport,
admitCardHtml,
admitCardsHtml,
hashPassword,
verifyPassword,
randomBytes,
uid,
nowIso,
authState,
buildWorkbook,
buildCenterMaterialsWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames,
permissionsByLevel
} = context;
const passPolicies = new Set(['fixed_score', 'rank_percent', 'subject_scores', 'none']);
const subjectPassRules = new Set(['fixed_score', 'rank_percent', 'none']);
function normalizeSubjects(input, examStart) {
const source = Array.isArray(input) ? input : String(input || '').split(/[,]/);
return source.map((item, index) => {
const structured = item && typeof item === 'object';
const name = cleanText(structured ? item.name : item, 50);
if (!name) return null;
const fullScore = Number(structured ? item.fullScore : 150);
const requestedRule = item?.passRule === 'score_ratio' ? 'rank_percent' : item?.passRule;
const passRule = subjectPassRules.has(requestedRule) ? requestedRule : 'fixed_score';
const passValue = passRule === 'none' ? 0 : Number(structured ? (item.passValue ?? item.passScore ?? fullScore * .6) : fullScore * .6);
const passScore = passRule === 'fixed_score' ? Number(passValue.toFixed(2)) : null;
return {
id: uid('sub'),
name,
date: cleanText(structured ? item.date : '', 10) || String(examStart).slice(0, 10),
start: cleanText(structured ? item.start : '', 5) || '09:00',
end: cleanText(structured ? item.end : '', 5) || '11:00',
fee: Number(structured ? item.fee ?? 0 : 0),
fullScore,
passRule,
passValue,
passScore,
order: index + 1
};
}).filter(Boolean);
}
function validateExamScoring(subjects, passPolicy, passValue) {
if (!subjects.length) return '请至少添加一个考试科目';
if (subjects.some(item => !Number.isFinite(item.fullScore) || item.fullScore <= 0 || item.fullScore > 1000)) return '科目满分必须大于 0 且不超过 1000';
if (subjects.some(item => !subjectPassRules.has(item.passRule))) return '请选择有效的单科合格线计算方式';
if (subjects.some(item => item.passRule === 'fixed_score' && (!Number.isFinite(item.passValue) || item.passValue < 0 || item.passValue > item.fullScore))) return '固定单科合格分必须在 0 与该科满分之间';
if (subjects.some(item => item.passRule === 'rank_percent' && (!Number.isFinite(item.passValue) || item.passValue <= 0 || item.passValue > 100))) return '单科排名比例必须大于 0 且不超过 100%';
if (subjects.some(item => !Number.isFinite(item.fee) || item.fee < 0 || item.fee > 100000)) return '科目费用必须在有效范围内';
if (!passPolicies.has(passPolicy)) return '请选择有效的合格线策略';
const totalScore = subjects.reduce((sum, item) => sum + item.fullScore, 0);
if (passPolicy === 'fixed_score' && (!Number.isFinite(passValue) || passValue < 0 || passValue > totalScore)) return `固定合格线必须在 0 与总分 ${totalScore} 之间`;
if (passPolicy === 'rank_percent' && (!Number.isFinite(passValue) || passValue <= 0 || passValue > 100)) return '排名比例必须大于 0 且不超过 100';
return '';
}
function admissionChoiceView(db, examId, choice) {
const school = db.schools.find(item => item.id === choice.schoolId);
const categories = admissionRecords(db, 'plan', examId)
.filter(plan => plan.schoolId === choice.schoolId)
.flatMap(plan => plan.payload?.categories || []);
const category = categories.find(item => item.code === choice.categoryCode)
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
return {
...choice,
schoolCode: choice.schoolCode || school?.code || '',
schoolName: choice.schoolName || school?.name || '',
categoryName: choice.categoryName || category?.name || ''
};
}
function admissionPreferenceSnapshotRows(db) {
const examById = new Map(db.exams.map(item => [item.id, item]));
const schoolById = new Map(db.schools.map(item => [item.id, item]));
const classById = new Map(db.classes.map(item => [item.id, item]));
const accountById = new Map(db.users.map(item => [item.id, item]));
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
const statusLabel = { unfilled: '尚未填报', submitted: '已填报', locked: '已锁定', unavailable: '成绩未齐', ineligible: '不可补录' };
const rows = [];
for (const setting of admissionRecords(db, 'setting').filter(item => item.payload?.enabled)) {
const exam = examById.get(setting.examId) || {};
const round = Number(setting.payload?.round || 1);
const maxSubmissions = Number(setting.payload?.maxSubmissions || 3);
const registrations = db.registrations.filter(item => item.examId === setting.examId && item.status === 'approved');
for (const registration of registrations) {
const account = accountById.get(registration.userId);
const profile = profileByUserId.get(registration.userId);
if (!account?.active || account.role !== 'candidate' || !profile) continue;
const preference = activePreference(db, setting.examId, registration.userId, round);
const blockingPlacement = setting.status === 'supplementary'
? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === registration.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status))
: null;
const submissionCount = Number(preference?.payload?.submissionCount || 0);
const status = blockingPlacement ? 'ineligible'
: preference && submissionCount >= maxSubmissions ? 'locked'
: preference ? 'submitted'
: candidateTotalScore(db, setting.examId, registration.userId) == null ? 'unavailable' : 'unfilled';
const qualification = resolveProfileSpecialty(profile);
const sourceSchool = schoolById.get(profile.schoolId) || {};
const schoolClass = classById.get(profile.classId) || {};
const indicator = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === registration.userId && item.status === 'confirmed');
rows.push({
id: `${setting.examId}:${round}:${registration.userId}`,
examId: setting.examId,
examCode: exam.code || '',
examName: exam.name || '',
round,
phase: setting.status,
status,
fillStatus: statusLabel[status],
lockStatus: status === 'locked' ? '已锁定' : status === 'ineligible' ? '不可填报' : '未锁定',
submissionCount,
maxSubmissions,
submittedAt: preference?.payload?.submittedAt || preference?.updatedAt || '',
sourceSchoolId: sourceSchool.id || '',
sourceSchoolCode: sourceSchool.code || '',
sourceSchoolName: sourceSchool.name || '',
className: schoolClass.name || profile.className || '',
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生',
indicatorStatus: indicator ? (indicator.payload?.eligible ? '有资格' : '无资格') : '未确认',
candidate: { userId: registration.userId, registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '' },
choices: (preference?.payload?.choices || []).map(choice => admissionChoiceView(db, setting.examId, choice))
});
}
}
return rows.sort((left, right) => left.examName.localeCompare(right.examName, 'zh-CN') || left.candidate.registrationNumber.localeCompare(right.candidate.registrationNumber));
}
function admissionPlacementLedgerRows(db) {
const examById = new Map(db.exams.map(item => [item.id, item]));
const schoolById = new Map(db.schools.map(item => [item.id, item]));
const classById = new Map(db.classes.map(item => [item.id, item]));
const accountById = new Map(db.users.map(item => [item.id, item]));
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
const reportingByPlacement = new Map();
const reportingRecords = admissionRecords(db, 'notification')
.filter(item => item.payload?.type === 'admission_reporting')
.sort((left, right) => new Date(left.updatedAt || left.createdAt) - new Date(right.updatedAt || right.createdAt));
for (const record of reportingRecords) for (const row of record.payload?.rows || []) reportingByPlacement.set(row.placementId, row);
const admissionStatusLabels = { school_review: '学校审核中', admitted: '拟录取', withdrawal_pending: '退档待审', final: '正式录取', withdrawn: '已退档', forfeited: '未报到放弃' };
const reportingStatusLabels = { reported: '已报到', not_reported: '未报到', pending: '待确认' };
return admissionRecords(db, 'placement').map(placement => {
const exam = examById.get(placement.examId) || {};
const school = schoolById.get(placement.schoolId) || {};
const account = accountById.get(placement.userId) || {};
const profile = profileByUserId.get(placement.userId) || {};
const sourceSchool = schoolById.get(profile.schoolId) || {};
const schoolClass = classById.get(profile.classId) || {};
const qualification = resolveProfileSpecialty(profile);
const reporting = reportingByPlacement.get(placement.id);
return {
...placement,
examName: exam.name || '', examCode: exam.code || '',
schoolName: school.name || '', schoolCode: school.code || '',
sourceSchoolId: sourceSchool.id || '', sourceSchoolName: sourceSchool.name || '', sourceSchoolCode: sourceSchool.code || '',
className: schoolClass.name || profile.className || '',
candidate: { registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '', idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) || '普通生' },
reportingStatus: reporting?.status || (placement.status === 'final' ? 'pending' : ''),
reportingStatusLabel: reportingStatusLabels[reporting?.status] || (placement.status === 'final' ? '待确认' : '—'),
admissionStatusLabel: admissionStatusLabels[placement.status] || placement.status
};
});
}
function filterAdmissionLedgerRows(rows, searchParams) {
const query = String(searchParams.get('q') || '').trim().toLowerCase();
const filters = {
examId: searchParams.get('examId') || '',
schoolId: searchParams.get('schoolId') || '',
sourceSchoolId: searchParams.get('sourceSchoolId') || '',
status: searchParams.get('status') || '',
round: searchParams.get('round') || ''
};
return rows.filter(item => {
const haystack = JSON.stringify(item).toLowerCase();
return Object.entries(filters).every(([key, value]) => !value || String(item[key] ?? item.payload?.[key] ?? '') === value)
&& (!query || query.split(/\s+/).every(word => haystack.includes(word)));
});
}
function normalizeAdmissionCategories(input) {
return (Array.isArray(input) ? input : []).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);
}
function systemPublications(db) {
const examName = examId => db.exams.find(item => item.id === examId)?.name || '未知考试';
const schoolName = schoolId => db.schools.find(item => item.id === schoolId)?.name || '未知学校';
const view = (record, sourceType, category, title, publishedAt, summary) => ({
id: record.id,
sourceType,
category,
title,
summary,
author: '系统自动发布',
publishedAt,
visible: record.payload?.publicVisible !== false,
status: record.payload?.publicVisible === false ? 'hidden' : 'visible'
});
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved').map(item => view(
item,
'plan',
'招生计划',
`${examName(item.examId)} · ${schoolName(item.schoolId)}招生计划公示`,
item.payload?.reviewedAt || item.updatedAt,
'审核通过后由系统生成,当前页面仅控制是否在公开通知目录显示。'
));
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published').map(item => view(
item,
'qualification',
'指标资格',
`${examName(item.examId)} · ${schoolName(item.schoolId)}指标分配资格公示`,
item.payload?.publishedAt || item.updatedAt,
'资格确认完成后由系统生成,内容随资格确认结果更新。'
));
const roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
{ ...item, id: item.sourceRecordId || item.id },
'admission',
'录取名单',
`${examName(item.examId)}${item.round} 轮录取名单公示`,
item.publishedAt,
`第 ${item.round} 轮录取通知书签发后由系统自动生成,共 ${item.rows.length} 人。`
));
const virtualSourceIds = new Set(admissionRoundPublications(db).filter(item => item.virtual).map(item => item.sourceRecordId));
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && !virtualSourceIds.has(item.id)).map(item => view(
item,
'admission',
'录取名单',
`${examName(item.examId)}最终录取名单`,
item.payload?.completedAt || item.updatedAt,
'录取结束后由系统生成,内容取自最终录取结果。'
));
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
item,
'cutoff',
'录取分数线',
`${examName(item.examId)}录取分数线`,
item.payload?.publishedAt || item.updatedAt,
'录取结束后由系统生成,内容取自各招生类别最低录取分数。'
));
const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting').map(item => ({
id: item.id, sourceType: 'reporting', category: item.category, title: item.title, summary: item.summary,
author: item.author, publishedAt: item.publishAt, visible: item.visible, status: item.status
}));
return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports]
.sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
}
async function handleAdmin(request, response, pathname) {
if (!pathname.startsWith('/api/admin/')) return false;
const user = await requireUser(request, response, 'admin');
if (!user) return true;
if (request.method === 'GET' && pathname === '/api/admin/results') {
const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId');
if (!requestedExamId) {
if (!requirePermission(user, response, 'results.read')) return true;
return sendJson(response, 200, { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: [], resultCache: { enabled: cache.enabled, status: cache.status } });
}
}
const db = request.authDb || await readDb();
if (request.method === 'DELETE' && /^\/api\/admin\/(?:admins|candidates|candidate-accounts)(?:\/|$)/.test(pathname)) {
return sendError(response, 405, '账户不得删除;考生账户请由校方归档,管理员账户可停用');
}
if (request.method === 'GET' && pathname === '/api/admin/context') {
return sendJson(response, 200, {
ok: true,
admin: safeUser(user),
adminLevelName: adminLevelNames[user.adminLevel || 'super'],
permissions: permissionsByLevel[user.adminLevel || 'super'],
scopeLabel: adminScopeLabel(db, user),
schools: db.schools,
classes: db.classes
});
}
if (pathname === '/api/admin/indicator-qualifications' && request.method === 'GET') {
if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格');
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isSourceSchool);
if (!school) return sendError(response, 403, '当前学校未设置为生源学校');
const exams = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => {
const exam = db.exams.find(item => item.id === setting.examId);
return { ...setting, exam: exam ? publicExam(exam) : null, qualificationStatus: sourceSchoolQualificationStatus(db, setting.examId, school.id) };
}).filter(item => item.exam);
return sendJson(response, 200, { ok: true, school, exams });
}
const qualificationBulkMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/bulk$/);
if (qualificationBulkMatch && request.method === 'PUT') {
if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以批量确认指标分配资格');
const setting = admissionSetting(db, qualificationBulkMatch[1]);
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报');
const body = await readJson(request);
if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择批量设置为有资格或无资格');
const userIds = [...new Set((Array.isArray(body.userIds) ? body.userIds : []).map(value => cleanText(value, 64)).filter(Boolean))];
if (!userIds.length) return sendError(response, 400, '请至少选择一名考生');
const profiles = userIds.map(userId => db.candidateProfiles.find(item => item.userId === userId && item.schoolId === user.schoolId && item.profileCompleted));
if (profiles.some(item => !item) || profiles.some(profile => !db.users.some(item => item.id === profile.userId && item.role === 'candidate' && item.active))) return sendError(response, 403, '批量名单中包含不属于本校的有效考生');
const now = nowIso();
const existing = new Map(admissionRecords(db, 'indicator_qualification', setting.examId).map(item => [item.userId, item]));
const qualifications = profiles.map(profile => {
const qualification = existing.get(profile.userId) || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now };
Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } });
return qualification;
});
const replacementIds = new Set(qualifications.map(item => item.id));
const nextDb = { ...db, admissionRecords: [...db.admissionRecords.filter(item => !replacementIds.has(item.id)), ...qualifications] };
const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId);
const records = [...qualifications];
if (status.complete) {
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
records.push(publication);
}
await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
return sendJson(response, 200, { ok: true, count: qualifications.length, qualificationStatus: status, published: status.complete });
}
const qualificationMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/([^/]+)$/);
if (qualificationMatch && request.method === 'PUT') {
if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格');
const setting = admissionSetting(db, qualificationMatch[1]);
const profile = db.candidateProfiles.find(item => item.userId === qualificationMatch[2] && item.schoolId === user.schoolId && item.profileCompleted);
const account = db.users.find(item => item.id === profile?.userId && item.role === 'candidate' && item.active);
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报');
if (!profile || !account) return sendError(response, 404, '本校有效考生不存在');
const body = await readJson(request);
if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择有或无指标分配资格');
const now = nowIso();
const existing = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === profile.userId);
const qualification = existing || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now };
Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } });
const nextRecords = [...db.admissionRecords.filter(item => item.id !== qualification.id), qualification];
const nextDb = { ...db, admissionRecords: nextRecords };
const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId);
const records = [qualification];
if (status.complete) {
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
records.push(publication);
}
await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
return sendJson(response, 200, { ok: true, qualification, qualificationStatus: status, published: status.complete, message: status.complete ? '资格已确认;本校全部考生确认完成,公示已自动发布' : '资格已确认' });
}
const admissionLedgerExportMatch = pathname.match(/^\/api\/admin\/admissions\/(preferences|placements)\/export$/);
if (admissionLedgerExportMatch && request.method === 'GET') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以导出志愿与录取台账');
const searchParams = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams;
const kind = admissionLedgerExportMatch[1];
const selectedExam = db.exams.find(item => item.id === searchParams.get('examId'));
const subtitle = `${selectedExam?.name || '全部考试'}|按当前筛选条件导出|生成时间 ${new Date().toLocaleString('zh-CN', { hour12: false })}`;
if (kind === 'preferences') {
const snapshots = filterAdmissionLedgerRows(admissionPreferenceSnapshotRows(db), searchParams);
const rows = snapshots.flatMap(item => {
const choices = item.choices.length ? item.choices : [null];
return choices.map((choice, index) => ({
examCode: item.examCode, examName: item.examName, round: item.round,
fillStatus: item.fillStatus, lockStatus: item.lockStatus,
submissionCount: item.submissionCount, maxSubmissions: item.maxSubmissions,
candidateNumber: item.candidate.registrationNumber, candidateName: item.candidate.name,
sourceSchoolCode: item.sourceSchoolCode, sourceSchoolName: item.sourceSchoolName, className: item.className,
specialty: item.specialty, indicatorStatus: item.indicatorStatus,
preferenceOrder: choice ? Number(choice.order || index + 1) : '',
preferenceType: choice ? (choice.preferenceType === 'indicator' ? '指标志愿' : '普通志愿') : '',
targetSchoolCode: choice?.schoolCode || '', targetSchoolName: choice?.schoolName || '', categoryName: choice?.categoryName || choice?.categoryCode || '',
submittedAt: item.submittedAt
}));
});
const buffer = Buffer.from(await buildWorkbook('admission_preferences', rows, { subtitle }));
return sendWorkbook(response, buffer, `志愿填报实时台账-${new Date().toISOString().slice(0, 10)}.xlsx`);
}
const placements = filterAdmissionLedgerRows(admissionPlacementLedgerRows(db), searchParams);
const rows = placements.map(item => ({
examCode: item.examCode, examName: item.examName, round: Number(item.payload?.round || 1),
candidateNumber: item.candidate.registrationNumber, candidateName: item.candidate.name,
sourceSchoolCode: item.sourceSchoolCode, sourceSchoolName: item.sourceSchoolName, className: item.className,
specialty: item.candidate.specialtyLabel,
culturalScore: Number(item.payload?.culturalScore ?? item.payload?.totalScore ?? 0),
featureScore: Number(item.payload?.featureScore || 0), totalScore: Number(item.payload?.totalScore || 0),
preferenceOrder: Number(item.payload?.preferenceOrder || 0),
admissionSchoolCode: item.schoolCode, admissionSchoolName: item.schoolName,
categoryName: item.payload?.categoryName || '',
quotaBucket: String(item.payload?.quotaBucket || '').startsWith('indicator') ? '指标分配' : '普通计划',
admissionStatus: item.admissionStatusLabel, reportingStatus: item.reportingStatusLabel,
noticeNumber: item.payload?.noticeNumber || '', withdrawalReason: item.payload?.withdrawalReason || item.payload?.reportingNote || '',
updatedAt: item.updatedAt || item.payload?.finalizedAt || ''
}));
const buffer = Buffer.from(await buildWorkbook('admission_placements', rows, { subtitle }));
return sendWorkbook(response, buffer, `招生录取情况台账-${new Date().toISOString().slice(0, 10)}.xlsx`);
}
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
const examById = new Map(db.exams.map(item => [item.id, item]));
const schoolById = new Map(db.schools.map(item => [item.id, item]));
const userById = new Map(db.users.map(item => [item.id, item]));
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: examById.get(setting.examId) }));
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: schoolById.get(plan.schoolId)?.name || '', examName: examById.get(plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
const placements = admissionPlacementLedgerRows(db);
const preferences = admissionRecords(db, 'preference').map(preference => {
const account = userById.get(preference.userId) || {};
const profile = profileByUserId.get(preference.userId) || {};
const plansForExam = admissionRecords(db, 'plan', preference.examId);
return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => {
const school = schoolById.get(choice.schoolId);
const categories = plansForExam.filter(plan => plan.schoolId === choice.schoolId).flatMap(plan => plan.payload?.categories || []);
const category = categories.find(item => item.code === choice.categoryCode)
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' };
}) };
});
const preferenceRows = admissionPreferenceSnapshotRows(db);
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(item => {
const school = schoolById.get(item.schoolId);
return { ...safeUser(item), active: item.active !== false, createdAt: item.createdAt, schoolName: school?.name || '', schoolCode: school?.code || '' };
});
const reportingRequests = admissionRecords(db, 'notification').filter(item => item.userId == null && item.payload?.type === 'admission_reporting').map(item => ({ ...item, schoolName: schoolById.get(item.schoolId)?.name || '', examName: examById.get(item.examId)?.name || '', progress: admissionPlanProgress(db, admissionRecords(db, 'plan', item.examId).find(plan => plan.schoolId === item.schoolId) || { examId: item.examId, schoolId: item.schoolId, payload: { categories: [] } }) }));
return sendJson(response, 200, { ok: true, settings, plans, preferences, preferenceRows, placements, reportingRequests, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) });
}
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
const body = await readJson(request);
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool);
const username = cleanText(body.username, 80);
const password = String(body.password || '');
if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码');
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '登录账号已存在');
const account = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admission_school', schoolId: school.id, displayName: cleanText(body.displayName, 80) || `${school.name}招生办`, active: true, createdAt: nowIso() };
await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`));
return sendJson(response, 201, { ok: true, account: safeUser(account) });
}
const admissionAccountMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)$/);
if (admissionAccountMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以维护招生学校账号');
const target = db.users.find(item => item.id === admissionAccountMatch[1] && item.role === 'admission_school');
if (!target) return sendError(response, 404, '招生学校账号不存在');
const body = await readJson(request);
target.active = body.active == null ? target.active : Boolean(body.active);
target.displayName = cleanText(body.displayName || target.displayName, 80);
await database.updateAdmin(target, false, logAction(db, user, target.active ? '启用招生学校账号' : '停用招生学校账号', `${target.displayName} · ${target.username}`));
if (!target.active) await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, account: { ...safeUser(target), active: target.active } });
}
const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/);
if (admissionAccountResetMatch && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置招生学校账号密码');
const target = db.users.find(item => item.id === admissionAccountResetMatch[1] && item.role === 'admission_school');
if (!target) return sendError(response, 404, '招生学校账号不存在');
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
target.passwordHash = hashPassword(temporaryPassword);
target.active = true;
await database.updateAdmin(target, true, logAction(db, user, '重置招生学校账号密码', `${target.displayName} · ${target.username}`));
await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
}
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
if (settingMatch && request.method === 'PUT') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报');
const exam = db.exams.find(item => item.id === settingMatch[1] && !item.archivedAt);
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
const body = await readJson(request);
const now = nowIso();
const setting = admissionSetting(db, exam.id) || { id: uid('admission_setting'), kind: 'setting', examId: exam.id, userId: user.id, schoolId: null, createdAt: now };
const requestedStatus = admissionPhases.has(body.status) ? body.status : 'draft';
const manualPhases = ['draft', 'filling', 'closed'];
const status = setting.status && !manualPhases.includes(setting.status) ? setting.status : manualPhases.includes(requestedStatus) ? requestedStatus : (setting.status || 'draft');
setting.status = status;
setting.updatedAt = now;
setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), maxSubmissions: Math.min(50, Math.max(1, Math.trunc(Number(body.maxSubmissions || 3)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' };
if (setting.payload.preferenceStart && setting.payload.preferenceEnd && new Date(setting.payload.preferenceStart) >= new Date(setting.payload.preferenceEnd)) return sendError(response, 400, '志愿填报结束时间必须晚于开始时间');
await database.saveAdmissionRecord(setting, logAction(db, user, '设置志愿填报', `${exam.name} · ${status}`));
return sendJson(response, 200, { ok: true, setting });
}
if (pathname === '/api/admin/admission-plans' && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以代招生学校上传计划');
const body = await readJson(request);
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool);
const categories = normalizeAdmissionCategories(body.categories);
if (!exam || !school || !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, allocation) => sum + allocation.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 (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
const now = nowIso();
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { ...plan.payload, categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
await cache.invalidate('public');
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
}
const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/);
if (planReviewMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核招生计划');
const plan = admissionRecords(db, 'plan').find(item => item.id === planReviewMatch[1]);
if (!plan) return sendError(response, 404, '招生计划不存在');
const body = await readJson(request);
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
plan.status = body.status;
plan.updatedAt = nowIso();
plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt };
await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id));
await cache.invalidate('public');
return sendJson(response, 200, { ok: true, plan });
}
const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/);
if (actionMatch && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以执行投档与录取操作');
const setting = admissionSetting(db, actionMatch[1]);
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开启志愿填报');
const action = actionMatch[2];
if (action === 'match') {
if (!['closed', 'supplementary'].includes(setting.status)) return sendError(response, 409, '请先结束当前填报阶段再投档');
const placements = buildVolunteerPlacements(db, setting, { uid, nowIso });
setting.status = 'school_review'; setting.updatedAt = nowIso(); setting.payload.progress = `第 ${setting.payload.round || 1} 轮投档完成,${placements.length} 人已发送招生学校审核`;
await database.saveAdmissionRecords([setting, ...placements], logAction(db, user, '执行分数优先志愿投档', `${setting.examId} · ${placements.length} 人`));
return sendJson(response, 200, { ok: true, setting, placementCount: placements.length });
}
if (action === 'finalize') {
if (setting.status !== 'school_review') return sendError(response, 409, '只有招生学校审核阶段可以签发录取通知书并开启报到');
const placements = admissionRecords(db, 'placement', setting.examId);
if (placements.some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有招生学校审核或退档申请未处理');
const now = nowIso();
const round = Number(setting.payload?.round || 1);
const admitted = assignAdmissionNoticeNumbers(db, placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now, payload: { ...item.payload, finalizedRound: round } })));
const notifications = admitted.map(item => ({ id: uid('notification'), kind: 'notification', examId: setting.examId, userId: item.userId, schoolId: item.schoolId, status: 'unread', createdAt: now, updatedAt: now, payload: { title: '录取结果通知', message: `你已被${db.schools.find(school => school.id === item.schoolId)?.name || '招生学校'}录取`, placementId: item.id } }));
const reportingRecords = approvedPlans(db, setting.examId).map(plan => {
const existing = admissionReportingRecord(db, setting.examId, plan.schoolId, round);
const schoolPlacements = admitted.filter(item => item.schoolId === plan.schoolId);
const previousRows = existing?.payload?.rows || [];
const previousIds = new Set(previousRows.map(item => item.placementId));
const rows = [...previousRows, ...schoolPlacements.filter(item => !previousIds.has(item.id)).map(item => ({ placementId: item.id, status: 'pending', note: '', updatedAt: now, source: 'system' }))];
const record = existing || { id: uid('admission_reporting'), kind: 'notification', examId: setting.examId, userId: null, schoolId: plan.schoolId, createdAt: now };
return { ...record, status: 'draft', updatedAt: now, payload: { type: 'admission_reporting', round, rows, openedAt: now, openedBy: user.displayName } };
});
const publicationDb = { ...db, admissionRecords: db.admissionRecords.map(item => admitted.find(entry => entry.id === item.id) || item) };
const existingPublication = admissionRecords(db, 'notification', setting.examId).find(item => item.userId == null && item.payload?.type === 'admission_round_publication' && Number(item.payload?.round || 1) === round);
const admissionPublication = existingPublication || { id: uid('admission_round_publication'), kind: 'notification', examId: setting.examId, userId: null, schoolId: null, createdAt: now };
Object.assign(admissionPublication, { status: 'published', updatedAt: now, payload: { ...admissionPublication.payload, type: 'admission_round_publication', round, publishedAt: now, publishedBy: user.displayName, rows: publicAdmissionRows(publicationDb, setting.examId, { round }) } });
setting.status = 'reporting'; setting.updatedAt = now; setting.payload = { ...setting.payload, roundPublishedAt: now, progress: `第 ${round} 轮录取结束,${admitted.length} 名考生已签发通知书,录取名单已公示,招生学校正在登记报到` };
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, ...reportingRecords, admissionPublication], logAction(db, user, '签发录取通知书并公示本轮录取名单', `${setting.examId} · 第 ${round} 轮 · ${admitted.length} 人`));
await cache.invalidate('public');
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, reportingSchoolCount: reportingRecords.length, publicationId: admissionPublication.id });
}
if (action === 'supplementary') return sendError(response, 409, '补录必须由招生学校提交报到情况和补录决定,再经超级管理员审批开启');
const body = await readJson(request);
const now = nowIso();
if (admissionRecords(db, 'placement', setting.examId).some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有学校审核或退档申请待处理,暂不能开启补录');
setting.status = 'supplementary'; setting.updatedAt = now; setting.payload = { ...setting.payload, round: Number(setting.payload.round || 1) + 1, preferenceStart: cleanText(body.preferenceStart, 35) || now, preferenceEnd: cleanText(body.preferenceEnd, 35), progress: '招生计划未满,补录志愿填报进行中' };
await database.saveAdmissionRecord(setting, logAction(db, user, '开启补录', `${setting.examId} · 第 ${setting.payload.round} 轮`));
return sendJson(response, 200, { ok: true, setting });
}
const withdrawalMatch = pathname.match(/^\/api\/admin\/admission-withdrawals\/([^/]+)$/);
const reportingReviewMatch = pathname.match(/^\/api\/admin\/admission-reporting\/([^/]+)$/);
if (reportingReviewMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审批学校报到与补录决定');
const record = admissionRecords(db, 'notification').find(item => item.id === reportingReviewMatch[1] && item.userId == null && item.payload?.type === 'admission_reporting');
if (!record || record.status !== 'pending_approval') return sendError(response, 404, '待审批的报到与补录决定不存在');
const body = await readJson(request);
const approvalNote = cleanText(body.approvalNote, 500);
const now = nowIso();
if (body.approved !== true) {
record.status = 'rejected'; record.updatedAt = now; record.payload = { ...record.payload, approvalNote, rejectedAt: now, rejectedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, '退回报到与补录决定', `${record.schoolId} · 第 ${record.payload?.round || 1} 轮`));
return sendJson(response, 200, { ok: true, record });
}
const supplement = record.payload?.supplementDecision === 'supplement';
const preferenceEnd = cleanText(body.preferenceEnd, 35);
if (supplement && (!preferenceEnd || new Date(preferenceEnd).getTime() <= Date.now())) return sendError(response, 400, '批准补录时必须设置晚于当前时间的补录志愿截止时间');
record.status = 'approved'; record.updatedAt = now; record.payload = { ...record.payload, approvalNote, approvedAt: now, approvedBy: user.displayName, approvedPreferenceEnd: supplement ? preferenceEnd : '' };
const reportPlan = admissionRecords(db, 'plan', record.examId).find(item => item.schoolId === record.schoolId) || { examId: record.examId, schoolId: record.schoolId, payload: { categories: [] } };
const reportDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
record.payload.statistics = admissionPlanProgress(reportDb, reportPlan);
const changedPlacements = [];
if (supplement) {
const notReported = new Set((record.payload?.rows || []).filter(item => item.status === 'not_reported').map(item => item.placementId));
for (const placement of admissionRecords(db, 'placement', record.examId).filter(item => notReported.has(item.id) && item.schoolId === record.schoolId && item.status === 'final')) {
changedPlacements.push({ ...placement, status: 'forfeited', updatedAt: now, payload: { ...placement.payload, forfeitedAt: now, forfeitedReason: '未按规定完成报到,学校补录申请已获批准' } });
}
}
const replacements = new Map([[record.id, record], ...changedPlacements.map(item => [item.id, item])]);
let nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => replacements.get(item.id) || item) };
replacements.set(record.id, record);
nextDb = { ...nextDb, admissionRecords: nextDb.admissionRecords.map(item => replacements.get(item.id) || item) };
const setting = admissionSetting(nextDb, record.examId);
const round = Number(record.payload?.round || 1);
const plans = approvedPlans(nextDb, record.examId);
const currentRecords = plans.map(item => admissionReportingRecord(nextDb, record.examId, item.schoolId, round));
const allApproved = currentRecords.length > 0 && currentRecords.every(item => item?.status === 'approved');
const recordsToSave = [record, ...changedPlacements];
let completed = false;
if (allApproved && setting) {
const supplementRecords = currentRecords.filter(item => item.payload?.supplementDecision === 'supplement');
setting.updatedAt = now;
if (supplementRecords.length) {
const supplementEnd = supplementRecords.map(item => item.payload?.approvedPreferenceEnd).filter(Boolean).sort().at(-1);
setting.status = 'supplementary';
setting.payload = { ...setting.payload, round: round + 1, preferenceStart: now, preferenceEnd: supplementEnd, progress: `第 ${round + 1} 轮补录志愿填报进行中,截止 ${new Date(supplementEnd).toLocaleString('zh-CN')}` };
} else {
setting.status = 'completed';
setting.payload = { ...setting.payload, completedAt: now, progress: '全部招生学校报到情况与补录决定已审批,录取工作完成' };
const cutoffRows = admissionCutoffRows(nextDb, setting.examId);
const existingCutoff = admissionRecords(nextDb, 'cutoff_publication', setting.examId)[0];
const cutoffPublication = existingCutoff || { id: uid('cutoff_publication'), kind: 'cutoff_publication', examId: setting.examId, userId: user.id, schoolId: null, createdAt: now };
Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { ...cutoffPublication.payload, publishedAt: now, rows: cutoffRows } });
recordsToSave.push(cutoffPublication);
completed = true;
}
recordsToSave.push(setting);
}
await database.saveAdmissionRecords(recordsToSave, logAction(db, user, supplement ? '批准补录申请并公开报到情况' : '批准不补录决定并公开报到情况', `${record.schoolId} · 第 ${round} 轮`));
await cache.invalidate('public');
return sendJson(response, 200, { ok: true, record, forfeitedCount: changedPlacements.length, phase: setting?.status, completed });
}
if (withdrawalMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核退档');
const placement = admissionRecords(db, 'placement').find(item => item.id === withdrawalMatch[1] && item.status === 'withdrawal_pending');
if (!placement) return sendError(response, 404, '待审核退档申请不存在');
const body = await readJson(request);
placement.status = body.approved === true ? 'withdrawn' : 'admitted';
placement.updatedAt = nowIso();
placement.payload.withdrawalReviewNote = cleanText(body.reviewNote, 500);
await database.saveAdmissionRecord(placement, logAction(db, user, body.approved === true ? '批准退档' : '驳回退档', placement.id));
return sendJson(response, 200, { ok: true, placement });
}
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$/);
if (excelMatch && request.method === 'GET') {
const resource = excelMatch[1];
if (!hasExcelResource(resource)) return sendError(response, 404, 'Excel 数据类型不存在');
if (['classes', 'class_admins', 'account_quotas', 'account_results'].includes(resource) && !['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能导出该数据');
if (resource === 'centers' && !hasPermission(user, 'centers.read')) return sendError(response, 403, '当前账号不能导出考点考场');
if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料');
if (resource === 'payments' && !hasPermission(user, 'payments.read')) return sendError(response, 403, '当前账号不能导出缴费名单');
if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩');
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
const template = requestUrl.searchParams.get('template') === '1';
const rows = template && resource !== 'results' ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user);
const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
}
if (excelMatch && request.method === 'POST') {
const resource = excelMatch[1];
if (['account_results', 'payments'].includes(resource)) return sendError(response, 400, '该清单只支持导出');
const rows = await parseWorkbook(resource, await readBodyBuffer(request));
if (resource === 'results') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以预览导入成绩');
return sendJson(response, 200, { ok: true, preview: true, ...prepareResultImport(db, rows) });
}
const result = await importExcelResource(db, user, resource, rows);
return sendJson(response, 200, { ok: true, ...result });
}
if (pathname === '/api/admin/schools' && request.method === 'GET') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以管理学校');
const schools = db.schools.map(school => ({
...school,
classCount: db.classes.filter(item => item.schoolId === school.id).length,
adminCount: db.users.filter(item => item.role === 'admin' && item.schoolId === school.id).length,
candidateCount: db.candidateProfiles.filter(item => item.schoolId === school.id).length,
centerCount: db.testCenters.filter(item => item.schoolId === school.id).length
}));
return sendJson(response, 200, { ok: true, schools });
}
if (pathname === '/api/admin/schools' && request.method === 'POST') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建学校');
const body = await readJson(request);
const name = cleanText(body.name, 100);
const code = cleanText(body.code, 40).toUpperCase();
const address = cleanText(body.address, 200);
const isSourceSchool = body.isSourceSchool !== false;
const isAdmissionSchool = body.isAdmissionSchool !== false;
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校');
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
if (db.schools.some(item => item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
if (db.schools.some(item => item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
const school = { id: uid('school'), name, code, address, isSourceSchool, isAdmissionSchool, active: body.active !== false };
await database.saveSchool(school, true, logAction(db, user, '创建学校', `${name} · ${code}`));
return sendJson(response, 201, { ok: true, school });
}
const schoolMatch = pathname.match(/^\/api\/admin\/schools\/([^/]+)$/);
if (schoolMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以维护学校');
const body = await readJson(request);
const school = db.schools.find(item => item.id === schoolMatch[1]);
if (!school) return sendError(response, 404, '学校不存在');
const name = cleanText(body.name ?? school.name, 100);
const code = cleanText(body.code ?? school.code, 40).toUpperCase();
const address = cleanText(body.address ?? school.address, 200);
const isSourceSchool = body.isSourceSchool == null ? school.isSourceSchool : Boolean(body.isSourceSchool);
const isAdmissionSchool = body.isAdmissionSchool == null ? school.isAdmissionSchool : Boolean(body.isAdmissionSchool);
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校');
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
if (db.schools.some(item => item.id !== school.id && item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
if (db.schools.some(item => item.id !== school.id && item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
Object.assign(school, { name, code, address, isSourceSchool, isAdmissionSchool, active: body.active == null ? school.active : Boolean(body.active) });
await database.saveSchool(school, false, logAction(db, user, '维护学校', `${name} · ${code} · ${school.active ? '启用' : '停用'}`));
return sendJson(response, 200, { ok: true, school });
}
if (pathname === '/api/admin/school-organization' && request.method === 'GET') {
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织');
const school = db.schools.find(item => item.id === user.schoolId);
const classes = db.classes.filter(item => item.schoolId === user.schoolId).map(item => ({
...item,
candidateCount: db.candidateProfiles.filter(profile => profile.classId === item.id).length,
admins: db.users.filter(admin => admin.role === 'admin' && admin.adminLevel === 'class' && admin.classId === item.id).map(admin => ({ ...safeUser(admin), active: admin.active }))
}));
return sendJson(response, 200, { ok: true, school, classes });
}
if (pathname === '/api/admin/classes' && request.method === 'POST') {
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级');
if (!db.schools.some(item => item.id === user.schoolId && item.active && item.isSourceSchool)) return sendError(response, 409, '当前学校未设置为已启用的生源校');
const body = await readJson(request);
const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60);
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
if (db.classes.some(item => item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级');
const schoolClass = { id: uid('class'), schoolId: user.schoolId, name, grade, active: body.active !== false };
await database.saveSchoolClass(schoolClass, true, logAction(db, user, '新增本校班级', `${grade} · ${name}`));
return sendJson(response, 201, { ok: true, schoolClass });
}
const classMatch = pathname.match(/^\/api\/admin\/classes\/([^/]+)$/);
if (classMatch && request.method === 'PATCH') {
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级');
const body = await readJson(request);
const schoolClass = db.classes.find(item => item.id === classMatch[1] && item.schoolId === user.schoolId);
if (!schoolClass) return sendError(response, 404, '班级不存在');
const name = cleanText(body.name ?? schoolClass.name, 100); const grade = cleanText(body.grade ?? schoolClass.grade, 60);
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
if (db.classes.some(item => item.id !== schoolClass.id && item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级');
Object.assign(schoolClass, { name, grade, active: body.active == null ? schoolClass.active : Boolean(body.active) });
await database.saveSchoolClass(schoolClass, false, logAction(db, user, '更新本校班级', `${grade} · ${name} · ${schoolClass.active ? '启用' : '停用'}`));
return sendJson(response, 200, { ok: true, schoolClass });
}
if (pathname === '/api/admin/admins' && request.method === 'GET') {
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能管理管理员');
const admins = db.users.filter(item => item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))).map(item => ({
...safeUser(item),
active: item.active,
levelName: adminLevelNames[item.adminLevel],
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || ''
}));
return sendJson(response, 200, { ok: true, admins, schools: db.schools.filter(item => item.isSourceSchool), classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
}
if (pathname === '/api/admin/admins' && request.method === 'POST') {
const body = await readJson(request);
const username = cleanText(body.username, 50);
const password = String(body.password || '');
const displayName = cleanText(body.displayName, 50);
const adminLevel = user.adminLevel === 'school' ? 'class' : cleanText(body.adminLevel, 20);
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能创建管理员');
if (!username || !displayName || password.length < 8 || !['super', 'school', 'class'].includes(adminLevel)) return sendError(response, 400, '请完整填写管理员账号、姓名、层级和至少 8 位密码');
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
const schoolId = adminLevel === 'super' ? null : user.adminLevel === 'school' ? user.schoolId : cleanText(body.schoolId, 64);
const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null;
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId && item.active && item.isSourceSchool)) return sendError(response, 400, '校级和班级管理员必须绑定已启用的生源校');
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() };
const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`);
await database.createAdmin(created, log);
return sendJson(response, 201, { ok: true, admin: safeUser(created) });
}
const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/);
if (adminMatch && request.method === 'PATCH') {
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能维护管理员');
const body = await readJson(request);
const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId)));
if (!target) return sendError(response, 404, '管理员账户不存在或不在当前管理范围');
if (target.id === user.id && body.active === false) return sendError(response, 409, '不能停用当前正在使用的管理员账户');
const schoolClass = target.adminLevel === 'class' ? db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === target.schoolId) : null;
if (target.adminLevel === 'class' && !schoolClass) return sendError(response, 400, '请选择该管理员所属学校的有效班级');
const password = String(body.password || '');
if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位');
Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass?.id || target.classId || null, active: body.active == null ? target.active : Boolean(body.active) });
if (password) target.passwordHash = hashPassword(password);
await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护管理员账户', `${target.displayName} · ${adminLevelNames[target.adminLevel]} · ${target.active ? '启用' : '停用'}`));
if (!target.active || password) await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, admin: safeUser(target) });
}
const adminPasswordResetMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)\/reset-password$/);
if (adminPasswordResetMatch && request.method === 'POST') {
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能重置管理员密码');
const target = db.users.find(item => item.id === adminPasswordResetMatch[1] && item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId)));
if (!target) return sendError(response, 404, '管理员账户不存在或不在当前管理范围');
if (target.id === user.id) return sendError(response, 409, '当前账号请在“账户安全”中修改自己的密码');
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
target.passwordHash = hashPassword(temporaryPassword);
target.active = true;
await database.updateAdmin(target, true, logAction(db, user, '重置管理员密码', `${target.displayName} · ${target.username}`));
await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
}
if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const enabled = Boolean(body.enabled);
const log = logAction(db, user, enabled ? '开启自主注册' : '关闭自主注册', enabled ? '考生可从公开入口申请报名号' : '仅允许使用学校下发的报名号登录');
await database.updateRegistrationSetting(enabled, log);
return sendJson(response, 200, { ok: true, enabled });
}
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'GET') {
if (!requirePermission(user, response, 'candidates.write')) return true;
if (!['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '只有校级管理员可以申领批量报名号');
const batches = db.candidateAccountBatches
.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.map(item => candidateAccountBatchView(db, item));
const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId));
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active && item.isSourceSchool) });
}
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') {
if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领');
const body = await readJson(request);
const requestedQuotas = Array.isArray(body.quotas) ? body.quotas : [];
const quotas = requestedQuotas.map(item => ({ classId: cleanText(item.classId, 64), count: Number(item.count) })).filter(item => item.count > 0);
if (!quotas.length) return sendError(response, 400, '请至少为一个班级填写申领数量');
if (new Set(quotas.map(item => item.classId)).size !== quotas.length) return sendError(response, 400, '同一班级只能填写一次申领数量');
if (quotas.some(item => !Number.isInteger(item.count) || item.count < 1 || item.count > 200)) return sendError(response, 400, '每个班级一次可申领 1—200 个报名号');
if (quotas.some(item => !db.classes.some(schoolClass => schoolClass.id === item.classId && schoolClass.schoolId === user.schoolId && schoolClass.active))) return sendError(response, 400, '只能为本校有效班级申领报名号');
const totalCount = quotas.reduce((sum, item) => sum + item.count, 0);
if (totalCount > 500) return sendError(response, 400, '单个批次最多申领 500 个报名号');
const batch = { id: uid('account_batch'), schoolId: user.schoolId, requestedBy: user.id, status: 'pending', reviewNote: '', createdAt: nowIso(), reviewedAt: null };
const items = [];
let position = 1;
for (const quota of quotas) for (let index = 0; index < quota.count; index += 1) {
items.push({ id: uid('account_batch_item'), batchId: batch.id, classId: quota.classId, position, candidateNumber: '', initialPassword: '', userId: null, createdAt: null });
position += 1;
}
const { instance, action } = createWorkflowSubmission(db, 'candidate_account_batch', batch.id, centerScopeProfile(db, user.schoolId), user.id);
const quotaSummary = quotas.map(item => `${db.classes.find(entry => entry.id === item.classId)?.name} ${item.count} 人`).join('');
const log = logAction(db, user, '提交批量报名号申领', `${totalCount} 个账户 · ${quotaSummary}`);
await database.createCandidateAccountBatch(batch, items, instance, action, log);
const fresh = await readDb();
return sendJson(response, 202, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) });
}
const accountBatchMatch = pathname.match(/^\/api\/admin\/candidate-account-batches\/([^/]+)$/);
if (accountBatchMatch && request.method === 'PATCH') {
if (!requirePermission(user, response, 'candidates.write')) return true;
const body = await readJson(request);
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效');
const batch = db.candidateAccountBatches.find(item => item.id === accountBatchMatch[1] && item.status === 'pending');
if (!batch) return sendError(response, 404, '待审批的批量报名号申请不存在');
const instance = pendingWorkflow(db, 'candidate_account_batch', batch.id);
const workflow = instance && db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (!instance || !workflow || !step) return sendError(response, 409, '批量报名号审批流程状态异常');
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
const log = logAction(db, user, body.status === 'approved' ? '审批批量报名号申领' : '退回批量报名号申领', `${db.schools.find(item => item.id === batch.schoolId)?.name} · ${note || '无备注'}`);
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
batch.status = 'rejected'; batch.reviewNote = note; batch.reviewedAt = nowIso();
await database.processWorkflow(instance, action, batch, log);
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, centerScopeProfile(db, batch.schoolId));
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
batch.reviewNote = note;
await database.processWorkflow(instance, action, batch, log);
} else {
const batchItems = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position);
if (!batchItems.length || batchItems.some(item => item.userId || item.candidateNumber)) return sendError(response, 409, '批次明细异常或已经生成过账号');
const generationDb = { ...db, users: [...db.users] };
const users = [];
const profiles = [];
for (const [index, item] of batchItems.entries()) {
const schoolClass = db.classes.find(entry => entry.id === item.classId && entry.schoolId === batch.schoolId);
if (!schoolClass) return sendError(response, 409, '批次包含无效班级,无法生成账号');
const generated = generateCandidateNumber(generationDb, { schoolId: batch.schoolId, classId: item.classId, gender: '' });
const userId = uid('usr');
const initialPassword = `Init-${randomBytes(6).toString('base64url')}`;
const displayName = `待补录考生 ${String(index + 1).padStart(3, '0')}`;
const candidateUser = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(initialPassword), role: 'candidate', displayName, schoolId: batch.schoolId, classId: item.classId, active: true, mustChangePassword: true, createdAt: nowIso() };
const profile = { id: uid('profile'), userId, name: displayName, gender: '', idNumber: `PENDING-${userId}`, phone: '', email: '', school: db.schools.find(entry => entry.id === batch.schoolId)?.name || '', grade: schoolClass.name, schoolId: batch.schoolId, classId: item.classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() };
item.candidateNumber = generated.number; item.initialPassword = initialPassword; item.userId = userId; item.createdAt = nowIso();
users.push(candidateUser); profiles.push(profile); generationDb.users.push(candidateUser);
}
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
batch.status = 'approved'; batch.reviewNote = note; batch.reviewedAt = nowIso();
await database.completeCandidateAccountBatch(batch, batchItems, users, profiles, instance, action, log);
}
const fresh = await readDb();
return sendJson(response, 200, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) });
}
if (pathname === '/api/admin/centers' && request.method === 'GET') {
if (!requirePermission(user, response, 'centers.read')) return true;
const centers = db.testCenters.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId).map(item => ({
...item,
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
rooms: db.testRooms.filter(room => room.centerId === item.id),
totalCapacity: db.testRooms.filter(room => room.centerId === item.id && room.status === 'active').reduce((sum, room) => sum + Number(room.capacity || 0), 0),
pendingChange: db.centerChangeRequests.some(change => change.centerId === item.id && change.status === 'pending')
}));
const changeRequests = db.centerChangeRequests
.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.map(item => centerChangeView(db, item));
return sendJson(response, 200, { ok: true, centers, changeRequests, schools: user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId) });
}
if (pathname === '/api/admin/centers' && request.method === 'POST') {
if (!requirePermission(user, response, 'centers.write')) return true;
const body = await readJson(request);
const schoolId = user.adminLevel === 'super' ? cleanText(body.schoolId, 64) : user.schoolId;
if (!db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '考点必须归属有效学校');
const parsed = parseCenterChange(db, body, schoolId);
const change = { id: uid('center_change'), centerId: null, schoolId, requestType: 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, schoolId), user.id);
const log = logAction(db, user, '提交新增考点审批', `${change.name} · ${parsed.rooms.length} 个考场`);
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log);
return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } });
}
const centerMatch = pathname.match(/^\/api\/admin\/centers\/([^/]+)$/);
if (centerMatch && request.method === 'PATCH') {
if (!requirePermission(user, response, 'centers.write')) return true;
const body = await readJson(request);
const center = db.testCenters.find(item => item.id === centerMatch[1]);
if (!center) return sendError(response, 404, '考点不存在');
if (user.adminLevel !== 'super' && center.schoolId !== user.schoolId) return sendError(response, 403, '只能维护本校考点');
if (db.centerChangeRequests.some(item => item.centerId === center.id && item.status === 'pending')) return sendError(response, 409, '该考点已有待审批变更,请处理完成后再提交');
const parsed = parseCenterChange(db, body, center.schoolId, center);
const change = { id: uid('center_change'), centerId: center.id, schoolId: center.schoolId, requestType: 'update', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, center.schoolId), user.id);
const log = logAction(db, user, '提交考点变更审批', `${change.name} · ${parsed.rooms.length} 个考场`);
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log);
return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } });
}
const centerChangeMatch = pathname.match(/^\/api\/admin\/center-change-requests\/([^/]+)$/);
if (centerChangeMatch && request.method === 'PATCH') {
if (!requirePermission(user, response, 'centers.write')) return true;
const body = await readJson(request);
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效');
const change = db.centerChangeRequests.find(item => item.id === centerChangeMatch[1] && item.status === 'pending');
if (!change) return sendError(response, 404, '待审批的考点变更不存在');
if (user.adminLevel !== 'super' && change.schoolId !== user.schoolId) return sendError(response, 403, '该变更不在你的学校范围内');
const instance = pendingWorkflow(db, 'center_change', change.id);
const workflow = instance && db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (!instance || !workflow || !step) return sendError(response, 409, '考点变更审批流程状态异常');
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
const log = logAction(db, user, body.status === 'approved' ? '审批考点变更' : '退回考点变更', `${change.name} · ${note || '无备注'}`);
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
change.status = 'rejected'; change.reviewNote = note; change.reviewedAt = nowIso();
await database.applyCenterChange(change, instance, action, null, [], log);
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, centerScopeProfile(db, change.schoolId));
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
change.reviewNote = note;
await database.processWorkflow(instance, action, change, log);
} else {
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
change.status = 'approved'; change.reviewNote = note; change.reviewedAt = nowIso();
const centerId = change.centerId || uid('center');
const proposedRooms = db.centerChangeRooms.filter(item => item.requestId === change.id);
const rooms = proposedRooms.map(room => ({ ...room, id: room.roomId || uid('room'), centerId }));
const center = {
id: centerId, schoolId: change.schoolId, code: change.code, name: change.name,
provinceCode: change.provinceCode, provinceName: change.provinceName, cityCode: change.cityCode,
cityName: change.cityName, districtCode: change.districtCode, districtName: change.districtName,
address: change.address,
contact: change.contact, managerName: change.managerName, managerPhone: change.managerPhone,
emergencyPhone: change.emergencyPhone, gateOpenTime: change.gateOpenTime, transport: change.transport,
status: change.centerStatus, notes: change.notes,
rooms: rooms.map(room => `${room.building} ${room.name}`).join(''), updatedAt: nowIso()
};
await database.applyCenterChange(change, instance, action, center, rooms, log);
}
return sendJson(response, 200, { ok: true, changeRequest: centerChangeView({ ...db, workflowActions: [...db.workflowActions, action] }, change) });
}
if (pathname === '/api/admin/number-rules' && request.method === 'GET') {
if (!requirePermission(user, response, '*')) return true;
const rule = db.numberRules.find(item => item.active) || null;
const previewProfile = db.candidateProfiles[0] || { gender: '女', schoolId: db.schools[0]?.id };
let preview = '';
if (rule) preview = generateCandidateNumber(db, previewProfile).number;
return sendJson(response, 200, { ok: true, rules: db.numberRules, activeRule: rule, preview });
}
if (pathname === '/api/admin/number-rules' && request.method === 'POST') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const allowedTypes = ['year', 'school_code', 'gender', 'sequence', 'literal'];
const requested = Array.isArray(body.segments) ? body.segments : [];
if (!requested.length || requested.some(item => !allowedTypes.includes(item.type)) || !requested.some(item => item.type === 'sequence')) return sendError(response, 400, '报名号规则至少包含一个流水号段');
const existing = db.numberRules.find(item => item.id === body.id);
const rule = {
id: existing?.id || uid('rule'), name: cleanText(body.name, 80) || '自定义报名号规则', separator: cleanText(body.separator, 3),
active: true, createdBy: user.id, updatedAt: nowIso(), segments: requested.map((item, index) => ({
id: uid('segment'), position: index + 1, type: item.type, value: cleanText(item.value, 20), width: Math.min(12, Math.max(0, Number(item.width || 0)))
}))
};
const log = logAction(db, user, '更新报名号规则', `${rule.name} · ${rule.segments.map(item => item.type).join(' + ')}`);
await database.saveNumberRule(rule, !existing, log);
return sendJson(response, 200, { ok: true, rule });
}
if (pathname === '/api/admin/workflows' && request.method === 'GET') {
if (!requirePermission(user, response, '*')) return true;
return sendJson(response, 200, { ok: true, workflows: db.workflows });
}
const workflowDefinitionMatch = pathname.match(/^\/api\/admin\/workflows\/(profile_change|registration_review|center_change|candidate_account_batch|score_appeal)$/);
if (workflowDefinitionMatch && request.method === 'PUT') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const workflow = activeWorkflow(db, workflowDefinitionMatch[1]);
if (!workflow) return sendError(response, 404, '审批流程不存在');
const steps = Array.isArray(body.steps) ? body.steps : [];
if (!steps.length || steps.some(item => !['class', 'school', 'super'].includes(item.adminLevel))) return sendError(response, 400, '流程至少需要一个班级、校级或超级管理员审批步骤');
if (['center_change', 'candidate_account_batch'].includes(workflowDefinitionMatch[1]) && steps.some(item => item.adminLevel === 'class')) return sendError(response, 400, '该业务不对应单一班级,不能配置班级管理员审批步骤');
if (workflowDefinitionMatch[1] === 'candidate_account_batch' && steps.at(-1)?.adminLevel !== 'super') return sendError(response, 400, '批量报名号申领的最终步骤必须由超级管理员审批');
workflow.name = cleanText(body.name, 80) || workflow.name;
workflow.updatedBy = user.id;
workflow.updatedAt = nowIso();
workflow.steps = steps.map((item, index) => ({ id: uid('workflow_step'), position: index + 1, name: cleanText(item.name, 80) || `第 ${index + 1} 步`, adminLevel: item.adminLevel }));
const log = logAction(db, user, '修改审批流程', `${workflow.name} · ${workflow.steps.length} 个步骤`);
await database.saveWorkflow(workflow, log);
return sendJson(response, 200, { ok: true, workflow });
}
if (pathname === '/api/admin/workflow-instances' && request.method === 'GET') {
if (!requirePermission(user, response, 'workflows.inbox')) return true;
const instances = db.workflowInstances.filter(instance => {
if (user.adminLevel === 'super') return true;
const profile = workflowScopeProfile(db, instance);
return Boolean(profile && profileInScope(user, profile));
}).map(instance => {
const profile = workflowScopeProfile(db, instance);
const registration = instance.businessType === 'registration_review' ? db.registrations.find(item => item.id === instance.businessId) : null;
const centerChange = instance.businessType === 'center_change' ? db.centerChangeRequests.find(item => item.id === instance.businessId) : null;
const accountBatch = instance.businessType === 'candidate_account_batch' ? db.candidateAccountBatches.find(item => item.id === instance.businessId) : null;
const appealResult = instance.businessType === 'score_appeal' ? db.results.find(item => item.id === instance.businessId) : null;
const appealRegistration = appealResult ? db.registrations.find(item => item.id === appealResult.registrationId) : null;
const appealExam = appealRegistration ? db.exams.find(item => item.id === appealRegistration.examId) : null;
const appealSubject = appealExam?.subjects.find(item => item.id === appealResult?.subjectId);
const appealRank = appealResult ? resultRankInfo(db, appealResult) : null;
const appealPass = appealResult ? subjectPassEvaluation(db, appealResult, appealSubject) : null;
return {
...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '',
examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '',
centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null,
accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null,
batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0,
appealResult: appealResult ? {
score: appealResult.score, grade: appealRank?.grade || appealResult.grade, rank: appealRank?.rank, cohortSize: appealRank?.cohortSize, rankPercent: appealRank?.rankPercent,
examName: appealExam?.name || '', examCode: appealExam?.code || '', subjectName: appealSubject?.name || '',
fullScore: appealSubject?.fullScore, passRule: appealSubject?.passRule || 'fixed_score', passValue: appealSubject?.passValue ?? appealSubject?.passScore,
passScore: appealPass?.passScore ?? null, cutoffRank: appealPass?.cutoffRank ?? null,
passText: subjectPassText(appealSubject), qualified: appealPass?.qualified ?? null
} : null
};
});
const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser);
return sendJson(response, 200, { ok: true, instances, availableAdmins, canSupervise: user.adminLevel === 'super' });
}
const transferMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/transfer$/);
if (transferMatch && request.method === 'PATCH') {
const body = await readJson(request);
const instance = db.workflowInstances.find(item => item.id === transferMatch[1] && item.status === 'pending');
if (!instance) return sendError(response, 404, '待处理流程不存在');
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (user.adminLevel !== 'super' && instance.assigneeId !== user.id) return sendError(response, 403, '只有当前处理人可以转交该流程');
const target = db.users.find(item => item.id === body.assigneeId && item.role === 'admin' && item.active && item.adminLevel === step?.adminLevel);
if (!target) return sendError(response, 400, '只能转交给当前步骤同级管理员');
const profile = workflowScopeProfile(db, instance);
if (step.adminLevel === 'school' && target.schoolId !== profile?.schoolId) return sendError(response, 400, '校级流程只能转交给本校同级管理员');
if (step.adminLevel === 'class' && (target.schoolId !== profile?.schoolId || target.classId !== profile?.classId)) return sendError(response, 400, '班级流程只能转交给本班同级管理员');
const previous = instance.assigneeId;
instance.assigneeId = target.id;
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: 'transfer', note: cleanText(body.note, 300), fromAssigneeId: previous, toAssigneeId: target.id, createdAt: nowIso() };
const log = logAction(db, user, '转交审批流程', `${workflow.name}${target.displayName}`);
await database.transferWorkflow(instance, action, log);
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
const superviseMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/supervise$/);
if (superviseMatch && request.method === 'PATCH') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const instance = db.workflowInstances.find(item => item.id === superviseMatch[1]);
if (!instance) return sendError(response, 404, '流程不存在');
if (instance.businessType === 'candidate_account_batch' && db.candidateAccountBatchItems.some(item => item.batchId === instance.businessId && item.userId)) return sendError(response, 409, '已生成账号的批次不可重新打开,避免重复建号');
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const requestedStep = Math.min(workflow.steps.length, Math.max(1, Number(body.currentStep || instance.currentStep)));
const step = workflow.steps.find(item => item.position === requestedStep);
const profile = workflowScopeProfile(db, instance);
const eligible = adminsForStep(db, step.adminLevel, profile);
const requestedAssignee = body.assigneeId ? eligible.find(item => item.id === body.assigneeId) : null;
if (body.assigneeId && !requestedAssignee) return sendError(response, 400, '指定管理员不在该学校或班级的目标步骤范围内');
const assignee = requestedAssignee || selectAdminForStep(db, step.adminLevel, profile);
if (!assignee) return sendError(response, 409, '目标步骤没有可用管理员');
const previous = instance.assigneeId;
const previousStep = instance.currentStep;
instance.status = 'pending'; instance.completedAt = null; instance.currentStep = requestedStep; instance.assigneeId = assignee.id;
const note = cleanText(body.note, 300) || `超级管理员将流程调整到第 ${requestedStep} 步`;
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: requestedStep < previousStep ? 'return' : 'supervise', note, fromAssigneeId: previous, toAssigneeId: assignee.id, createdAt: nowIso() };
const business = instance.businessType === 'profile_change'
? profile
: instance.businessType === 'registration_review'
? db.registrations.find(item => item.id === instance.businessId)
: instance.businessType === 'center_change'
? db.centerChangeRequests.find(item => item.id === instance.businessId)
: instance.businessType === 'candidate_account_batch'
? db.candidateAccountBatches.find(item => item.id === instance.businessId)
: null;
if (business) {
business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null;
}
const log = logAction(db, user, '监督调整审批流程', `${workflow.name} · 第 ${requestedStep} 步 · ${assignee.displayName}`);
await database.processWorkflow(instance, action, business, log);
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
if (request.method === 'GET' && pathname === '/api/admin/dashboard') {
const profiles = db.candidateProfiles.filter(item => profileInScope(user, item));
const registrations = db.registrations.filter(item => registrationInScope(db, user, item));
const visibleFlows = db.workflowInstances.filter(instance => {
if (user.adminLevel === 'super') return true;
const business = workflowScopeProfile(db, instance);
return business && profileInScope(user, business) && (instance.assigneeId === user.id || instance.status !== 'pending');
});
const pendingCandidates = profiles.filter(item => item.status === 'pending').length;
const pendingRegistrations = registrations.filter(item => item.status === 'pending').length;
const pendingPayments = registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').length;
return sendJson(response, 200, {
ok: true,
admin: safeUser(user),
scopeLabel: adminScopeLabel(db, user),
permissions: permissionsByLevel[user.adminLevel || 'super'],
metrics: { candidates: profiles.length, pendingCandidates, registrations: registrations.length, pendingRegistrations, pendingPayments, pendingFlows: visibleFlows.filter(item => item.status === 'pending').length, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length },
logs: user.adminLevel === 'super' ? db.auditLogs.slice(0, 8) : db.auditLogs.filter(log => log.actorId === user.id).slice(0, 8)
});
}
if (request.method === 'GET' && pathname === '/api/admin/candidates') {
if (!requirePermission(user, response, 'candidates.read')) return true;
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
const account = db.users.find(item => item.id === profile.userId);
const registrations = db.registrations
.filter(item => item.userId === profile.userId)
.map(item => examRegistrationView(db, item))
.sort((left, right) => new Date(right.createdAt || 0) - new Date(left.createdAt || 0));
return {
...profile,
idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber),
username: account?.username,
candidateNumber: account?.candidateNumber || '',
mustChangePassword: Boolean(account?.mustChangePassword),
accountArchived: Boolean(account?.archivedAt),
archivedAt: account?.archivedAt || null,
archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '',
registrations,
workflow: workflowView(db, instance)
};
});
return sendJson(response, 200, { ok: true, candidates, schools: user.adminLevel === 'super' ? db.schools.filter(item => item.active) : db.schools.filter(item => item.id === user.schoolId && item.active), classes: db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)) });
}
if (request.method === 'POST' && pathname === '/api/admin/candidate-accounts/archive') {
if (user.adminLevel !== 'school') return sendError(response, 403, '考生账户归档由校级管理员负责');
const body = await readJson(request);
const scopeType = cleanText(body.scopeType, 20);
const scopeValue = cleanText(body.scopeValue, 100);
const archived = Boolean(body.archived);
if (!['class', 'grade'].includes(scopeType) || !scopeValue) return sendError(response, 400, '请选择要归档的班级或年级');
const scopedClasses = db.classes.filter(item => item.schoolId === user.schoolId);
const targetClassIds = scopeType === 'class'
? scopedClasses.filter(item => item.id === scopeValue).map(item => item.id)
: scopedClasses.filter(item => item.grade === scopeValue).map(item => item.id);
if (!targetClassIds.length) return sendError(response, 404, '所选班级或年级不在本校范围内');
const targetIds = new Set(db.candidateProfiles.filter(profile => profile.schoolId === user.schoolId && targetClassIds.includes(profile.classId)).map(profile => profile.userId));
const targets = db.users.filter(item => item.role === 'candidate' && targetIds.has(item.id) && Boolean(item.archivedAt) !== archived);
const changedAt = archived ? nowIso() : null;
for (const target of targets) {
target.archivedAt = changedAt;
target.archivedBy = archived ? user.id : null;
}
const scopeLabel = scopeType === 'class'
? db.classes.find(item => item.id === scopeValue)?.name
: scopeValue;
await database.updateCandidateArchives(targets, logAction(db, user, archived ? '批量归档考生账户' : '批量恢复考生账户', `${scopeLabel} · ${targets.length} 个账户`));
if (archived && targets.length) {
const targetUserIds = new Set(targets.map(item => item.id));
await authState.deleteUsersSessions(targetUserIds);
}
return sendJson(response, 200, { ok: true, archived, count: targets.length, scopeLabel });
}
const candidatePasswordResetMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)\/reset-password$/);
if (request.method === 'POST' && candidatePasswordResetMatch) {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置考生密码');
const profile = db.candidateProfiles.find(item => item.id === candidatePasswordResetMatch[1]);
const target = db.users.find(item => item.id === profile?.userId && item.role === 'candidate');
if (!profile || !target) return sendError(response, 404, '考生账户不存在');
if (target.archivedAt) return sendError(response, 409, '归档账户需由校方恢复后才能重置密码');
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
target.passwordHash = hashPassword(temporaryPassword);
target.mustChangePassword = true;
await database.changePassword(target, logAction(db, user, '重置考生密码', `${target.candidateNumber} · ${profile.name}`));
await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, candidateNumber: target.candidateNumber, temporaryPassword });
}
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
if (request.method === 'PATCH' && candidateMatch) {
if (!requirePermission(user, response, 'candidates.review')) return true;
const body = await readJson(request);
const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]);
if (!profile) return sendError(response, 404, '考生资料不存在');
if (!profileInScope(user, profile) && user.adminLevel !== 'super') return sendError(response, 403, '该考生不在你的数据范围内');
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
const instance = pendingWorkflow(db, 'profile_change', profile.id);
if (!instance) return sendError(response, 409, '当前没有待处理的考生信息流程');
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
profile.status = 'rejected'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile);
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
profile.status = 'pending'; profile.reviewNote = note;
} else {
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
profile.status = 'approved'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
}
const log = logAction(db, user, body.status === 'approved' ? '处理考生信息流程' : '退回考生信息', `${profile.name}${note || '无备注'}`);
await database.processWorkflow(instance, action, profile, log);
return sendJson(response, 200, { ok: true, profile, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
if (request.method === 'GET' && pathname === '/api/admin/registrations') {
if (!requirePermission(user, response, 'registrations.read')) return true;
const registrations = db.registrations.filter(registration => registrationInScope(db, user, registration)).map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const schoolClass = db.classes.find(item => item.id === profile?.classId);
return {
...examRegistrationView(db, registration),
candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null,
schoolName: db.schools.find(item => item.id === profile?.schoolId)?.name || profile?.school || '',
gradeName: schoolClass?.grade || '',
className: schoolClass?.name || profile?.grade || ''
};
});
return sendJson(response, 200, { ok: true, registrations });
}
if (request.method === 'GET' && pathname === '/api/admin/payments') {
if (!requirePermission(user, response, 'payments.read')) return true;
const registrations = db.registrations
.filter(registration => registration.status === 'approved' && registrationInScope(db, user, registration))
.map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const view = examRegistrationView(db, registration);
return {
...view,
candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null,
schoolName: db.schools.find(item => item.id === profile?.schoolId)?.name || profile?.school || '',
gradeName: db.classes.find(item => item.id === profile?.classId)?.grade || '',
className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '',
amountDue: Number(view.subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)),
paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || ''
};
});
return sendJson(response, 200, {
ok: true,
scopeLabel: adminScopeLabel(db, user),
canConfirmPayment: hasPermission(user, 'payments.write'),
canUpdatePayment: hasPermission(user, 'payments.write'),
registrations
});
}
const paymentMatch = pathname.match(/^\/api\/admin\/payments\/([^/]+)$/);
if (request.method === 'PATCH' && paymentMatch) {
if (!requirePermission(user, response, 'payments.write')) return true;
const body = await readJson(request);
const registration = db.registrations.find(item => item.id === paymentMatch[1]);
if (!registration || !registrationInScope(db, user, registration)) return sendError(response, 404, '缴费记录不存在或不在当前管理范围内');
if (registration.status !== 'approved') return sendError(response, 409, '报名审核通过后才能确认缴费');
if (db.exams.find(item => item.id === registration.examId)?.archivedAt) return sendError(response, 409, '该考试已归档,缴费记录已冻结');
const nextStatus = body.status || 'paid';
if (!['paid', 'unpaid'].includes(nextStatus)) return sendError(response, 400, '缴费状态无效');
if (registration.paymentStatus === nextStatus) return sendError(response, 409, `该考生已经是${nextStatus === 'paid' ? '已缴费' : '待缴费'}状态`);
registration.paymentStatus = nextStatus;
registration.paidAt = nextStatus === 'paid' ? nowIso() : null;
registration.paidBy = nextStatus === 'paid' ? user.id : null;
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const exam = db.exams.find(item => item.id === registration.examId);
await database.updateRegistrationPayment(
registration,
logAction(db, user, nextStatus === 'paid' ? '标记考生已缴费' : '撤销考生缴费确认', `${profile?.name || registration.registrationNumber} · ${exam?.name || registration.examId}`)
);
return sendJson(response, 200, {
ok: true,
payment: { registrationId: registration.id, status: registration.paymentStatus, paidAt: registration.paidAt, paidBy: registration.paidBy, paidByName: nextStatus === 'paid' ? user.displayName : '' }
});
}
if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') {
if (!requirePermission(user, response, 'registrations.read')) return true;
const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item));
const registrations = scopedRegistrations.map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
});
const plans = db.arrangementPlans.map(plan => ({
...plan,
examName: db.exams.find(item => item.id === plan.examId)?.name || '',
ruleName: db.admissionNumberRules.find(item => item.id === plan.numberRuleId)?.name || '',
mixingScopeName: admissionMixingScopes.find(item => item.code === plan.mixingScope)?.name || plan.mixingScope
}));
const visibleExams = user.adminLevel === 'super' ? db.exams : db.exams.filter(exam => scopedRegistrations.some(item => item.examId === exam.id));
const exams = visibleExams.map(exam => ({
...publicExam(exam),
approvedCount: scopedRegistrations.filter(item => item.examId === exam.id).length,
arrangedCount: scopedRegistrations.filter(item => item.examId === exam.id && item.admitCard).length,
plan: plans.find(item => item.examId === exam.id) || null
}));
const centerScope = user.adminLevel === 'super'
? db.testCenters
: user.adminLevel === 'school' ? db.testCenters.filter(item => item.schoolId === user.schoolId) : [];
return sendJson(response, 200, {
ok: true,
canArrange: user.adminLevel === 'super',
canExportCenterMaterials: user.adminLevel === 'school',
scopeLabel: adminScopeLabel(db, user),
exams,
registrations,
plans: user.adminLevel === 'super' ? plans : [],
rules: user.adminLevel === 'super' ? db.admissionNumberRules.filter(item => item.active) : [],
mixingScopes: user.adminLevel === 'super' ? admissionMixingScopes : [],
centers: centerScope.filter(item => item.status === 'active').map(center => ({
...center,
roomCount: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active').length,
capacity: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active' && room.roomType !== 'spare').reduce((sum, room) => sum + room.capacity, 0)
}))
});
}
const admissionExportMatch = pathname.match(/^\/api\/admin\/admission-exports\/(admit-cards|info|center-materials)$/);
if (request.method === 'GET' && admissionExportMatch) {
if (!requirePermission(user, response, 'registrations.read')) return true;
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
const examId = cleanText(requestUrl.searchParams.get('examId'), 64);
const exam = db.exams.find(item => item.id === examId);
if (!exam) return sendError(response, 404, '请选择有效考试');
const type = admissionExportMatch[1];
if (type === 'center-materials') {
if (user.adminLevel !== 'school') return sendError(response, 403, '考点桌贴、门贴和签名单只能由维护该考点的校级管理员导出');
const rows = centerMaterialRows(db, user.schoolId, exam.id);
if (!rows.length) return sendError(response, 404, '本校维护考点暂无该考试的已编排考生');
const buffer = Buffer.from(await buildCenterMaterialsWorkbook(rows, `${exam.name}${adminScopeLabel(db, user)}`));
return sendWorkbook(response, buffer, `${exam.name}-${adminScopeLabel(db, user)}-考点桌贴门贴签名单.xlsx`);
}
const scoped = db.registrations.filter(item => item.examId === exam.id && item.admitCard && registrationInScope(db, user, item));
if (!scoped.length) return sendError(response, 404, '当前范围暂无已生成的准考证');
if (type === 'admit-cards') {
const html = admitCardsHtml(db, scoped, `${exam.name}-${adminScopeLabel(db, user)}-准考证`);
const filename = encodeURIComponent(`${exam.name}-${adminScopeLabel(db, user)}-准考证批量打印.html`);
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename*=UTF-8''${filename}`, 'Cache-Control': 'no-store' });
response.end(html);
return true;
}
const rows = admissionRowsForRegistrations(db, scoped);
const buffer = Buffer.from(await buildWorkbook('admit_cards', rows, { subtitle: `${exam.name}${adminScopeLabel(db, user)}` }));
return sendWorkbook(response, buffer, `${exam.name}-${adminScopeLabel(db, user)}-准考证信息.xlsx`);
}
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
if (request.method === 'PATCH' && registrationMatch) {
if (!requirePermission(user, response, 'registrations.review')) return true;
const body = await readJson(request);
const registration = db.registrations.find(item => item.id === registrationMatch[1]);
if (!registration) return sendError(response, 404, '报名记录不存在');
if (db.exams.find(item => item.id === registration.examId)?.archivedAt) return sendError(response, 409, '该考试已归档,报名流程已冻结');
if (!registrationInScope(db, user, registration) && user.adminLevel !== 'super') return sendError(response, 403, '该报名不在你的数据范围内');
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const instance = pendingWorkflow(db, 'registration_review', registration.id);
if (!instance) return sendError(response, 409, '当前没有待处理的报名审核流程');
const workflow = db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso();
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile);
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
registration.status = 'pending'; registration.reviewNote = note;
} else {
const account = db.users.find(item => item.id === registration.userId);
if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号,请先在报名号管理中完成分配');
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
registration.status = 'approved'; registration.reviewNote = note; registration.reviewedAt = nowIso();
registration.registrationNumber = account.candidateNumber;
registration.numberRuleId = db.numberRules.find(item => item.active)?.id || registration.numberRuleId;
}
const log = logAction(db, user, body.status === 'approved' ? '处理报名审核流程' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`);
await database.processWorkflow(instance, action, registration, log);
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
const scoreAppealMatch = pathname.match(/^\/api\/admin\/score-appeals\/([^/]+)$/);
if (request.method === 'PATCH' && scoreAppealMatch) {
if (!requirePermission(user, response, 'workflows.inbox')) return true;
const body = await readJson(request);
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '复议处理状态无效');
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
const registration = db.registrations.find(item => item.id === result?.registrationId);
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
if (!result || !registration || !profile) return sendError(response, 404, '待处理的成绩复议不存在');
if (!profileInScope(user, profile)) return sendError(response, 403, '该成绩复议不在你的数据范围内');
const instance = pendingWorkflow(db, 'score_appeal', result.id);
const workflow = instance && db.workflows.find(item => item.id === instance.workflowId);
const step = workflow?.steps.find(item => item.position === instance.currentStep);
if (!instance || !workflow || !step) return sendError(response, 409, '成绩复议流程状态异常');
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
const exam = db.exams.find(item => item.id === registration.examId);
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩及复议流程已永久锁定');
const subject = exam?.subjects.find(item => item.id === result.subjectId);
const finalStep = instance.currentStep >= workflow.steps.length;
let reviewedScore = null;
if (body.status === 'approved' && finalStep) {
reviewedScore = body.reviewedScore == null || String(body.reviewedScore).trim() === '' ? Number.NaN : Number(body.reviewedScore);
if (!Number.isFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > Number(subject?.fullScore || 0)) return sendError(response, 400, `最终审批必须填写 0—${subject?.fullScore || 0} 之间的复核后分数`);
}
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
} else if (instance.currentStep < workflow.steps.length) {
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile);
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
} else {
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
const originalScore = result.score;
result.score = reviewedScore;
const rank = resultRankInfo(db, result, reviewedScore);
result.grade = rank.grade;
result.updatedAt = nowIso();
const pass = subjectPassEvaluation(db, result, subject, reviewedScore);
const passConclusion = pass.qualified == null
? '本科不判定单科达线'
: `${pass.qualified ? '达到' : '未达到'}${subject.passRule === 'rank_percent' ? `排名前 ${subject.passValue}%(当前第 ${pass.rank}/${pass.cohortSize} 名,截止第 ${pass.cutoffRank} 名)` : `固定及格线 ${pass.passScore} 分`}`;
action.note = [`原分 ${originalScore} → 复核后 ${reviewedScore}`, passConclusion, note].filter(Boolean).join('');
}
const log = logAction(db, user, body.status === 'approved' ? (finalStep ? '复议终审并更新成绩' : '处理成绩复议') : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}${finalStep && body.status === 'approved' ? ` · ${result.score} 分` : ''}`);
await database.processWorkflow(instance, action, finalStep && body.status === 'approved' ? result : null, log);
const rank = resultRankInfo(db, result);
const pass = subjectPassEvaluation(db, result, subject);
return sendJson(response, 200, {
ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance),
result: { ...result, ...rank, passScore: pass.passScore, cutoffRank: pass.cutoffRank, qualified: pass.qualified }
});
}
const arrangementMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/admission-arrangement(\/preview)?$/);
if (request.method === 'POST' && arrangementMatch) {
if (!requirePermission(user, response, '*')) return true;
const arrangementExam = db.exams.find(item => item.id === arrangementMatch[1]);
if (arrangementExam?.archivedAt) return sendError(response, 409, '该考试已归档,不能重新编排准考证');
const body = await readJson(request);
const generatedAt = nowIso();
const result = buildAdmissionArrangement(db, {
examId: arrangementMatch[1],
mixingScope: cleanText(body.mixingScope, 20),
numberRuleId: cleanText(body.numberRuleId, 64),
seed: cleanText(body.seed, 80),
generatedAt
});
if (arrangementMatch[2]) return sendJson(response, 200, {
ok: true,
preview: true,
summary: result.summary,
warnings: result.warnings,
samples: result.cards.slice(0, 5)
});
const plan = {
id: uid('arrangement'),
examId: result.exam.id,
numberRuleId: result.rule.id,
mixingScope: result.mixingScope,
randomSeed: result.seed,
...result.summary,
warnings: result.warnings,
generatedBy: user.id,
generatedAt
};
const log = logAction(db, user, db.arrangementPlans.some(item => item.examId === result.exam.id) ? '重新编排准考证' : '批量编排准考证',
`${result.exam.name} · ${plan.candidateCount} 人 · ${plan.centerCount} 个考点 · ${result.rule.name}`);
await database.saveAdmissionArrangement(plan, result.cards, log);
return sendJson(response, 200, { ok: true, plan, summary: result.summary, warnings: result.warnings, cards: result.cards });
}
const legacyAdmitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
if (request.method === 'POST' && legacyAdmitMatch) {
return sendError(response, 410, '单人生成已停用,请在“准考证编排”中按整场考试预检并批量生成');
}
if (request.method === 'GET' && pathname === '/api/admin/exams') {
if (!requirePermission(user, response, '*')) return true;
return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) });
}
if (request.method === 'POST' && pathname === '/api/admin/exams') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const name = cleanText(body.name, 100);
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
const subjects = normalizeSubjects(body.subjects, body.examStart);
const requestedPolicy = body.passPolicy === 'score_ratio' ? 'rank_percent' : body.passPolicy;
const passPolicy = passPolicies.has(requestedPolicy) ? requestedPolicy : 'rank_percent';
const passValue = ['subject_scores', 'none'].includes(passPolicy) ? 0 : Number(body.passValue ?? 60);
const scoringError = validateExamScoring(subjects, passPolicy, passValue);
if (scoringError) return sendError(response, 400, scoringError);
const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), passPolicy, passValue, status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() };
const log = logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`);
await database.createExam(exam, log);
return sendJson(response, 201, { ok: true, exam });
}
const examArchiveMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/archive$/);
if (request.method === 'POST' && examArchiveMatch) {
if (!requirePermission(user, response, '*')) return true;
const exam = db.exams.find(item => item.id === examArchiveMatch[1]);
if (!exam) return sendError(response, 404, '考试不存在');
if (exam.archivedAt) return sendError(response, 409, '该考试已经归档,归档操作不可撤销');
const resultIds = new Set(db.results.filter(result => {
const registration = db.registrations.find(item => item.id === result.registrationId);
return registration?.examId === exam.id;
}).map(result => result.id));
const pendingAppeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && instance.status === 'pending' && resultIds.has(instance.businessId));
if (pendingAppeals.length) return sendError(response, 409, `本场还有 ${pendingAppeals.length} 项成绩复议待处理,请先办结后再归档`);
exam.archivedAt = nowIso();
exam.archivedBy = user.id;
exam.status = 'closed';
const log = logAction(db, user, '归档考试并永久锁定成绩', `${exam.name} · ${exam.code}`);
await database.archiveExam(exam, log);
return sendJson(response, 200, { ok: true, exam: publicExam(exam), message: '考试已归档,全部成绩已永久锁定' });
}
const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/);
if (request.method === 'PATCH' && examMatch) {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const exam = db.exams.find(item => item.id === examMatch[1]);
if (!exam) return sendError(response, 404, '考试不存在');
if (exam.archivedAt) return sendError(response, 409, '该考试已归档,所有配置和成绩均已锁定');
const originalStatus = exam.status;
const detailFields = ['code', 'name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'];
const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null || body.passPolicy != null || body.passValue != null;
if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑');
if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status;
detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); });
if (body.passPolicy != null) {
const requestedPolicy = body.passPolicy === 'score_ratio' ? 'rank_percent' : body.passPolicy;
if (passPolicies.has(requestedPolicy)) exam.passPolicy = requestedPolicy;
}
if (body.passValue != null) exam.passValue = Number(body.passValue);
let replaceSubjects = false;
if (body.subjects != null) {
if (db.registrations.some(registration => registration.examId === exam.id)) return sendError(response, 409, '已有报名记录,不能修改考试科目');
exam.subjects = normalizeSubjects(body.subjects, exam.examStart);
replaceSubjects = true;
}
const scoringError = validateExamScoring(exam.subjects, exam.passPolicy, Number(exam.passValue));
if (scoringError) return sendError(response, 400, scoringError);
if (!exam.name || !exam.registrationStart || !exam.registrationEnd || !exam.examStart || !exam.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
if (exam.status === 'published' && !exam.subjects.length) return sendError(response, 400, '请先配置考试科目再发布');
const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`);
await database.updateExam(exam, log, replaceSubjects);
return sendJson(response, 200, { ok: true, exam });
}
if (request.method === 'GET' && pathname === '/api/admin/notices') {
if (!requirePermission(user, response, '*')) return true;
const notices = db.notices
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
.map(noticeForClient);
return sendJson(response, 200, { ok: true, notices, publications: systemPublications(db) });
}
if (request.method === 'POST' && pathname === '/api/admin/notices') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const title = cleanText(body.title, 120);
const content = sanitizeNoticeContent(body.content);
const contentText = noticePlainText(content);
if (!title || !contentText) return sendError(response, 400, '通知标题和正文不能为空');
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || contentText.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
await database.createNotice(notice, log);
await cache.invalidate('public');
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
}
const publicationMatch = pathname.match(/^\/api\/admin\/publications\/(plan|qualification|admission|cutoff|reporting)\/([^/]+)$/);
if (request.method === 'PATCH' && publicationMatch) {
if (!requirePermission(user, response, '*')) return true;
const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication', reporting: 'notification' };
const sourceType = publicationMatch[1];
const record = sourceType === 'admission'
? (db.admissionRecords || []).find(item => item.id === publicationMatch[2] && (item.kind === 'setting' || (item.kind === 'notification' && item.payload?.type === 'admission_round_publication')))
: admissionRecords(db, kindByType[sourceType]).find(item => item.id === publicationMatch[2] && (sourceType !== 'reporting' || item.payload?.type === 'admission_reporting'));
if (!record) return sendError(response, 404, '系统公示不存在');
const body = await readJson(request);
if (typeof body.visible !== 'boolean') return sendError(response, 400, '请明确设置是否显示');
record.payload = { ...record.payload, publicVisible: body.visible };
record.updatedAt = nowIso();
await database.saveAdmissionRecord(record, logAction(db, user, body.visible ? '显示系统公示' : '隐藏系统公示', `${sourceType} · ${record.id}`));
await cache.invalidate('public');
return sendJson(response, 200, { ok: true, publication: systemPublications({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }).find(item => item.id === record.id && item.sourceType === sourceType) });
}
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
if (request.method === 'PATCH' && noticeMatch) {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const notice = db.notices.find(item => item.id === noticeMatch[1]);
if (!notice) return sendError(response, 404, '通知不存在');
['title', 'summary', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], 260); });
if (body.content != null) {
const content = sanitizeNoticeContent(body.content);
if (!noticePlainText(content)) return sendError(response, 400, '通知正文不能为空');
notice.content = content;
}
if (body.pinned != null) notice.pinned = Boolean(body.pinned);
if (body.status && ['draft', 'published'].includes(body.status)) {
notice.status = body.status;
if (body.status === 'published' && !notice.publishAt) notice.publishAt = nowIso();
}
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
await database.updateNotice(notice, log);
await cache.invalidate('public');
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
}
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
if (!requirePermission(user, response, '*')) return true;
const refreshed = await cache.invalidate('results');
return sendJson(response, 200, {
ok: true,
refreshed,
cacheStatus: cache.status,
message: refreshed ? '成绩 Redis 缓存已刷新,后续查询将重新生成缓存' : 'Redis 缓存当前未连接或刷新失败,成绩查询继续直接读取数据库'
});
}
if (request.method === 'GET' && pathname === '/api/admin/results') {
if (!requirePermission(user, response, 'results.read')) return true;
const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId');
const selectedExam = db.exams.find(item => item.id === requestedExamId);
if (!selectedExam) return sendError(response, 404, '请选择有效的考试后再读取成绩');
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
const selectedScopedRegistrations = scopedRegistrations.filter(item => item.examId === selectedExam.id);
if (user.adminLevel !== 'super' && !selectedScopedRegistrations.length) return sendError(response, 404, '该考试不在当前管理范围内');
const approved = selectedScopedRegistrations.filter(item => item.status === 'approved');
const selectedRegistrationIds = new Set(selectedScopedRegistrations.map(item => item.id));
const examRegistrations = db.registrations.filter(item => item.examId === selectedExam.id && item.status === 'approved');
const examRegistrationIds = new Set(examRegistrations.map(item => item.id));
const examResults = db.results.filter(result => examRegistrationIds.has(result.registrationId));
const scoreDb = { ...db, registrations: examRegistrations, results: examResults };
const registrationById = new Map(selectedScopedRegistrations.map(item => [item.id, item]));
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
const accountById = new Map(db.users.map(item => [item.id, item]));
const classById = new Map(db.classes.map(item => [item.id, item]));
const rawScopedResults = examResults.filter(result => selectedRegistrationIds.has(result.registrationId));
const results = rawScopedResults.map(result => {
const registration = registrationById.get(result.registrationId);
const profile = profileByUserId.get(registration?.userId);
const account = accountById.get(registration?.userId);
const subject = selectedExam.subjects.find(item => item.id === result.subjectId);
const pass = subjectPassEvaluation(scoreDb, result, subject);
return {
...result, grade: result.published ? pass.grade : result.grade, rank: pass.rank, cohortSize: pass.cohortSize, rankPercent: pass.rankPercent,
candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '',
schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '',
examId: selectedExam.id, examCode: selectedExam.code, examName: selectedExam.name, subjectName: subject?.name,
fullScore: subject?.fullScore, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore,
passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject),
qualified: pass.qualified
};
});
const resultById = new Map(results.map(result => [result.id, result]));
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && resultById.has(instance.businessId)).map(instance => {
const result = resultById.get(instance.businessId);
const workflow = workflowView(db, instance);
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
});
const exams = db.exams.map(exam => {
if (exam.id !== selectedExam.id) return publicExam(exam);
const summaries = approved.map(item => examResultSummary(scoreDb, item)).filter(Boolean);
const enrolledSubjects = approved.reduce((sum, item) => sum + item.subjectIds.length, 0);
const scored = rawScopedResults.length;
return {
...publicExam(exam), registrationCount: approved.length, enrolledSubjects, scored,
published: rawScopedResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored),
complete: summaries.filter(item => item.complete).length,
qualified: summaries.filter(item => item.complete && item.qualified === true).length,
unqualified: summaries.filter(item => item.complete && item.qualified === false).length,
appeals: appeals.length
};
});
const registrations = user.adminLevel === 'super' ? approved.map(item => {
const view = examRegistrationView(db, item);
const profile = profileByUserId.get(item.userId);
const account = accountById.get(item.userId);
const specialty = resolveProfileSpecialty(profile || {});
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '', specialtyCategory: specialty.category, specialtyType: specialty.type, specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生' };
}) : [];
return sendJson(response, 200, { ok: true, selectedExamId: selectedExam.id, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } });
}
if (request.method === 'POST' && pathname === '/api/admin/results/import') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const result = await commitResultImport(db, user, body.rows);
return sendJson(response, 200, { ok: true, ...result });
}
if (request.method === 'POST' && pathname === '/api/admin/results/bulk') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
const subject = exam?.subjects.find(item => item.id === cleanText(body.subjectId, 64));
if (!exam || !subject) return sendError(response, 400, '请选择有效且未归档的考试科目');
const sourceRows = [];
const seen = new Set();
for (const [index, row] of (Array.isArray(body.rows) ? body.rows : []).entries()) {
const registration = db.registrations.find(item => item.id === row.registrationId && item.examId === exam.id && item.status === 'approved' && item.subjectIds.includes(subject.id));
if (!registration || seen.has(registration.id)) return sendError(response, 400, `第 ${index + 1} 条考生成绩无效或重复`);
seen.add(registration.id);
const account = db.users.find(item => item.id === registration.userId);
sourceRows.push({
__row: index + 3,
candidateNumber: account?.candidateNumber || registration.registrationNumber || '',
examCode: exam.code,
subjectName: subject.name,
score: row.score,
published: body.published === true
});
}
if (!sourceRows.length) return sendError(response, 400, '没有需要保存的成绩');
const result = await commitResultImport(db, user, sourceRows);
return sendJson(response, 200, { ok: true, published: body.published === true, ...result });
}
if (request.method === 'POST' && pathname === '/api/admin/feature-scores/bulk') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
if (!exam) return sendError(response, 400, '请选择有效且未归档的考试');
const entries = [];
const seen = new Set();
for (const [index, row] of (Array.isArray(body.rows) ? body.rows : []).entries()) {
const registration = db.registrations.find(item => item.id === row.registrationId && item.examId === exam.id && item.status === 'approved');
const featureScore = Number(row.featureScore);
if (!registration || seen.has(registration.id)) return sendError(response, 400, `第 ${index + 1} 条考生记录无效或重复`);
if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, `第 ${index + 1} 条特征分必须在 0—1000 之间`);
seen.add(registration.id);
registration.featureScore = Number(featureScore.toFixed(2));
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
entries.push({ registration, log: logAction(db, user, '批量登记特征分', `${profile?.name || registration.userId} · ${exam.name} · ${registration.featureScore}`) });
}
if (!entries.length) return sendError(response, 400, '没有需要保存的特征分');
await database.updateFeatureScores(entries);
return sendJson(response, 200, { ok: true, count: entries.length });
}
const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/);
if (request.method === 'PATCH' && featureScoreMatch) {
if (!requirePermission(user, response, '*')) return true;
const registration = db.registrations.find(item => item.id === featureScoreMatch[1] && item.status === 'approved');
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
const exam = db.exams.find(item => item.id === registration.examId);
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,特征分已永久锁定');
const body = await readJson(request);
const featureScore = Number(body.featureScore);
if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, '特征分必须在 0—1000 之间');
registration.featureScore = Number(featureScore.toFixed(2));
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
await database.updateFeatureScore(registration, logAction(db, user, '登记特征分', `${profile?.name || registration.userId} · ${exam?.name || registration.examId} · ${registration.featureScore}`));
return sendJson(response, 200, { ok: true, registration });
}
if (request.method === 'POST' && pathname === '/api/admin/results') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const registration = db.registrations.find(item => item.id === body.registrationId && item.status === 'approved');
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
const exam = db.exams.find(item => item.id === registration.examId);
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩已永久锁定');
if (!registration.subjectIds.includes(body.subjectId) || !exam.subjects.some(item => item.id === body.subjectId)) return sendError(response, 400, '该考生未报名此科目');
const score = Number(body.score);
const subject = exam.subjects.find(item => item.id === body.subjectId);
if (!Number.isFinite(score) || score < 0 || score > subject.fullScore) return sendError(response, 400, `成绩必须在 0—${subject.fullScore} 之间`);
let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === body.subjectId);
const isNew = !result;
if (!result) {
result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId };
db.results.push(result);
}
Object.assign(result, { score, published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? (result.publishedAt || nowIso()) : null });
result.grade = result.published ? resultRankInfo(db, result, score).grade : '待发布';
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
await database.saveResult(result, isNew, log);
return sendJson(response, 200, { ok: true, result });
}
return sendError(response, 404, '管理功能接口不存在');
}
return handleAdmin;
}