Files
Exam-Information-System/src/routes/admin.routes.mjs
T

990 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
export function createAdminRoutes(context) {
const {
database,
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,
sessions,
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 '';
}
async function handleAdmin(request, response, pathname) {
if (!pathname.startsWith('/api/admin/')) return false;
const user = await requireUser(request, response, 'admin');
if (!user) return true;
const db = await readDb();
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
});
}
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|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 === '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 ? [] : 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 (resource === 'account_results') 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/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, '只有校级管理员可以新增本校班级');
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, 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)) 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 (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员');
const body = await readJson(request);
const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId);
if (!target) return sendError(response, 404, '班级管理员不存在');
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId);
if (!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, 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} · ${schoolClass.name}`));
return sendJson(response, 200, { ok: true, admin: safeUser(target) });
}
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) });
}
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;
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, 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);
return { ...profile, idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber), username: account?.username, candidateNumber: account?.candidateNumber || '', mustChangePassword: Boolean(account?.mustChangePassword), 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)) });
}
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);
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
});
return sendJson(response, 200, { ok: true, registrations });
}
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 (!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.paymentStatus = 'paid'; 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);
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 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 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, '考试不存在');
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;
return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) });
}
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 = cleanText(body.content, 5000);
if (!title || !content) return sendError(response, 400, '通知标题和正文不能为空');
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || content.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);
return sendJson(response, 201, { ok: true, notice });
}
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', 'content', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], field === 'content' ? 5000 : 260); });
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);
return sendJson(response, 200, { ok: true, notice });
}
if (request.method === 'GET' && pathname === '/api/admin/results') {
if (!requirePermission(user, response, 'results.read')) return true;
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
const registration = db.registrations.find(item => item.id === result.registrationId);
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
const account = db.users.find(item => item.id === registration?.userId);
const exam = db.exams.find(item => item.id === registration?.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
const pass = subjectPassEvaluation(db, result, subject);
const rank = resultRankInfo(db, result);
return {
...result, grade: result.published ? rank.grade : result.grade, rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent,
candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '',
schoolName: profile?.school || '', className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '',
examId: exam?.id, examCode: exam?.code, examName: exam?.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 appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && results.some(result => result.id === instance.businessId)).map(instance => {
const result = results.find(item => item.id === instance.businessId);
const workflow = workflowView(db, instance);
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
});
const approved = scopedRegistrations.filter(item => item.status === 'approved');
const exams = db.exams.map(exam => {
const examRegistrations = approved.filter(item => item.examId === exam.id);
const examResults = results.filter(item => item.examId === exam.id);
const summaries = examRegistrations.map(item => examResultSummary(db, item)).filter(Boolean);
const enrolledSubjects = examRegistrations.reduce((sum, item) => sum + item.subjectIds.length, 0);
const scored = examResults.length;
return {
...publicExam(exam), registrationCount: examRegistrations.length, enrolledSubjects, scored,
published: examResults.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.filter(item => item.result?.examId === exam.id).length
};
}).filter(exam => exam.registrationCount || results.some(item => item.examId === exam.id) || user.adminLevel === 'super');
const registrations = user.adminLevel === 'super' ? approved.map(item => {
const view = examRegistrationView(db, item);
const profile = db.candidateProfiles.find(profileItem => profileItem.userId === item.userId);
const account = db.users.find(accountItem => accountItem.id === item.userId);
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '' };
}) : [];
return sendJson(response, 200, { ok: true, results, appeals, registrations, exams });
}
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') {
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 (!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;
}