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; }