Refactor exam information workflow

This commit is contained in:
2026-07-20 10:07:14 +08:00 Unverified
parent d4e085b6c5
commit 7094e9b916
22 changed files with 3233 additions and 2793 deletions
+714
View File
@@ -0,0 +1,714 @@
export function createAdminRoutes(context) {
const {
database,
readDb,
sendJson,
sendError,
readJson,
readBodyBuffer,
sendWorkbook,
currentUser,
safeUser,
requireUser,
hasPermission,
requirePermission,
profileInScope,
registrationInScope,
adminScopeLabel,
adminsForStep,
activeWorkflow,
createWorkflowSubmission,
workflowView,
pendingWorkflow,
candidateSequence,
generateCandidateNumber,
cleanText,
centerScopeProfile,
workflowScopeProfile,
candidateAccountBatchView,
centerChangeView,
parseCenterChange,
maskId,
publicExam,
examRegistrationView,
logAction,
excelResourceNames,
excelRowsForResource,
importExcelResource,
admitCardHtml,
hashPassword,
verifyPassword,
randomBytes,
uid,
nowIso,
sessions,
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames,
permissionsByLevel
} = context;
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));
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 = adminsForStep(db, nextStep.adminLevel, centerScopeProfile(db, batch.schoolId))[0];
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 = adminsForStep(db, nextStep.adminLevel, centerScopeProfile(db, change.schoolId))[0];
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, 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)$/);
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 => !['school', 'super'].includes(item.adminLevel))) 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 (user.adminLevel === 'class') return sendError(response, 403, '班级管理员只读查看考生、成绩和报名状态');
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;
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
};
});
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, '校级流程只能转交给本校同级管理员');
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 assignee = eligible.find(item => item.id === body.assigneeId) || eligible[0];
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)
: db.candidateAccountBatches.find(item => item.id === instance.businessId);
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;
if (user.adminLevel === 'class') return false;
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 = adminsForStep(db, nextStep.adminLevel, profile)[0];
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 });
}
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 = adminsForStep(db, nextStep.adminLevel, profile)[0];
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 admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
if (request.method === 'POST' && admitMatch) {
if (!requirePermission(user, response, '*')) return true;
const registration = db.registrations.find(item => item.id === admitMatch[1]);
if (!registration) return sendError(response, 404, '报名记录不存在');
if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证');
if (!registration.admitCard) {
const exam = db.exams.find(item => item.id === registration.examId);
const sequence = String(db.registrations.filter(item => item.examId === exam.id && item.admitCard).length + 1).padStart(4, '0');
registration.admitCard = {
number: `${exam.code.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-12) || String(new Date().getFullYear())}-${sequence}`,
testCenter: cleanText((await readJson(request)).testCenter || '海州市第一中学', 80),
room: `0${Math.ceil(Number(sequence) / 30) || 1} 考场`,
seat: String(((Number(sequence) - 1) % 30) + 1).padStart(2, '0'),
generatedAt: nowIso()
};
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const log = logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`);
await database.createAdmitCard(registration.id, registration.admitCard, log);
}
return sendJson(response, 200, { ok: true, admitCard: registration.admitCard });
}
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 subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,]/);
const subjects = subjectNames.map(name => cleanText(typeof name === 'string' ? name : name.name, 30)).filter(Boolean).map((name, index) => ({ id: uid('sub'), name, date: cleanText(body.examStart, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
if (!subjects.length) return sendError(response, 400, '请至少添加一个考试科目');
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), 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;
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); });
let replaceSubjects = false;
if (body.subjects != null) {
if (db.registrations.some(registration => registration.examId === exam.id)) return sendError(response, 409, '已有报名记录,不能修改考试科目');
const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,]/);
const names = subjectNames.map(item => cleanText(typeof item === 'string' ? item : item.name, 30)).filter(Boolean);
if (!names.length) return sendError(response, 400, '请至少添加一个考试科目');
exam.subjects = names.map((name, index) => ({ id: uid('sub'), name, date: String(exam.examStart).slice(0, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
replaceSubjects = true;
}
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 exam = db.exams.find(item => item.id === registration?.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name };
});
return sendJson(response, 200, { ok: true, results, registrations: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] });
}
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);
if (!Number.isFinite(score) || score < 0 || score > 150) return sendError(response, 400, '成绩必须在 0—150 之间');
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, grade: cleanText(body.grade, 10) || (score >= 135 ? 'A+' : score >= 120 ? 'A' : score >= 105 ? 'B+' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null });
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const subject = exam.subjects.find(item => item.id === body.subjectId);
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;
}
+118
View File
@@ -0,0 +1,118 @@
export function createAuthRoutes(context) {
const {
database,
readDb,
sendJson,
sendError,
readJson,
readBodyBuffer,
sendWorkbook,
currentUser,
parseCookies,
safeUser,
requireUser,
hasPermission,
requirePermission,
profileInScope,
registrationInScope,
adminScopeLabel,
adminsForStep,
activeWorkflow,
createWorkflowSubmission,
workflowView,
pendingWorkflow,
candidateSequence,
generateCandidateNumber,
cleanText,
centerScopeProfile,
workflowScopeProfile,
candidateAccountBatchView,
centerChangeView,
parseCenterChange,
maskId,
publicExam,
examRegistrationView,
logAction,
excelResourceNames,
excelRowsForResource,
importExcelResource,
admitCardHtml,
hashPassword,
verifyPassword,
randomBytes,
uid,
nowIso,
sessions,
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames,
permissionsByLevel
} = context;
async function handleAuth(request, response, pathname) {
if (request.method === 'GET' && pathname === '/api/auth/me') {
const user = await currentUser(request);
if (!user) return sendJson(response, 200, { ok: true, user: null });
const db = await readDb();
const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null;
return sendJson(response, 200, { ok: true, user: safeUser(user), profile, ...(user.role === 'admin' ? { permissions: permissionsByLevel[user.adminLevel || 'super'], scopeLabel: adminScopeLabel(db, user) } : {}) });
}
if (request.method === 'POST' && pathname === '/api/auth/register') {
const body = await readJson(request);
const password = String(body.password || '');
const name = cleanText(body.name, 30);
const gender = cleanText(body.gender, 10);
if (!name || !['男', '女'].includes(gender)) return sendError(response, 400, '请填写姓名并选择性别');
if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位');
const db = await readDb();
if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录');
const schoolId = cleanText(body.schoolId, 64);
const classId = cleanText(body.classId, 64);
const school = db.schools.find(item => item.id === schoolId && item.active);
const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active);
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
const draftProfile = { schoolId, classId, gender };
const generated = generateCandidateNumber(db, draftProfile);
const userId = uid('usr');
const user = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(password), role: 'candidate', displayName: name, active: true, mustChangePassword: false, createdAt: nowIso() };
const profile = { id: uid('profile'), userId, name, idNumber: `PENDING-${userId}`, phone: '', gender, email: '', school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() };
await database.createCandidate(user, profile, null, null);
return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' });
}
if (request.method === 'POST' && pathname === '/api/auth/login') {
const body = await readJson(request);
const db = await readDb();
const account = cleanText(body.username, 120).toLowerCase();
const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account);
if (!user || user.active === false || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
const token = randomBytes(32).toString('hex');
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` });
}
if (request.method === 'POST' && pathname === '/api/auth/change-password') {
const user = await requireUser(request, response);
if (!user) return true;
const body = await readJson(request);
const currentPassword = String(body.currentPassword || '');
const newPassword = String(body.newPassword || '');
if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确');
if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位');
if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与初始密码相同');
user.passwordHash = hashPassword(newPassword);
user.mustChangePassword = false;
const db = await readDb();
const log = logAction(db, user, '修改登录密码', user.role === 'candidate' ? `报名号 ${user.candidateNumber}` : user.username);
await database.changePassword(user, log);
return sendJson(response, 200, { ok: true, user: safeUser(user) });
}
if (request.method === 'POST' && pathname === '/api/auth/logout') {
const token = parseCookies(request).hz_session;
if (token) sessions.delete(token);
return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' });
}
return false;
}
return handleAuth;
}
+146
View File
@@ -0,0 +1,146 @@
export function createCandidateRoutes(context) {
const {
database,
readDb,
sendJson,
sendError,
readJson,
readBodyBuffer,
sendWorkbook,
currentUser,
safeUser,
requireUser,
hasPermission,
requirePermission,
profileInScope,
registrationInScope,
adminScopeLabel,
adminsForStep,
activeWorkflow,
createWorkflowSubmission,
workflowView,
pendingWorkflow,
candidateSequence,
generateCandidateNumber,
cleanText,
centerScopeProfile,
workflowScopeProfile,
candidateAccountBatchView,
centerChangeView,
parseCenterChange,
maskId,
publicExam,
examRegistrationView,
logAction,
excelResourceNames,
excelRowsForResource,
importExcelResource,
admitCardHtml,
hashPassword,
verifyPassword,
uid,
nowIso,
sessions,
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames
} = context;
async function handleCandidate(request, response, pathname) {
if (!pathname.startsWith('/api/candidate/')) return false;
const user = await requireUser(request, response, 'candidate');
if (!user) return true;
const db = await readDb();
const profile = db.candidateProfiles.find(item => item.userId === user.id);
if (user.mustChangePassword) return sendError(response, 428, '首次登录必须先修改初始密码');
const profileRoute = pathname === '/api/candidate/profile';
if (!profile.profileCompleted && !profileRoute) return sendError(response, 428, '请先补全个人信息并提交审核');
if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5);
const profileInstance = pendingWorkflow(db, 'profile_change', profile.id)
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices });
}
if (request.method === 'GET' && pathname === '/api/candidate/profile') {
const instance = pendingWorkflow(db, 'profile_change', profile.id)
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active) });
}
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
const body = await readJson(request);
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone'];
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
profile.schoolId = school.id;
profile.classId = schoolClass.id;
profile.school = school.name;
profile.grade = schoolClass.name;
if (!profile.name || !['男', '女'].includes(profile.gender) || !profile.idNumber || profile.idNumber.startsWith('PENDING-') || !profile.nativePlace || !profile.address || !profile.phone || !profile.email || !profile.school || !profile.classId) return sendError(response, 400, '请完整填写姓名、性别、证件号码、籍贯、家庭住址、手机号、邮箱、学校和班级');
if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用');
profile.status = 'pending';
profile.profileCompleted = true;
profile.reviewNote = '';
profile.updatedAt = nowIso();
const existingWorkflow = pendingWorkflow(db, 'profile_change', profile.id);
const submission = existingWorkflow ? null : createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id);
await database.updateCandidateProfile(profile, profile.name, submission?.instance, submission?.action);
return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' });
}
if (request.method === 'GET' && pathname === '/api/candidate/exams') {
const registrations = db.registrations.filter(item => item.userId === user.id);
const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registration: registrations.find(reg => reg.examId === exam.id) || null }));
return sendJson(response, 200, { ok: true, profileStatus: profile.status, exams });
}
if (request.method === 'GET' && pathname === '/api/candidate/registrations') {
return sendJson(response, 200, { ok: true, registrations: db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)) });
}
if (request.method === 'POST' && pathname === '/api/candidate/registrations') {
if (profile.status !== 'approved') return sendError(response, 403, '个人资料审核通过后才能报名考试');
const body = await readJson(request);
const exam = db.exams.find(item => item.id === body.examId && item.status === 'published');
if (!exam) return sendError(response, 404, '考试不存在或尚未发布');
const state = publicExam(exam).registrationState;
if (state !== 'open') return sendError(response, 400, state === 'upcoming' ? '报名尚未开始' : '报名已经截止');
if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试');
const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])];
if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目');
const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), registrationNumber: user.candidateNumber, numberRuleId: db.numberRules.find(item => item.active)?.id || null, admitCard: null };
const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id);
await database.createRegistration(registration, instance, action);
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
}
if (request.method === 'GET' && pathname === '/api/candidate/results') {
const registrations = db.registrations.filter(item => item.userId === user.id);
const results = db.results.filter(item => item.published && registrations.some(reg => reg.id === item.registrationId)).map(result => {
const registration = registrations.find(reg => reg.id === result.registrationId);
const exam = db.exams.find(item => item.id === registration.examId);
const subject = exam.subjects.find(item => item.id === result.subjectId);
return { ...result, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId };
});
return sendJson(response, 200, { ok: true, results });
}
const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/);
if (request.method === 'GET' && admitMatch) {
const registration = db.registrations.find(item => item.id === admitMatch[1] && item.userId === user.id);
if (!registration || !registration.admitCard) return sendError(response, 404, '准考证尚未生成');
const exam = db.exams.find(item => item.id === registration.examId);
const now = Date.now();
if (now < new Date(exam.admitDownloadStart).getTime()) return sendError(response, 403, '准考证下载尚未开放');
if (now > new Date(exam.admitDownloadEnd).getTime()) return sendError(response, 403, '准考证下载时间已结束');
const html = admitCardHtml(db, user, profile, registration);
const filename = encodeURIComponent(`${exam.name}-${profile.name}-准考证.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;
}
return sendError(response, 404, '考生功能接口不存在');
}
return handleCandidate;
}
+66
View File
@@ -0,0 +1,66 @@
export function createPublicRoutes(context) {
const {
database,
readDb,
sendJson,
sendError,
readJson,
readBodyBuffer,
sendWorkbook,
currentUser,
safeUser,
requireUser,
hasPermission,
requirePermission,
profileInScope,
registrationInScope,
adminScopeLabel,
adminsForStep,
activeWorkflow,
createWorkflowSubmission,
workflowView,
pendingWorkflow,
candidateSequence,
generateCandidateNumber,
cleanText,
centerScopeProfile,
workflowScopeProfile,
candidateAccountBatchView,
centerChangeView,
parseCenterChange,
maskId,
publicExam,
examRegistrationView,
logAction,
excelResourceNames,
excelRowsForResource,
importExcelResource,
admitCardHtml,
hashPassword,
verifyPassword,
uid,
nowIso,
sessions,
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames
} = context;
async function handlePublic(pathname, response) {
const db = await readDb();
if (pathname === '/api/public/home') {
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } });
}
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
if (noticeMatch) {
const notice = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
}
return false;
}
return handlePublic;
}