1882 lines
151 KiB
JavaScript
1882 lines
151 KiB
JavaScript
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;
|
||
}
|