966 lines
58 KiB
JavaScript
966 lines
58 KiB
JavaScript
import { createServer } from 'node:http';
|
||
import { existsSync } from 'node:fs';
|
||
import { readFile } from 'node:fs/promises';
|
||
import { extname, join, normalize, resolve } from 'node:path';
|
||
import { loadEnvFile } from 'node:process';
|
||
import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto';
|
||
import { createDatabase } from './database.mjs';
|
||
import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs';
|
||
import { createAdminRoutes } from './src/routes/admin.routes.mjs';
|
||
import { createCandidateRoutes } from './src/routes/candidate.routes.mjs';
|
||
import { createAdmissionRoutes } from './src/routes/admission.routes.mjs';
|
||
import { createAuthRoutes } from './src/routes/auth.routes.mjs';
|
||
import { createPublicRoutes } from './src/routes/public.routes.mjs';
|
||
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs';
|
||
import { createSessionManager } from './src/security/session.mjs';
|
||
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
|
||
import { createBaseDatabase } from './src/data/base.mjs';
|
||
import { resolveRegion } from './src/data/region-service.mjs';
|
||
import { createRedisCache, withCacheInvalidation } from './src/cache/redis-cache.mjs';
|
||
|
||
const root = resolve(process.cwd());
|
||
const envPath = join(root, '.env');
|
||
if (existsSync(envPath)) loadEnvFile(envPath);
|
||
|
||
const port = Number(process.env.PORT || 4173);
|
||
const host = process.env.HOST || '127.0.0.1';
|
||
const publicSiteConfig = Object.freeze({
|
||
organization: {
|
||
name: process.env.PUBLIC_SITE_NAME || '考试服务平台',
|
||
code: process.env.PUBLIC_SITE_CODE || 'EXAM-SERVICE',
|
||
phone: process.env.PUBLIC_SITE_PHONE || '',
|
||
address: process.env.PUBLIC_SITE_ADDRESS || '',
|
||
email: process.env.PUBLIC_SITE_EMAIL || ''
|
||
},
|
||
heroEyebrow: process.env.PUBLIC_SITE_HERO_EYEBROW || 'EXAMINATION SERVICE',
|
||
heroTitle: process.env.PUBLIC_SITE_HERO_TITLE || '一个报名号,',
|
||
heroHighlight: process.env.PUBLIC_SITE_HERO_HIGHLIGHT || '贯穿每一次考试。',
|
||
heroDescription: process.env.PUBLIC_SITE_HERO_DESCRIPTION || '使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。',
|
||
footerNotice: process.env.PUBLIC_SITE_FOOTER_NOTICE || ''
|
||
});
|
||
const sessions = new Map();
|
||
const staticFiles = new Set([
|
||
'/index.html',
|
||
'/styles.css',
|
||
'/app.js',
|
||
'/src/client/api.mjs',
|
||
'/src/client/admin-views.mjs',
|
||
'/src/client/candidate-views.mjs',
|
||
'/src/client/admission-views.mjs',
|
||
'/src/client/admission-plan-editor.mjs',
|
||
'/src/client/public-views.mjs',
|
||
'/src/client/state.mjs',
|
||
'/src/client/ui.mjs',
|
||
'/src/client/region-select.mjs',
|
||
'/src/data/china-regions.mjs',
|
||
'/src/data/specialty-types.mjs'
|
||
]);
|
||
const vendorStaticFiles = new Map([
|
||
['/vendor/ckeditor5/ckeditor5.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.js')],
|
||
['/vendor/ckeditor5/ckeditor5.css', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.css')],
|
||
['/vendor/ckeditor5/translations/zh-cn.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'translations', 'zh-cn.js')]
|
||
]);
|
||
const mimeTypes = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.mjs': 'text/javascript; charset=utf-8',
|
||
'.svg': 'image/svg+xml'
|
||
};
|
||
|
||
function nowIso() {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
function uid(prefix) {
|
||
return `${prefix}_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`;
|
||
}
|
||
|
||
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||
return `${salt}:${hash}`;
|
||
}
|
||
|
||
function verifyPassword(password, stored) {
|
||
const [salt, expected] = String(stored).split(':');
|
||
if (!salt || !expected) return false;
|
||
const actual = pbkdf2Sync(password, salt, 120000, 32, 'sha256');
|
||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||
return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer);
|
||
}
|
||
|
||
const initializeDatabase = () => createBaseDatabase({
|
||
nowIso,
|
||
hashPassword,
|
||
initialAdmin: {
|
||
username: process.env.INITIAL_ADMIN_USERNAME,
|
||
password: process.env.INITIAL_ADMIN_PASSWORD,
|
||
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
|
||
}
|
||
});
|
||
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
|
||
const cache = await createRedisCache();
|
||
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateExam', 'archiveExam']);
|
||
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
|
||
const namespaces = ['public'];
|
||
const instance = args[0];
|
||
if (resultCacheWriteMethods.has(method)
|
||
|| (['createWorkflow', 'processWorkflow', 'transferWorkflow'].includes(method) && instance?.businessType === 'score_appeal')
|
||
|| (method === 'saveWorkflow' && instance?.businessType === 'score_appeal')) {
|
||
namespaces.push('results');
|
||
}
|
||
return namespaces;
|
||
});
|
||
const readDb = () => database.read();
|
||
|
||
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
|
||
const requirePermission = createPermissionGuard(sendError);
|
||
|
||
function adminsForStep(db, adminLevel, profile) {
|
||
return db.users.filter(item => {
|
||
if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false;
|
||
if (adminLevel === 'super') return true;
|
||
if (adminLevel === 'school') return Boolean(profile?.schoolId && item.schoolId === profile.schoolId);
|
||
if (adminLevel === 'class') return Boolean(
|
||
profile?.schoolId && profile?.classId
|
||
&& item.schoolId === profile.schoolId && item.classId === profile.classId
|
||
);
|
||
return false;
|
||
});
|
||
}
|
||
|
||
function selectAdminForStep(db, adminLevel, profile) {
|
||
const pendingByAdmin = new Map();
|
||
for (const instance of db.workflowInstances) {
|
||
if (instance.status !== 'pending' || !instance.assigneeId) continue;
|
||
pendingByAdmin.set(instance.assigneeId, (pendingByAdmin.get(instance.assigneeId) || 0) + 1);
|
||
}
|
||
const assignedByAdmin = new Map();
|
||
for (const action of db.workflowActions) {
|
||
if (!action.toAssigneeId) continue;
|
||
assignedByAdmin.set(action.toAssigneeId, (assignedByAdmin.get(action.toAssigneeId) || 0) + 1);
|
||
}
|
||
return adminsForStep(db, adminLevel, profile).sort((left, right) =>
|
||
(pendingByAdmin.get(left.id) || 0) - (pendingByAdmin.get(right.id) || 0)
|
||
|| (assignedByAdmin.get(left.id) || 0) - (assignedByAdmin.get(right.id) || 0)
|
||
|| String(left.createdAt || '').localeCompare(String(right.createdAt || ''))
|
||
|| left.id.localeCompare(right.id)
|
||
)[0] || null;
|
||
}
|
||
|
||
function activeWorkflow(db, businessType) {
|
||
return db.workflows.find(item => item.businessType === businessType && item.active);
|
||
}
|
||
|
||
function createWorkflowSubmission(db, businessType, businessId, profile, actorId = null) {
|
||
const workflow = activeWorkflow(db, businessType);
|
||
if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 });
|
||
const firstStep = workflow.steps[0];
|
||
const assignee = selectAdminForStep(db, firstStep.adminLevel, profile);
|
||
if (!assignee) throw Object.assign(new Error(`没有可承接“${firstStep.name}”的${adminLevelNames[firstStep.adminLevel]}`), { status: 409 });
|
||
const instance = {
|
||
id: uid('flow'), workflowId: workflow.id, businessType, businessId, status: 'pending', currentStep: 1,
|
||
assigneeId: assignee.id, createdAt: nowIso(), completedAt: null
|
||
};
|
||
const action = {
|
||
id: uid('flow_action'), instanceId: instance.id, actorId, action: 'submit', note: '提交审批',
|
||
fromAssigneeId: null, toAssigneeId: assignee.id, createdAt: nowIso()
|
||
};
|
||
return { workflow, instance, action };
|
||
}
|
||
|
||
function workflowView(db, instance) {
|
||
if (!instance) return null;
|
||
const workflow = db.workflows.find(item => item.id === instance.workflowId);
|
||
const assignee = db.users.find(item => item.id === instance.assigneeId);
|
||
const actions = db.workflowActions.filter(item => item.instanceId === instance.id).map(item => ({
|
||
...item,
|
||
actorName: db.users.find(user => user.id === item.actorId)?.displayName || '系统',
|
||
fromAssigneeName: db.users.find(user => user.id === item.fromAssigneeId)?.displayName || '',
|
||
toAssigneeName: db.users.find(user => user.id === item.toAssigneeId)?.displayName || ''
|
||
}));
|
||
return {
|
||
...instance,
|
||
workflowName: workflow?.name || '未命名流程',
|
||
steps: workflow?.steps || [],
|
||
currentStepDetail: workflow?.steps.find(step => step.position === instance.currentStep) || null,
|
||
assignee: assignee ? safeUser(assignee) : null,
|
||
actions
|
||
};
|
||
}
|
||
|
||
function pendingWorkflow(db, businessType, businessId) {
|
||
return db.workflowInstances.find(item => item.businessType === businessType && item.businessId === businessId && item.status === 'pending');
|
||
}
|
||
|
||
function candidateSequence(db, rule, schoolId, year) {
|
||
const prefixParts = rule.segments.filter(item => item.type !== 'sequence').map(segment => segment.type === 'year' ? year : segment.type === 'school_code' ? db.schools.find(school => school.id === schoolId)?.code || '' : '').filter(Boolean);
|
||
const prefix = prefixParts.join(rule.separator);
|
||
return db.users.filter(item => item.role === 'candidate' && item.candidateNumber && (!prefix || item.candidateNumber.startsWith(prefix))).length + 1;
|
||
}
|
||
|
||
function generateCandidateNumber(db, profile, year = String(new Date().getFullYear())) {
|
||
const rule = db.numberRules.find(item => item.active);
|
||
if (!rule?.segments.length) throw Object.assign(new Error('尚未配置可用的报名号生成规则'), { status: 409 });
|
||
const school = db.schools.find(item => item.id === profile.schoolId);
|
||
const sequence = candidateSequence(db, rule, profile.schoolId, year);
|
||
const parts = rule.segments.map(segment => {
|
||
if (segment.type === 'year') return year.slice(-Math.max(2, segment.width || 4));
|
||
if (segment.type === 'school_code') return school?.code || 'NOSCHOOL';
|
||
if (segment.type === 'gender') return profile.gender === '男' ? 'M' : profile.gender === '女' ? 'F' : 'X';
|
||
if (segment.type === 'sequence') return String(sequence).padStart(Math.max(1, segment.width || 4), '0');
|
||
return cleanText(segment.value, 20).toUpperCase();
|
||
});
|
||
return { number: parts.join(rule.separator), ruleId: rule.id };
|
||
}
|
||
|
||
function cleanText(value, max = 200) {
|
||
return String(value ?? '').trim().slice(0, max);
|
||
}
|
||
|
||
function centerScopeProfile(db, schoolId) {
|
||
const school = db.schools.find(item => item.id === schoolId);
|
||
return { schoolId, classId: null, school: school?.name || '', grade: '' };
|
||
}
|
||
|
||
function workflowScopeProfile(db, instance) {
|
||
if (instance.businessType === 'profile_change') return db.candidateProfiles.find(item => item.id === instance.businessId) || null;
|
||
if (instance.businessType === 'registration_review') {
|
||
const registration = db.registrations.find(item => item.id === instance.businessId);
|
||
return db.candidateProfiles.find(item => item.userId === registration?.userId) || null;
|
||
}
|
||
if (instance.businessType === 'center_change') {
|
||
const change = db.centerChangeRequests.find(item => item.id === instance.businessId);
|
||
return change ? centerScopeProfile(db, change.schoolId) : null;
|
||
}
|
||
if (instance.businessType === 'candidate_account_batch') {
|
||
const batch = db.candidateAccountBatches.find(item => item.id === instance.businessId);
|
||
return batch ? centerScopeProfile(db, batch.schoolId) : null;
|
||
}
|
||
if (instance.businessType === 'score_appeal') {
|
||
const result = db.results.find(item => item.id === instance.businessId);
|
||
const registration = db.registrations.find(item => item.id === result?.registrationId);
|
||
return db.candidateProfiles.find(item => item.userId === registration?.userId) || null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function candidateAccountBatchView(db, batch) {
|
||
const items = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position);
|
||
const quotaMap = new Map();
|
||
for (const item of items) quotaMap.set(item.classId, (quotaMap.get(item.classId) || 0) + 1);
|
||
const instance = db.workflowInstances.find(item => item.businessType === 'candidate_account_batch' && item.businessId === batch.id);
|
||
return {
|
||
...batch,
|
||
schoolName: db.schools.find(item => item.id === batch.schoolId)?.name || '',
|
||
requesterName: db.users.find(item => item.id === batch.requestedBy)?.displayName || '原提交人',
|
||
totalCount: items.length,
|
||
quotas: [...quotaMap.entries()].map(([classId, count]) => {
|
||
const schoolClass = db.classes.find(item => item.id === classId);
|
||
return { classId, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '', count };
|
||
}),
|
||
items: items.map(item => {
|
||
const schoolClass = db.classes.find(entry => entry.id === item.classId);
|
||
return { ...item, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '' };
|
||
}),
|
||
workflow: workflowView(db, instance)
|
||
};
|
||
}
|
||
|
||
function centerChangeView(db, change) {
|
||
const instance = db.workflowInstances.find(item => item.businessType === 'center_change' && item.businessId === change.id);
|
||
return {
|
||
...change,
|
||
schoolName: db.schools.find(item => item.id === change.schoolId)?.name || '',
|
||
rooms: db.centerChangeRooms.filter(item => item.requestId === change.id),
|
||
workflow: workflowView(db, instance)
|
||
};
|
||
}
|
||
|
||
function parseCenterChange(db, body, schoolId, center = null) {
|
||
const code = cleanText(body.code, 30).toUpperCase();
|
||
const name = cleanText(body.name, 100);
|
||
const address = cleanText(body.address, 200);
|
||
const region = resolveRegion(body);
|
||
const rooms = Array.isArray(body.rooms) ? body.rooms : [];
|
||
if (!code || !name || !address || !region) throw Object.assign(new Error('请填写考点代码、名称、省市区县和详细地址'), { status: 400 });
|
||
if (!rooms.length) throw Object.assign(new Error('请至少配置一个结构化考场'), { status: 400 });
|
||
const duplicateCenter = db.testCenters.some(item => item.code.toUpperCase() === code && item.id !== center?.id)
|
||
|| db.centerChangeRequests.some(item => item.status === 'pending' && item.code.toUpperCase() === code && item.centerId !== center?.id);
|
||
if (duplicateCenter) throw Object.assign(new Error('考点代码已被正式档案或待审批申请占用'), { status: 409 });
|
||
const roomCodes = new Set();
|
||
const normalizedRooms = rooms.map((room, index) => {
|
||
const roomCode = cleanText(room.code, 30).toUpperCase();
|
||
const roomName = cleanText(room.name, 80);
|
||
const building = cleanText(room.building, 80);
|
||
const capacity = Number(room.capacity);
|
||
if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1) {
|
||
throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋或容量无效`), { status: 400 });
|
||
}
|
||
if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 });
|
||
roomCodes.add(roomCode);
|
||
return {
|
||
id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName,
|
||
building, floor: cleanText(room.floor, 30), capacity, seatPlan: cleanText(room.seatPlan, 500), seatStart: 1, seatEnd: capacity,
|
||
roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard',
|
||
status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300)
|
||
};
|
||
});
|
||
return {
|
||
center: {
|
||
schoolId, code, name, ...region, address, contact: cleanText(body.contact, 80), managerName: cleanText(body.managerName, 50),
|
||
managerPhone: cleanText(body.managerPhone, 30), emergencyPhone: cleanText(body.emergencyPhone, 30),
|
||
gateOpenTime: cleanText(body.gateOpenTime, 20), transport: cleanText(body.transport, 500),
|
||
centerStatus: body.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(body.notes, 1000)
|
||
},
|
||
rooms: normalizedRooms
|
||
};
|
||
}
|
||
|
||
function maskId(value) {
|
||
const text = String(value || '');
|
||
return text.length > 8 ? `${text.slice(0, 4)}********${text.slice(-4)}` : text;
|
||
}
|
||
|
||
function publicExam(exam) {
|
||
const now = Date.now();
|
||
const start = new Date(exam.registrationStart).getTime();
|
||
const end = new Date(exam.registrationEnd).getTime();
|
||
return {
|
||
...exam,
|
||
totalScore: exam.subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0),
|
||
registrationState: exam.archivedAt ? 'archived' : now < start ? 'upcoming' : now > end ? 'closed' : 'open'
|
||
};
|
||
}
|
||
|
||
function subjectPassThreshold(subject) {
|
||
const rule = subject?.passRule || 'fixed_score';
|
||
if (rule !== 'fixed_score') return null;
|
||
const value = Number(subject?.passValue ?? subject?.passScore ?? 0);
|
||
return Number(value.toFixed(2));
|
||
}
|
||
|
||
function subjectPassText(subject) {
|
||
const rule = subject?.passRule || 'fixed_score';
|
||
if (rule === 'none') return '不设单科线';
|
||
if (rule === 'rank_percent') return `本科排名前 ${Number(subject.passValue ?? 60)}% 达线`;
|
||
return `固定 ${subjectPassThreshold(subject)} 分`;
|
||
}
|
||
|
||
function gradeForRank(rank, cohortSize) {
|
||
const cutoff = ratio => Math.max(1, Math.ceil(cohortSize * ratio));
|
||
if (rank <= cutoff(.1)) return 'A+';
|
||
if (rank <= cutoff(.25)) return 'A';
|
||
if (rank <= cutoff(.5)) return 'B+';
|
||
if (rank <= cutoff(.7)) return 'B';
|
||
if (rank <= cutoff(.9)) return 'C';
|
||
return 'D';
|
||
}
|
||
|
||
function resultRankInfo(db, result, scoreOverride = result?.score) {
|
||
if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' };
|
||
const score = Number(scoreOverride);
|
||
const peers = db.results.filter(item => item.id !== result.id && item.subjectId === result.subjectId && item.published);
|
||
const cohortSize = peers.length + 1;
|
||
const rank = 1 + peers.filter(item => Number(item.score) > score).length;
|
||
const rankPercent = Number((rank / cohortSize * 100).toFixed(2));
|
||
return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) };
|
||
}
|
||
|
||
function subjectPassEvaluation(db, result, subject, scoreOverride = result?.score) {
|
||
const rule = subject?.passRule || 'fixed_score';
|
||
if (rule === 'none') return { qualified: null, passScore: null, cutoffRank: null, ...resultRankInfo(db, result, scoreOverride) };
|
||
if (rule === 'rank_percent') {
|
||
const rankInfo = resultRankInfo(db, result, scoreOverride);
|
||
const cutoffRank = Math.max(1, Math.ceil(rankInfo.cohortSize * Number(subject.passValue ?? 60) / 100));
|
||
return { ...rankInfo, qualified: rankInfo.rank <= cutoffRank, passScore: null, cutoffRank };
|
||
}
|
||
const passScore = subjectPassThreshold(subject);
|
||
return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null };
|
||
}
|
||
|
||
function examResultSummary(db, registration) {
|
||
const exam = db.exams.find(item => item.id === registration.examId);
|
||
if (!exam) return null;
|
||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||
const published = db.results.filter(result => result.registrationId === registration.id && result.published);
|
||
const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
|
||
const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
|
||
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
|
||
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
|
||
const scoreRatio = fullScore ? total / fullScore * 100 : 0;
|
||
const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent');
|
||
const value = Number(exam.passValue ?? 60);
|
||
let qualified = null;
|
||
let rank = null;
|
||
let cohortSize = null;
|
||
|
||
if (complete && policy === 'fixed_score') qualified = total >= value;
|
||
if (complete && policy === 'subject_scores') qualified = subjects.every(subject => {
|
||
const subjectResult = resultsBySubject.get(subject.id);
|
||
return subjectPassEvaluation(db, subjectResult, subject).qualified !== false;
|
||
});
|
||
if (complete && policy === 'none') qualified = null;
|
||
if (complete && policy === 'rank_percent') {
|
||
const subjectKey = [...registration.subjectIds].sort().join('|');
|
||
const totals = db.registrations
|
||
.filter(item => item.examId === exam.id && item.status === 'approved' && [...item.subjectIds].sort().join('|') === subjectKey)
|
||
.map(item => {
|
||
const itemResults = db.results.filter(result => result.registrationId === item.id && result.published);
|
||
if (!item.subjectIds.every(id => itemResults.some(result => result.subjectId === id))) return null;
|
||
return itemResults.filter(result => item.subjectIds.includes(result.subjectId)).reduce((sum, result) => sum + Number(result.score), 0);
|
||
})
|
||
.filter(item => item != null);
|
||
cohortSize = totals.length;
|
||
rank = 1 + totals.filter(item => item > total).length;
|
||
qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100));
|
||
}
|
||
|
||
return {
|
||
examId: exam.id,
|
||
examName: exam.name,
|
||
examCode: exam.code,
|
||
examStart: exam.examStart,
|
||
archivedAt: exam.archivedAt || null,
|
||
complete,
|
||
publishedSubjects: published.length,
|
||
subjectCount: subjects.length,
|
||
featureScore: Number(registration.featureScore || 0),
|
||
total,
|
||
fullScore,
|
||
scoreRatio: Number(scoreRatio.toFixed(2)),
|
||
passPolicy: policy,
|
||
passValue: value,
|
||
qualified,
|
||
rank,
|
||
cohortSize
|
||
};
|
||
}
|
||
|
||
function examRegistrationView(db, registration) {
|
||
const exam = db.exams.find(item => item.id === registration.examId);
|
||
const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id));
|
||
const instance = db.workflowInstances.find(item => item.businessType === 'registration_review' && item.businessId === registration.id && item.status === 'pending')
|
||
|| db.workflowInstances.filter(item => item.businessType === 'registration_review' && item.businessId === registration.id)[0];
|
||
return {
|
||
...registration,
|
||
exam,
|
||
subjects,
|
||
amountDue: Number(subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)),
|
||
paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || '',
|
||
workflow: workflowView(db, instance)
|
||
};
|
||
}
|
||
|
||
function logAction(db, user, action, detail) {
|
||
const log = { id: uid('log'), actorId: user.id, actorName: user.displayName, action, detail, createdAt: nowIso() };
|
||
db.auditLogs.unshift(log);
|
||
db.auditLogs = db.auditLogs.slice(0, 200);
|
||
return log;
|
||
}
|
||
|
||
const excelResourceNames = {
|
||
classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额',
|
||
account_results: '报名号下发结果', candidates: '考生资料', payments: '考试缴费名单', centers: '考点考场档案', results: '成绩台账',
|
||
admit_cards: '准考证信息台账', admitted_candidates: '录取考生信息'
|
||
};
|
||
|
||
function admissionRowsForRegistrations(db, registrations) {
|
||
return registrations.flatMap(registration => {
|
||
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) || { subjects: [] };
|
||
const school = db.schools.find(item => item.id === profile.schoolId);
|
||
const schoolClass = db.classes.find(item => item.id === profile.classId);
|
||
const card = registration.admitCard;
|
||
if (!card) return [];
|
||
const center = db.testCenters.find(item => item.id === card.centerId) || {};
|
||
return (card.assignments || []).map(assignment => {
|
||
const subject = exam.subjects.find(item => item.id === assignment.subjectId) || {};
|
||
const room = db.testRooms.find(item => item.id === assignment.roomId) || {};
|
||
return {
|
||
schoolName: school?.name || profile.school || '', className: schoolClass?.name || profile.grade || '',
|
||
candidateNumber: account.candidateNumber || registration.registrationNumber || '', candidateName: profile.name || account.displayName || '', idNumber: profile.idNumber || '',
|
||
examCode: exam.code || '', examName: exam.name || '', cardNumber: card.number || '',
|
||
centerCode: card.centerCode || center.code || '', centerName: card.testCenter || center.name || '',
|
||
centerAddress: card.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '),
|
||
subjectName: subject.name || '', subjectDate: subject.date || '', subjectTime: [subject.start, subject.end].filter(Boolean).join('—'),
|
||
examRoomCode: assignment.examRoomCode || '', roomName: assignment.roomName || assignment.room || room.name || '',
|
||
roomCode: assignment.roomCode || room.code || '', building: assignment.building || room.building || '',
|
||
floor: assignment.floor || room.floor || '', seat: assignment.seat || ''
|
||
};
|
||
});
|
||
});
|
||
}
|
||
|
||
function centerMaterialRows(db, schoolId, examId) {
|
||
const centerIds = new Set(db.testCenters.filter(item => item.schoolId === schoolId).map(item => item.id));
|
||
const registrations = db.registrations.filter(item => item.admitCard && item.examId === examId && centerIds.has(item.admitCard.centerId));
|
||
return admissionRowsForRegistrations(db, registrations);
|
||
}
|
||
|
||
function excelRowsForResource(db, user, resource, searchParams) {
|
||
const schools = user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId);
|
||
if (resource === 'classes') return db.classes.filter(item => schools.some(school => school.id === item.schoolId)).map(item => ({
|
||
schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', grade: item.grade, name: item.name, status: item.active ? '启用' : '停用'
|
||
}));
|
||
if (resource === 'class_admins') return db.users.filter(item => item.role === 'admin' && item.adminLevel === 'class' && schools.some(school => school.id === item.schoolId)).map(item => ({
|
||
schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '',
|
||
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', displayName: item.displayName,
|
||
username: item.username, initialPassword: '', status: item.active ? '启用' : '停用'
|
||
}));
|
||
if (resource === 'account_quotas') return db.classes.filter(item => item.active && schools.some(school => school.id === item.schoolId)).map(item => ({ className: item.name, count: 0 }));
|
||
if (resource === 'account_results') {
|
||
const batchId = cleanText(searchParams.get('batchId'), 64);
|
||
const batch = db.candidateAccountBatches.find(item => item.id === batchId && schools.some(school => school.id === item.schoolId));
|
||
if (!batch) throw Object.assign(new Error('批次不存在或不在当前学校范围内'), { status: 404 });
|
||
return db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position).map(item => ({
|
||
batchId: batch.id, className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', candidateNumber: item.candidateNumber, initialPassword: item.initialPassword
|
||
}));
|
||
}
|
||
if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({
|
||
candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender,
|
||
idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email,
|
||
nativePlace: profile.nativePlace, provinceCode: profile.provinceCode, provinceName: profile.provinceName,
|
||
cityCode: profile.cityCode, cityName: profile.cityName, districtCode: profile.districtCode, districtName: profile.districtName,
|
||
address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade,
|
||
ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone
|
||
}));
|
||
if (resource === 'payments') return db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item)).map(registration => {
|
||
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) || { subjects: [] };
|
||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||
return {
|
||
examCode: exam.code || '', examName: exam.name || '',
|
||
schoolName: db.schools.find(item => item.id === profile.schoolId)?.name || profile.school || '',
|
||
className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade || '',
|
||
candidateNumber: account.candidateNumber || registration.registrationNumber || '', candidateName: profile.name || account.displayName || '',
|
||
subjectNames: subjects.map(subject => subject.name).join('、'),
|
||
amountDue: Number(subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)),
|
||
paymentStatus: registration.paymentStatus === 'paid' ? '已缴费' : '待缴费', paidAt: registration.paidAt || '',
|
||
paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || ''
|
||
};
|
||
});
|
||
if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => {
|
||
const rooms = db.testRooms.filter(room => room.centerId === center.id);
|
||
return (rooms.length ? rooms : [{}]).map(room => ({
|
||
schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name,
|
||
provinceCode: center.provinceCode, provinceName: center.provinceName, cityCode: center.cityCode, cityName: center.cityName,
|
||
districtCode: center.districtCode, districtName: center.districtName, address: center.address,
|
||
managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact,
|
||
emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport,
|
||
centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes,
|
||
roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '',
|
||
seatPlan: room.seatPlan || '', roomType: ({ standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' })[room.roomType] || '',
|
||
roomStatus: room.status === 'inactive' ? '停用' : '启用', roomNotes: room.notes || ''
|
||
}));
|
||
});
|
||
if (resource === 'results') {
|
||
const examId = cleanText(searchParams.get('examId'), 64);
|
||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId && (!examId || item.examId === examId))).map(result => {
|
||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||
const exam = db.exams.find(item => item.id === registration?.examId);
|
||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||
const account = db.users.find(item => item.id === registration?.userId);
|
||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||
const evaluation = subjectPassEvaluation(db, result, subject);
|
||
const rank = resultRankInfo(db, result);
|
||
return {
|
||
candidateNumber: account?.candidateNumber || '', candidateName: profile?.name || account?.displayName || '',
|
||
examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '',
|
||
fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation.passScore ?? '', score: result.score,
|
||
rank: rank.rank, rankPercent: rank.rankPercent,
|
||
qualified: evaluation.qualified == null ? '不判定' : evaluation.qualified ? '达线' : '未达线',
|
||
grade: result.published ? resultRankInfo(db, result).grade : '待发布', published: result.published ? '发布' : '不发布', updatedAt: result.updatedAt || result.publishedAt || ''
|
||
};
|
||
});
|
||
}
|
||
if (resource === 'admit_cards') {
|
||
const examId = cleanText(searchParams.get('examId'), 64);
|
||
const scoped = db.registrations.filter(item => item.admitCard && registrationInScope(db, user, item) && (!examId || item.examId === examId));
|
||
return admissionRowsForRegistrations(db, scoped);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function excelImportError(row, message) {
|
||
return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 });
|
||
}
|
||
|
||
function prepareResultImport(db, rows) {
|
||
const normalized = [];
|
||
const errors = [];
|
||
const seen = new Set();
|
||
for (const source of Array.isArray(rows) ? rows : []) {
|
||
const sourceRow = Number(source.__row || source.sourceRow || normalized.length + 3);
|
||
const candidateNumber = cleanText(source.candidateNumber, 120);
|
||
const examCode = cleanText(source.examCode, 60);
|
||
const subjectName = cleanText(source.subjectName, 50);
|
||
const account = db.users.find(item => item.candidateNumber === candidateNumber);
|
||
const exam = db.exams.find(item => item.code.toUpperCase() === examCode.toUpperCase());
|
||
const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved');
|
||
const subject = exam?.subjects.find(item => item.name.toLowerCase() === subjectName.toLowerCase());
|
||
const score = source.score == null || String(source.score).trim() === '' ? Number.NaN : Number(source.score);
|
||
const rowErrors = [];
|
||
if (!candidateNumber) rowErrors.push('报名号不能为空');
|
||
else if (!account) rowErrors.push('报名号不存在');
|
||
if (!examCode) rowErrors.push('考试代码不能为空');
|
||
else if (!exam) rowErrors.push('考试代码不存在');
|
||
else if (exam.archivedAt) rowErrors.push('该考试已归档,成绩已永久锁定');
|
||
if (!subjectName) rowErrors.push('科目不能为空');
|
||
else if (exam && !subject) rowErrors.push('该考试中不存在此科目');
|
||
if (exam && account && !registration) rowErrors.push('该考生没有已通过的本场考试报名');
|
||
if (registration && subject && !registration.subjectIds.includes(subject.id)) rowErrors.push('该考生未报考此科目');
|
||
if (!Number.isFinite(score) || score < 0 || (subject && score > Number(subject.fullScore))) rowErrors.push(`成绩须在 0—${subject?.fullScore ?? 0} 之间`);
|
||
const publishText = typeof source.published === 'boolean' ? (source.published ? '发布' : '不发布') : cleanText(source.published, 20) || '不发布';
|
||
if (!['发布', '不发布'].includes(publishText)) rowErrors.push('发布状态只能是“发布”或“不发布”');
|
||
const key = registration && subject ? `${registration.id}|${subject.id}` : `${candidateNumber}|${examCode}|${subjectName}`;
|
||
if (seen.has(key)) rowErrors.push('同一考生、考试和科目在文件中重复');
|
||
seen.add(key);
|
||
const existing = registration && subject ? db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id) : null;
|
||
const evaluation = subject ? subjectPassEvaluation(db, existing || { id: `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, subject, score) : null;
|
||
const profile = db.candidateProfiles.find(item => item.userId === account?.id);
|
||
const schoolClass = db.classes.find(item => item.id === profile?.classId);
|
||
const rank = subject && Number.isFinite(score) ? resultRankInfo(db, { id: existing?.id || `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, score) : null;
|
||
const row = {
|
||
sourceRow, candidateNumber, candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '',
|
||
examId: exam?.id || '', examCode, examName: exam?.name || '', registrationId: registration?.id || '',
|
||
subjectId: subject?.id || '', subjectName, fullScore: subject?.fullScore ?? null,
|
||
passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore ?? null,
|
||
passScore: evaluation?.passScore ?? null, passText: subject ? subjectPassText(subject) : '', score,
|
||
rank: rank?.rank ?? null, cohortSize: rank?.cohortSize ?? null, rankPercent: rank?.rankPercent ?? null,
|
||
qualified: !Number.isFinite(score) ? null : evaluation?.qualified ?? null,
|
||
grade: rank?.grade || '',
|
||
published: publishText === '发布', existingResultId: existing?.id || '', mode: existing ? 'update' : 'create', errors: rowErrors
|
||
};
|
||
normalized.push(row);
|
||
errors.push(...rowErrors.map(message => ({ row: sourceRow, message })));
|
||
}
|
||
const previewResults = db.results.map(item => ({ ...item }));
|
||
for (const row of normalized.filter(item => !item.errors.length)) {
|
||
const existingIndex = row.existingResultId ? previewResults.findIndex(item => item.id === row.existingResultId) : -1;
|
||
const previewResult = {
|
||
...(existingIndex >= 0 ? previewResults[existingIndex] : {}),
|
||
id: row.existingResultId || `preview-${row.sourceRow}`,
|
||
registrationId: row.registrationId,
|
||
subjectId: row.subjectId,
|
||
score: row.score,
|
||
published: row.published
|
||
};
|
||
if (existingIndex >= 0) previewResults[existingIndex] = previewResult;
|
||
else previewResults.push(previewResult);
|
||
}
|
||
const previewDb = { ...db, results: previewResults };
|
||
for (const row of normalized.filter(item => !item.errors.length)) {
|
||
const previewResult = previewResults.find(item => item.id === (row.existingResultId || `preview-${row.sourceRow}`));
|
||
const subject = db.exams.find(item => item.id === row.examId)?.subjects.find(item => item.id === row.subjectId);
|
||
const rank = resultRankInfo(previewDb, previewResult, row.score);
|
||
const evaluation = subjectPassEvaluation(previewDb, previewResult, subject, row.score);
|
||
Object.assign(row, { rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent, grade: row.published ? rank.grade : '待发布', passScore: evaluation.passScore, cutoffRank: evaluation.cutoffRank, qualified: evaluation.qualified });
|
||
}
|
||
return {
|
||
rows: normalized,
|
||
errors,
|
||
summary: {
|
||
total: normalized.length,
|
||
valid: normalized.filter(item => !item.errors.length).length,
|
||
invalid: normalized.filter(item => item.errors.length).length,
|
||
create: normalized.filter(item => !item.errors.length && item.mode === 'create').length,
|
||
update: normalized.filter(item => !item.errors.length && item.mode === 'update').length,
|
||
publish: normalized.filter(item => !item.errors.length && item.published).length
|
||
}
|
||
};
|
||
}
|
||
|
||
async function commitResultImport(db, user, rows) {
|
||
if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以批量提交成绩'), { status: 403 });
|
||
const prepared = prepareResultImport(db, rows);
|
||
if (prepared.errors.length) {
|
||
const first = prepared.errors[0];
|
||
throw Object.assign(new Error(`第 ${first.row} 行:${first.message};请返回预览修正后重试`), { status: 400 });
|
||
}
|
||
const entries = prepared.rows.map(row => {
|
||
const existing = row.existingResultId ? db.results.find(item => item.id === row.existingResultId) : null;
|
||
const result = existing || { id: uid('result'), registrationId: row.registrationId, subjectId: row.subjectId };
|
||
Object.assign(result, {
|
||
score: row.score, grade: row.grade, published: row.published, updatedAt: nowIso(),
|
||
publishedAt: row.published ? (existing?.publishedAt || nowIso()) : null
|
||
});
|
||
const log = logAction(db, user, row.published ? 'Excel 批量发布成绩' : 'Excel 批量保存成绩', `${row.candidateNumber} · ${row.examName} · ${row.subjectName} · ${row.score}`);
|
||
return { result, isNew: !existing, log };
|
||
});
|
||
await database.saveResults(entries);
|
||
for (const entry of entries) if (entry.isNew) db.results.push(entry.result);
|
||
return { count: entries.length, summary: prepared.summary };
|
||
}
|
||
|
||
async function importExcelResource(db, user, resource, rows) {
|
||
if (resource === 'classes') {
|
||
if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 });
|
||
for (const row of rows) {
|
||
const school = db.schools.find(item => item.code.toUpperCase() === String(row.schoolCode).toUpperCase());
|
||
if (!school || (user.adminLevel === 'school' && school.id !== user.schoolId)) throw excelImportError(row, '学校代码无效或不在管理范围内');
|
||
const name = cleanText(row.name, 100); const grade = cleanText(row.grade, 60);
|
||
if (!name || !grade) throw excelImportError(row, '年级和班级名称不能为空');
|
||
const existing = db.classes.find(item => item.schoolId === school.id && item.name === name);
|
||
const schoolClass = existing || { id: uid('class'), schoolId: school.id };
|
||
Object.assign(schoolClass, { name, grade, active: row.status !== '停用' });
|
||
await database.saveSchoolClass(schoolClass, !existing, logAction(db, user, existing ? 'Excel 更新班级' : 'Excel 新增班级', `${school.name} · ${name}`));
|
||
if (!existing) db.classes.push(schoolClass);
|
||
}
|
||
return { count: rows.length };
|
||
}
|
||
if (resource === 'class_admins') {
|
||
if (user.adminLevel !== 'school') throw Object.assign(new Error('班级管理员 Excel 导入由校级管理员执行'), { status: 403 });
|
||
for (const row of rows) {
|
||
const school = db.schools.find(item => item.id === user.schoolId && item.code.toUpperCase() === String(row.schoolCode).toUpperCase());
|
||
const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100));
|
||
if (!school || !schoolClass) throw excelImportError(row, '学校代码或班级名称无效');
|
||
const username = cleanText(row.username, 50); const displayName = cleanText(row.displayName, 50); const password = String(row.initialPassword || '');
|
||
if (!username || !displayName) throw excelImportError(row, '管理员姓名和登录账号不能为空');
|
||
const existing = db.users.find(item => item.username.toLowerCase() === username.toLowerCase());
|
||
if (existing && (existing.adminLevel !== 'class' || existing.schoolId !== user.schoolId)) throw excelImportError(row, '登录账号已被其他用户占用');
|
||
if (!existing && password.length < 8) throw excelImportError(row, '新建管理员的初始密码至少 8 位');
|
||
if (existing) {
|
||
Object.assign(existing, { displayName, classId: schoolClass.id, active: row.status !== '停用' });
|
||
if (password) existing.passwordHash = hashPassword(password);
|
||
await database.updateAdmin(existing, Boolean(password), logAction(db, user, 'Excel 更新班级管理员', `${displayName} · ${schoolClass.name}`));
|
||
} else {
|
||
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel: 'class', schoolId: user.schoolId, classId: schoolClass.id, displayName, active: row.status !== '停用', createdAt: nowIso() };
|
||
await database.createAdmin(created, logAction(db, user, 'Excel 创建班级管理员', `${displayName} · ${schoolClass.name}`));
|
||
db.users.push(created);
|
||
}
|
||
}
|
||
return { count: rows.length };
|
||
}
|
||
if (resource === 'account_quotas') {
|
||
if (user.adminLevel !== 'school') throw Object.assign(new Error('班级配额模板仅供校级管理员使用'), { status: 403 });
|
||
const quotas = rows.filter(row => Number(row.count) > 0).map(row => {
|
||
const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100) && item.active);
|
||
if (!schoolClass || !Number.isInteger(Number(row.count)) || Number(row.count) < 1 || Number(row.count) > 200) throw excelImportError(row, '班级不存在,或申领数量不在 1—200 之间');
|
||
return { classId: schoolClass.id, className: schoolClass.name, count: Number(row.count) };
|
||
});
|
||
if (!quotas.length) throw Object.assign(new Error('模板中没有大于 0 的申领数量'), { status: 400 });
|
||
return { count: quotas.length, quotas };
|
||
}
|
||
if (resource === 'candidates') {
|
||
if (!hasPermission(user, 'candidates.write')) throw Object.assign(new Error('当前账号不能导入考生资料'), { status: 403 });
|
||
for (const row of rows) {
|
||
const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120));
|
||
const profile = db.candidateProfiles.find(item => item.userId === account?.id);
|
||
if (!account || !profile || !profileInScope(user, profile)) throw excelImportError(row, '报名号不存在或不在数据范围内');
|
||
if (pendingWorkflow(db, 'profile_change', profile.id)) throw excelImportError(row, '该考生已有待审批资料流程');
|
||
const schoolClass = db.classes.find(item => item.schoolId === profile.schoolId && item.name === cleanText(row.className, 100));
|
||
if (!schoolClass) throw excelImportError(row, '班级名称无效');
|
||
const required = ['name', 'gender', 'idNumber', 'phone'];
|
||
if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填');
|
||
const region = resolveRegion(row);
|
||
if (!region) throw excelImportError(row, '省、市或区县代码无效,或上下级不匹配');
|
||
Object.assign(profile, {
|
||
name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30),
|
||
email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), ...region,
|
||
address: cleanText(row.address, 200), classId: schoolClass.id,
|
||
grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20),
|
||
guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso()
|
||
});
|
||
const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id);
|
||
await database.updateCandidateProfile(profile, profile.name, instance, action);
|
||
}
|
||
return { count: rows.length };
|
||
}
|
||
if (resource === 'centers') {
|
||
if (!hasPermission(user, 'centers.write')) throw Object.assign(new Error('当前账号不能导入考点考场'), { status: 403 });
|
||
const groups = Map.groupBy(rows, row => cleanText(row.centerCode, 30).toUpperCase());
|
||
for (const [centerCode, centerRows] of groups) {
|
||
const first = centerRows[0];
|
||
const school = db.schools.find(item => item.code.toUpperCase() === String(first.schoolCode).toUpperCase() && (user.adminLevel === 'super' || item.id === user.schoolId));
|
||
if (!school || !centerCode) throw excelImportError(first, '学校代码或考点代码无效');
|
||
const existing = db.testCenters.find(item => item.code.toUpperCase() === centerCode);
|
||
if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校');
|
||
if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更');
|
||
const body = {
|
||
schoolId: school.id, code: centerCode, name: first.centerName,
|
||
provinceCode: first.provinceCode, cityCode: first.cityCode, districtCode: first.districtCode,
|
||
address: first.address, managerName: first.managerName,
|
||
managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime,
|
||
transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes,
|
||
rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan,
|
||
roomType: ({ 标准考场: 'standard', 机考考场: 'computer', 无障碍考场: 'accessible', 备用考场: 'spare' })[row.roomType] || row.roomType,
|
||
status: row.roomStatus === '停用' ? 'inactive' : 'active', notes: row.roomNotes }))
|
||
};
|
||
const parsed = parseCenterChange(db, body, school.id, existing || null);
|
||
const change = { id: uid('center_change'), centerId: existing?.id || null, schoolId: school.id, requestType: existing ? 'update' : 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
|
||
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, school.id), user.id);
|
||
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, logAction(db, user, 'Excel 提交考点考场审批', `${change.name} · ${parsed.rooms.length} 个考场`));
|
||
}
|
||
return { count: groups.size };
|
||
}
|
||
if (resource === 'results') {
|
||
return commitResultImport(db, user, rows);
|
||
}
|
||
throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 });
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
|
||
}
|
||
|
||
function admitCardSection(db, user, profile, registration) {
|
||
const exam = db.exams.find(item => item.id === registration.examId);
|
||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||
const assignments = new Map((registration.admitCard.assignments || []).map(item => [item.subjectId, item]));
|
||
const center = db.testCenters.find(item => item.id === registration.admitCard.centerId) || {};
|
||
const centerCode = registration.admitCard.centerCode || center.code || '';
|
||
const centerAddress = registration.admitCard.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' ');
|
||
const rows = subjects.map(subject => {
|
||
const assignment = assignments.get(subject.id) || {};
|
||
const room = db.testRooms.find(item => item.id === assignment.roomId) || {};
|
||
return `<tr><td>${escapeHtml(subject.name)}</td><td>${escapeHtml(subject.date)}</td><td>${escapeHtml(subject.start)}—${escapeHtml(subject.end)}</td><td class="room-no">${escapeHtml(assignment.examRoomCode || '待定')}</td><td><strong>${escapeHtml(assignment.roomName || assignment.room || room.name || '待定')}</strong><small>场地代码:${escapeHtml(assignment.roomCode || room.code || '—')}</small></td><td>${escapeHtml(assignment.building || room.building || '—')}<small>${escapeHtml(assignment.floor || room.floor || '楼层待定')}</small></td><td>${escapeHtml(assignment.seat || '—')}</td></tr>`;
|
||
}).join('');
|
||
return `<main class="card"><header class="head"><div><div class="brand">衡准 · 准考证</div><p>${escapeHtml(exam.name)}</p></div><div><small>准考证号</small><div class="code">${escapeHtml(registration.admitCard.number)}</div></div></header><section class="meta"><div><span>姓名</span><strong>${escapeHtml(profile.name || user.displayName)}</strong></div><div><span>证件号码</span><strong>${escapeHtml(maskId(profile.idNumber))}</strong></div><div><span>报名号</span><strong>${escapeHtml(user.candidateNumber || registration.registrationNumber || '—')}</strong></div></section><section class="center"><div><span>固定考点</span><strong>${escapeHtml(registration.admitCard.testCenter)}</strong><small>考点代码:${escapeHtml(centerCode || '—')}</small></div><div><span>考点详细地址</span><strong>${escapeHtml(centerAddress || '地址待公布')}</strong></div></section><table><thead><tr><th>科目</th><th>日期</th><th>时间</th><th>考试考场序号</th><th>考场通用名称 / 场地代码</th><th>楼栋 / 楼层</th><th>座位号</th></tr></thead><tbody>${rows}</tbody></table><div class="explain"><strong>编号说明</strong>“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。</div><div class="notice">所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。</div><footer class="footer"><span>${escapeHtml(db.organization.name)}</span><span>生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}</span></footer></main>`;
|
||
}
|
||
|
||
function admitCardsDocument(db, items, title) {
|
||
const cards = items.map(item => admitCardSection(db, item.user, item.profile, item.registration)).join('');
|
||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>@page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{font-family:"Microsoft YaHei",sans-serif;color:#182552;margin:0;background:#f2f4f8}.card{max-width:1120px;min-height:720px;margin:24px auto;background:#fff;border:2px solid #182552;padding:26px;box-shadow:0 12px 35px #18255218;break-after:page}.card:last-child{break-after:auto}.head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px double #182552;padding-bottom:14px}.head>div:last-child{text-align:right}.head small{color:#64748b}.brand{font-size:27px;font-weight:800}.head p{margin:6px 0}.code{font:700 22px Consolas,monospace;margin-top:5px}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}.meta div,.center>div{border:1px solid #d9e0e7;padding:9px 12px}.meta span,.center span{display:block;color:#64748b;font-size:11px;margin-bottom:4px}.center{display:grid;grid-template-columns:1fr 1.5fr;gap:10px;margin-bottom:16px}.center small,td small{display:block;color:#64748b;margin-top:4px}table{width:100%;border-collapse:collapse;font-size:12px}th,td{border:1px solid #b9c5d2;padding:9px;text-align:left;vertical-align:middle}th{background:#eaf1f5}.room-no{font:700 15px Consolas,monospace;text-align:center}.explain,.notice{margin-top:12px;padding:10px 12px;font-size:12px}.explain{background:#eaf4f6;color:#245365}.explain strong{margin-right:8px}.notice{background:#fff7e6;color:#704b12}.footer{display:flex;justify-content:space-between;margin-top:14px;color:#64748b;font-size:11px}@media print{body{background:#fff}.card{margin:0;box-shadow:none}}</style></head><body>${cards}</body></html>`;
|
||
}
|
||
|
||
function admitCardHtml(db, user, profile, registration) {
|
||
const exam = db.exams.find(item => item.id === registration.examId);
|
||
return admitCardsDocument(db, [{ user, profile, registration }], `${exam.name}-${profile.name}-准考证`);
|
||
}
|
||
|
||
function admitCardsHtml(db, registrations, title) {
|
||
const items = registrations.map(registration => ({
|
||
registration,
|
||
user: db.users.find(item => item.id === registration.userId) || {},
|
||
profile: db.candidateProfiles.find(item => item.userId === registration.userId) || {}
|
||
}));
|
||
return admitCardsDocument(db, items, title);
|
||
}
|
||
|
||
const routeContext = {
|
||
database,
|
||
cache,
|
||
resultsCacheTtlSeconds: process.env.REDIS_RESULTS_CACHE_TTL_SECONDS || 86400,
|
||
readDb,
|
||
publicSiteConfig,
|
||
sendJson,
|
||
sendError,
|
||
readJson,
|
||
readBodyBuffer,
|
||
sendWorkbook,
|
||
currentUser,
|
||
parseCookies,
|
||
safeUser,
|
||
requireUser,
|
||
hasPermission,
|
||
requirePermission,
|
||
profileInScope,
|
||
registrationInScope,
|
||
adminScopeLabel,
|
||
adminsForStep,
|
||
selectAdminForStep,
|
||
activeWorkflow,
|
||
createWorkflowSubmission,
|
||
workflowView,
|
||
pendingWorkflow,
|
||
candidateSequence,
|
||
generateCandidateNumber,
|
||
cleanText,
|
||
centerScopeProfile,
|
||
workflowScopeProfile,
|
||
candidateAccountBatchView,
|
||
centerChangeView,
|
||
parseCenterChange,
|
||
maskId,
|
||
publicExam,
|
||
examRegistrationView,
|
||
examResultSummary,
|
||
subjectPassText,
|
||
subjectPassEvaluation,
|
||
resultRankInfo,
|
||
logAction,
|
||
excelResourceNames,
|
||
excelRowsForResource,
|
||
admissionRowsForRegistrations,
|
||
centerMaterialRows,
|
||
importExcelResource,
|
||
prepareResultImport,
|
||
commitResultImport,
|
||
admitCardHtml,
|
||
admitCardsHtml,
|
||
hashPassword,
|
||
verifyPassword,
|
||
randomBytes,
|
||
uid,
|
||
nowIso,
|
||
sessions,
|
||
buildWorkbook,
|
||
buildCenterMaterialsWorkbook,
|
||
hasExcelResource,
|
||
parseWorkbook,
|
||
adminLevelNames,
|
||
permissionsByLevel,
|
||
resolveRegion
|
||
};
|
||
const handlePublic = createPublicRoutes(routeContext);
|
||
const handleAuth = createAuthRoutes(routeContext);
|
||
const handleCandidate = createCandidateRoutes(routeContext);
|
||
const handleAdmission = createAdmissionRoutes(routeContext);
|
||
const handleAdmin = createAdminRoutes(routeContext);
|
||
|
||
async function serveStatic(response, pathname) {
|
||
const requestPath = pathname === '/' ? '/index.html' : pathname;
|
||
const filePath = vendorStaticFiles.get(requestPath)
|
||
|| (staticFiles.has(requestPath) ? normalize(join(root, requestPath.replace(/^\/+/, ''))) : null);
|
||
if (!filePath) return false;
|
||
const body = await readFile(filePath);
|
||
response.writeHead(200, { 'Content-Type': mimeTypes[extname(filePath)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
||
response.end(body);
|
||
return true;
|
||
}
|
||
|
||
const server = createServer(async (request, response) => {
|
||
const url = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
|
||
const pathname = decodeURIComponent(url.pathname);
|
||
try {
|
||
if (pathname.startsWith('/api/public/')) {
|
||
const handled = await handlePublic(pathname, response);
|
||
if (handled !== false) return;
|
||
}
|
||
const authHandled = await handleAuth(request, response, pathname);
|
||
if (authHandled !== false) return;
|
||
const candidateHandled = await handleCandidate(request, response, pathname);
|
||
if (candidateHandled !== false) return;
|
||
const admissionHandled = await handleAdmission(request, response, pathname);
|
||
if (admissionHandled !== false) return;
|
||
const adminHandled = await handleAdmin(request, response, pathname);
|
||
if (adminHandled !== false) return;
|
||
if (await serveStatic(response, pathname)) return;
|
||
sendError(response, 404, '页面或接口不存在');
|
||
} catch (error) {
|
||
console.error(error);
|
||
sendError(response, error.status || 500, error.status ? error.message : '服务器处理请求时发生错误');
|
||
}
|
||
});
|
||
|
||
server.listen(port, host, () => {
|
||
console.log(`衡准考试信息管理系统:http://${host}:${port}`);
|
||
console.log(`数据库:${database.client}(${database.location})`);
|
||
console.log(`Redis 缓存:${cache.status === 'ready' ? '已连接' : cache.status === 'disabled' ? '未配置' : '不可用,已回源数据库'}`);
|
||
});
|
||
|
||
async function shutdown(signal) {
|
||
console.log(`收到 ${signal},正在关闭服务...`);
|
||
server.close(async () => {
|
||
await Promise.allSettled([database.close(), cache.close()]);
|
||
process.exit(0);
|
||
});
|
||
}
|
||
|
||
process.once('SIGINT', () => shutdown('SIGINT'));
|
||
process.once('SIGTERM', () => shutdown('SIGTERM'));
|