diff --git a/README.md b/README.md index 92707ba..50eb5cd 100644 --- a/README.md +++ b/README.md @@ -150,14 +150,37 @@ npm test ## 项目结构 +项目采用模块化单体架构:仍由一个 Node.js 进程部署,但 HTTP、权限、业务路由、数据库适配和前端页面按职责分开。 + ```text -index.html 页面入口 -styles.css 公共首页、考生端、管理端响应式样式 -app.js 前端路由、状态和业务交互 -server.mjs HTTP 服务、认证、权限与全部业务 API -database.mjs 分表结构、SQLite / MySQL 适配与事务仓储 -excel.mjs Excel 模板、导入解析与导出工作簿 -tests/system.test.mjs 端到端系统测试 -data/exam.sqlite 本地运行后生成的 SQLite 数据库 -.env.example 开发与生产环境变量模板 +index.html 页面入口 +styles.css 公共首页、考生端、管理端响应式样式 +app.js 前端路由、事件与表单控制器 +server.mjs HTTP 服务启动、模块装配与静态文件服务 +database.mjs 数据仓储与数据库模块装配 +excel.mjs Excel 模板、导入解析与导出工作簿 + +src/data/seed.mjs 演示数据 +src/http/responses.mjs JSON、文件与请求体处理 +src/security/session.mjs Cookie 会话与当前用户 +src/security/authorization.mjs 管理层级、权限和数据范围 +src/routes/public.routes.mjs 公开 API +src/routes/auth.routes.mjs 登录、注册与改密 API +src/routes/candidate.routes.mjs 考生业务 API +src/routes/admin.routes.mjs 管理业务 API + +src/database/schema.mjs SQLite / MySQL 关系模型 +src/database/sqlite-adapter.mjs SQLite 初始化、迁移与事务适配 +src/database/mysql-adapter.mjs MySQL 初始化、迁移与事务适配 + +src/client/state.mjs 前端共享状态 +src/client/api.mjs 浏览器 API 请求封装 +src/client/ui.mjs 格式化、图标与通用 UI 工具 +src/client/public-views.mjs 公共首页与登录注册视图 +src/client/candidate-views.mjs 考生中心视图 +src/client/admin-views.mjs 管理后台视图 + +tests/system.test.mjs 端到端系统测试 +data/exam.sqlite 本地运行后生成的 SQLite 数据库 +.env.example 开发与生产环境变量模板 ``` diff --git a/app.js b/app.js index 29cf98c..e39be0c 100644 --- a/app.js +++ b/app.js @@ -1,75 +1,12 @@ -const state = { - user: null, - profile: null, - publicData: { organization: {}, notices: [], exams: [], stats: {} }, - permissions: [], - scopeLabel: '', - pageData: null, - loading: false -}; +import { api } from './src/client/api.mjs'; +import { createAdminViews } from './src/client/admin-views.mjs'; +import { createCandidateViews } from './src/client/candidate-views.mjs'; +import { createPublicViews } from './src/client/public-views.mjs'; +import { state } from './src/client/state.mjs'; +import { badge, dateRange, formatDate, h, icons, money, statusLabels } from './src/client/ui.mjs'; const app = document.querySelector('#app'); const modalRoot = document.querySelector('#modalRoot'); -const statusLabels = { - pending: '待审核', approved: '已通过', rejected: '需修改', - published: '已发布', draft: '草稿', closed: '已结束', - open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费', - super: '超级管理员', school: '校级管理员', class: '班级管理员' -}; - -const icons = { - home: '', - user: '', - exam: '', - ticket: '', - chart: '', - bell: '', - users: '', - check: '', - plus: '', - logout: '', - menu: '', - search: '', - arrow: '' -}; - -function h(value) { - return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); -} - -function formatDate(value, withTime = false) { - if (!value) return '待定'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return h(value); - return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); -} - -function dateRange(start, end) { - return `${formatDate(start)} — ${formatDate(end)}`; -} - -function badge(status) { - return `${h(statusLabels[status] || status)}`; -} - -function money(value) { - return `¥${Number(value || 0).toFixed(2)}`; -} - -async function api(path, options = {}) { - const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData; - const response = await fetch(path, { - credentials: 'same-origin', - headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, - ...options, - body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body - }); - const type = response.headers.get('content-type') || ''; - const data = type.includes('application/json') ? await response.json() : await response.text(); - if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试'); - return data; -} - let toastTimer; function toast(title, message = '') { const element = document.querySelector('#toast'); @@ -89,314 +26,6 @@ function closeModal() { modalRoot.innerHTML = ''; } -function brand() { - return `衡准EXAM SERVICE`; -} - -function publicHeader() { - return `
${brand()}
`; -} - -function renderHome() { - const { notices, exams, stats, organization } = state.publicData; - const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; - const topNotice = notices[0]; - app.innerHTML = `${publicHeader()}
-
-
-
最新

HAIZHOU EXAMINATION SERVICE

一个报名号,
贯穿每一次考试。

使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。

${state.user?.role === 'candidate' ? `` : state.publicData.selfRegistrationEnabled ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
- ${featured ? renderHeroTicket(featured) : '
暂无开放考试
'} -
-
-

NOTICE BOARD

通知公告

报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
-

OPEN EXAMINATIONS

考试报名

登录后选择考试,并按实际需要勾选报考科目。

${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
-

SERVICE FLOW

报名号是唯一账户

报名号不会随考试改变,每场考试只新增一条报名记录。

${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
-
`; -} - -function renderHeroTicket(exam) { - const status = exam.registrationState; - return `
${badge(status)}${h(exam.code)}

UPCOMING EXAM

${h(exam.name)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考试地点
${h(exam.location)}
${exam.subjects.slice(0, 5).map(subject => `${h(subject.name)}`).join('')}${exam.subjects.length > 5 ? `+${exam.subjects.length - 5}` : ''}
报名人数${h(exam.registrationCount || 0)}
`; -} - -function renderNoticeRow(notice) { - return ``; -} - -function renderPublicExam(exam) { - return `
${h(exam.code)}${badge(exam.registrationState)}

${h(exam.name)}

${h(exam.description)}

报名${dateRange(exam.registrationStart, exam.registrationEnd)}考试${dateRange(exam.examStart, exam.examEnd)}
`; -} - -function renderAuth(kind) { - const login = kind === 'login'; - const selfRegistration = state.publicData.selfRegistrationEnabled; - app.innerHTML = `
${brand()}

CANDIDATE SERVICE

${login ? '凭一个号码,' : '自主申请,'}
${login ? '办理每一次考试。' : '领取固定报名号。'}

报名号就是考生账户,不因考试、科目或年度报名而改变。

首次登录顺序

修改初始密码 → 补全个人信息 → 等待资料审核。

${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}

${login ? '报名号登录' : '自主申请报名号'}

${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}

${login ? loginForm() : selfRegistration ? registerForm() : '
自主注册已关闭学校管理员会为考生创建账户并下发初始密码。
'}${login && selfRegistration ? `
还没有报名号?
` : !login ? '
已经有报名号?
' : ''}${login ? `
演示账号
` : ''}
`; -} - -function loginForm() { - return `
`; -} - -function registerForm() { - const schools = state.publicData.schools || []; - return `
`; -} - -const candidateNav = [ - ['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'], - ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'] -]; -function adminNavForUser() { - const level = state.user?.adminLevel || 'super'; - const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; - if (level === 'class') return core; - const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; - if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]]; - return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]]; -} - -function portalShell(role, page, content, title, description) { - const nav = role === 'admin' ? adminNavForUser() : candidateNav; - const roleName = role === 'admin' ? '管理后台' : '考生中心'; - const adminTitle = statusLabels[state.user?.adminLevel] || '管理员'; - return `
${roleName}/${h(title)}
${role === 'admin' && state.user?.adminLevel !== 'class' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; -} - -function portalHeadingAction(role, page) { - if (role === 'admin' && page === 'notices') return ``; - if (role === 'admin' && page === 'exams') return ``; - if (role === 'admin' && page === 'admins') return ``; - if (role === 'admin' && page === 'centers') return ``; - if (role === 'admin' && page === 'organization') return ``; - if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')}`; - return ''; -} - -function loadingPanel() { - return `
正在读取数据
`; -} - -function onboardingShell(stage, content) { - const passwordDone = stage !== 'password'; - return `
FIRST SIGN-IN

${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}

${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}

${content}
`; -} - -function passwordOnboardingForm() { - return `
新密码要求至少 8 位,且不能与初始密码相同。
`; -} - -async function renderCandidate(page) { - if (state.user?.role !== 'candidate') return navigate('login'); - if (state.user.mustChangePassword) { - app.innerHTML = onboardingShell('password', passwordOnboardingForm()); - return; - } - if (!state.profile?.profileCompleted) { - try { - const data = await api('/api/candidate/profile'); - state.pageData = data; state.profile = data.profile; - app.innerHTML = onboardingShell('profile', candidateProfile(data, true)); - } catch (error) { renderError(error); } - return; - } - const meta = { - dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'], - profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'], - exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'], - registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'], - admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'], - results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'], - notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'] - }; - if (!meta[page]) page = 'dashboard'; - app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]); - try { - const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations'; - const data = page === 'notices' ? { notices: state.publicData.notices } : await api(`/api/candidate/${endpoint}`); - state.pageData = data; - if (data.profile) state.profile = data.profile; - const content = { - dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data), - registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations), - results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices) - }[page](); - app.innerHTML = portalShell('candidate', page, content, ...meta[page]); - } catch (error) { renderError(error); } -} - -function candidateDashboard(data) { - const registration = data.registrations[0]; - const steps = [ - ['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'], - ['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'], - ['考试报名', Boolean(registration), registration ? '已报名' : '未报名'], - ['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'], - ['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length} 科` : '待发布'] - ]; - return `
${new Date().getHours() < 12 ? '上午好' : '下午好'}

${h(data.profile?.name || state.user.displayName)},下一步已为你标出。

${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}


${icons.user}
个人资料${statusLabels[data.profile?.status] || '未填写'}
${badge(data.profile?.status || 'pending')}
${icons.exam}
已报名考试${data.registrations.length} 场
${icons.ticket}
可下载准考证${data.registrations.filter(item => item.admitCard).length} 份
${icons.chart}
已发布成绩${data.results.length} 科

我的应考进度

自动更新
${steps.map((step, index) => `
${step[1] ? '✓' : index + 1}
${step[0]}${step[2]}
`).join('')}

最近通知

${data.notices.map(notice => ``).join('')}
`; -} - -function candidateProfile(data, onboarding = false) { - const { profile, schools = [], classes = [], workflow } = data; - const step = workflow?.currentStepDetail; - const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber; - return `
${workflow ? `
当前审批${h(step?.name || statusLabels[workflow.status])}${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}
` : ''}
01

身份信息

姓名和证件号码须与有效证件完全一致。

02

学校与班级

学校和班级决定资料审批范围。

03

家庭与联系信息

用于考试通知、身份复核和紧急联系。

${profile?.reviewNote ? `
审核意见

${h(profile.reviewNote)}

` : ''}

${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}

`; -} - -function candidateExams(data) { - return `
${data.exams.map(exam => `
${h(exam.code)}${badge(exam.registrationState)}
${exam.registrationCount || 0} 人已报名

${h(exam.name)}

${h(exam.description)}

报名期限
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点安排
${h(exam.location)}
选择报考科目可多选
${exam.subjects.map(subject => ``).join('') || '

科目安排尚未发布

'}
${exam.registration ? `
${icons.check}已提交报名 · ${exam.registration.subjectIds.length} 个科目${badge(exam.registration.status)}
` : `
已选 0合计 ¥0.00
`}
`).join('')}
`; -} - -function candidateRegistrations(registrations) { - return registrations.length ? `
${registrations.map(reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.status)}
账户报名号
${h(reg.registrationNumber || state.user.candidateNumber)}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}
`).join('')}
` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); -} - -function candidateAdmit(registrations) { - const cards = registrations.filter(reg => reg.admitCard); - return cards.length ? `
${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
考点
${h(reg.admitCard.testCenter)}
考场 / 座位
${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
下载后请使用 A4 纸打印
`; }).join('')}
` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态'); -} - -function candidateResults(results) { - if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知'); - const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {}); - return `
${Object.entries(grouped).map(([examName, items]) => `
${h(items[0].examCode)}

${h(examName)}

发布时间 ${formatDate(items[0].publishedAt, true)}
${items.map(item => `
${h(item.subjectName)}${h(item.score)}${h(item.grade)}满分 150
`).join('')}
`).join('')}
`; -} - -function candidateNotices(notices) { - return `
${notices.map(notice => ``).join('')}
`; -} - -async function renderAdmin(page) { - if (state.user?.role !== 'admin') return navigate('login'); - const meta = { - dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], - registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], - notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'], - results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'], - admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], - centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], - organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'], - 'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'], - flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], - 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], - 'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'] - }; - const allowedPages = adminNavForUser().map(item => item[0]); - if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; - app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); - try { - const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page; - const data = await api(`/api/admin/${endpoint}`); - state.pageData = data; - const content = { - dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations), - exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data), - admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data), - 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data) - }[page](); - app.innerHTML = portalShell('admin', page, content, ...meta[page]); - } catch (error) { renderError(error); } -} - -function adminDashboard(data) { - const m = data.metrics; - const canFlow = state.user.adminLevel !== 'class'; - return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
待处理流程${m.pendingFlows ?? 0}${canFlow ? '进入流程中心办理' : '班级账号只读'}
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

${canFlow ? '当前工作入口' : '本班查询入口'}

${h(data.scopeLabel)}
${canFlow ? `` : ''}

最近操作

系统审计日志
${data.logs.map(log => `
${h((log.actorName || '系').slice(0,1))}

${h(log.actorName || '系统')} · ${h(log.action)}${h(log.detail)}

`).join('') || '

当前账号暂无操作记录

'}
`; -} - -function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) { - return `
${h(label)} Excel使用系统模板可获得逐行校验
${template ? `` : ''}${importable ? `` : ''}
`; -} - -function adminSchoolOrganization(data) { - const classes = data.classes || []; - const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0); - return `
SCHOOL ORGANIZATION

${h(data.school?.name)}

班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。

班级
${classes.length}
班级管理员
${activeAdmins}
在册考生
${classes.reduce((sum, item) => sum + item.candidateCount, 0)}
${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}
${classes.map(item => `
${h(item.grade)}

${h(item.name)}

${badge(item.active ? 'approved' : 'closed')}
${item.candidateCount}在册考生${item.admins.length}管理员
班级管理员
${item.admins.map(admin => ``).join('') || '

尚未配置班级管理员

'}
`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}
`; -} - -function adminCandidates(candidates) { - const readOnly = state.user.adminLevel === 'class'; - return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}
${candidates.map(item => ``).join('')}
报名号 / 考生证件号码学校 / 班级账户进度更新时间资料状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.candidateNumber || '待分配')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}${h(item.workflow?.assignee?.displayName || '')}${formatDate(item.updatedAt,true)}${item.profileCompleted ? badge(item.status) : '未完成'}
`; -} - -function adminRegistrations(registrations) { - const readOnly = state.user.adminLevel === 'class'; - return `
${registrations.map(reg => ``).join('')}
考生考试 / 科目账户报名号当前流程缴费状态操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.registrationNumber || '待同步账户号码')}各次考试保持一致${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.status)}
`; -} - -function adminExams(exams) { - return `
${exams.map(exam => `
${h(exam.code)}${badge(exam.status)}

${h(exam.name)}

${h(exam.description)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点
${h(exam.location)}
${exam.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('') || '科目待配置'}
`).join('')}
`; -} - -function adminNotices(notices) { - return `

发布状态会实时同步到公开首页和考生中心。

${notices.map(notice => ``).join('')}
通知标题分类作者发布时间展示状态操作
${h(notice.title)}${h(notice.summary)}${h(notice.category)}${h(notice.author)}${formatDate(notice.publishAt || notice.createdAt,true)}${notice.pinned ? '首页置顶' : '普通'}${badge(notice.status)}
`; -} - -function adminAdmit(registrations) { - const approved = registrations.filter(reg => reg.status === 'approved'); - return `

仅已通过报名审核的考生可生成准考证。

${approved.map(reg => ``).join('') || ''}
考生考试报考科目准考证号考点 / 考场下载时间操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.phone)}
${h(reg.exam.name)}${h(reg.exam.code)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.admitCard?.number || '尚未生成')}${reg.admitCard ? `${h(reg.admitCard.testCenter)}${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号` : '—'}${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
暂无已通过的考试报名
`; -} - -function adminResults(data) { - const entry = state.user.adminLevel === 'super' ? `

录入单科成绩

保存后可立即发布
` : ''; - return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}
${entry}

${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}

${data.results.length} 条记录
${data.results.slice(0, 50).map(result => `
${h((result.candidateName || '?').slice(0,1))}

${h(result.candidateName)} · ${h(result.subjectName)}${h(result.examName)}

${h(result.score)}${badge(result.published ? 'published' : 'draft')}
`).join('') || '

还没有成绩记录

'}
`; -} - -function adminUsers(data) { - return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}

管理员层级决定可见范围和可执行操作;同一级可创建多名账号。

${data.admins.map(item => ``).join('')}
管理员登录账号层级绑定范围状态
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
`; -} - -function adminCenters(data) { - const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' }; - const cards = data.centers.map(center => `
${h(center.schoolName)} · ${h(center.code)}

${h(center.name)}

${center.pendingChange ? '变更审批中' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}
结构化考场${center.rooms.length}
启用席位${center.totalCapacity}
开放时间${h(center.gateOpenTime || '未设')}
详细地址
${h(center.address)}
考点负责人
${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}
应急电话
${h(center.emergencyPhone || '未填写')}
交通提示
${h(center.transport || '未填写')}
${center.rooms.map(room => ``).join('')}
考场位置类型容量座位编排状态
${h(room.name)}${h(room.code)}${h(room.building)} · ${h(room.floor || '楼层未填')}${h(roomTypeNames[room.roomType] || room.roomType)}${h(room.capacity)} 席${h(room.seatPlan || '按现场座次表编排')}${badge(room.status === 'active' ? 'approved' : 'closed')}
`).join(''); - const requests = data.changeRequests || []; - return `${excelToolbar('centers', { label: '考点考场档案' })}
正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requests.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
暂无考点变更申请
`; -} - -const numberSegmentMeta = { - year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], - sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字'] -}; - -function adminAccountBatches(data) { - const classes = data.classes || []; - const batches = data.batches || []; - const form = `
SCHOOL ACCOUNT REQUEST

按班级申领报名号

只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。

单批上限500个账户
${classes.map(item => ``).join('')}

审批通过后生成固定报名号、随机初始密码、待补录考生账户

`; - const ledger = batches.map(batch => { - const resultRows = batch.status === 'approved' ? `
账号下发清单${batch.totalCount} 个账号 · 考生首次登录必须改密
${batch.items.map((item, index) => ``).join('')}
序号班级固定报名号 / 账户初始密码
${index + 1}${h(item.className)}${h(item.candidateNumber)}${h(item.initialPassword)}
` : ''; - return `
${h(batch.id)}

${h(batch.schoolName)} · ${batch.totalCount} 个报名号

${formatDate(batch.createdAt, true)} 由 ${h(batch.requesterName)} 提交

${badge(batch.status)}
${batch.quotas.map(item => `${h(item.className)}${item.count} 人`).join('')}
当前进度${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}
${batch.reviewNote ? `
审批意见${h(batch.reviewNote)}
` : ''}${resultRows}
`; - }).join(''); - return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}
REQUEST LEDGER

申领批次与返回结果

结果只在最终批准后生成;报名号随后作为考生长期账户。

${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}
`; -} - -function adminNumberRules(data) { - const rule = data.activeRule || { name: '自定义报名号规则', separator: '-', segments: [] }; - const byType = Object.fromEntries(rule.segments.map(item => [item.type, item])); - const types = Object.keys(numberSegmentMeta); - return `
ONE CANDIDATE · ONE NUMBER超级管理员只设计号码规则

校级管理员按班级提交申领,流程最终批准后系统才创建长期考生账户。

账户报名号组成

规则用于最终审批后的批量建号;流水号为必选字段。

当前规则
${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return ``; }).join('')}
`; -} - -function adminFlowDesign(workflows) { - const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE', candidate_account_batch: 'ACCOUNT BATCH' }; - return `
${workflows.map(workflow => `
${h(codes[workflow.businessType] || workflow.businessType)}

${h(workflow.name)}

${workflow.steps.map(step => workflowStepEditor(step)).join('')}
`).join('')}
`; -} - -function workflowStepEditor(step = {}) { - return `
`; -} - -function adminFlows(data) { - const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' }; - const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领' }; - return `
${data.instances.map(instance => { - const isCenter = instance.businessType === 'center_change'; - const isBatch = instance.businessType === 'candidate_account_batch'; - const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : instance.candidateName; - const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`; - return `
${h(typeNames[instance.businessType] || instance.businessType)}

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
`; - }).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
`; -} - function emptyState(title, description, route, action) { return `
${icons.ticket}

${h(title)}

${h(description)}

${route ? `` : ''}
`; } @@ -405,6 +34,11 @@ function renderError(error) { app.innerHTML = `
!

页面暂时无法加载

${h(error.message)}

`; } +const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, statusLabels, icons, api, renderError, emptyState }; +const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext); +const { adminNavForUser, portalShell, loadingPanel, renderCandidate } = createCandidateViews({ ...baseViewContext, brand }); +const { renderAdmin } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser }); + function navigate(route) { location.hash = route; if (location.hash.slice(1) === route) renderRoute(); diff --git a/database.mjs b/database.mjs index d0b2949..810c369 100644 --- a/database.mjs +++ b/database.mjs @@ -1,5 +1,8 @@ import { mkdir } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; +import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs'; +import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs'; +import { createMysqlAdapter } from './src/database/mysql-adapter.mjs'; export const relationalTables = [ 'schema_metadata', @@ -30,748 +33,6 @@ export const relationalTables = [ 'audit_logs' ]; -const sqliteSchema = ` - PRAGMA foreign_keys = ON; - - CREATE TABLE IF NOT EXISTS schema_metadata ( - id INTEGER PRIMARY KEY CHECK (id = 1), - schema_version INTEGER NOT NULL DEFAULT 1, - app_version INTEGER NOT NULL DEFAULT 1, - self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1)), - created_at TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS organization ( - id INTEGER PRIMARY KEY CHECK (id = 1), - name TEXT NOT NULL, - code TEXT NOT NULL, - phone TEXT NOT NULL, - address TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS schools ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - code TEXT NOT NULL UNIQUE, - address TEXT, - active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) - ) STRICT; - - CREATE TABLE IF NOT EXISTS school_classes ( - id TEXT PRIMARY KEY, - school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - name TEXT NOT NULL, - grade TEXT NOT NULL, - active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), - UNIQUE (school_id, name) - ) STRICT; - - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - candidate_number TEXT UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')), - admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')), - school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, - class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, - active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), - must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)), - display_name TEXT NOT NULL, - created_at TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS candidate_profiles ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - gender TEXT, - id_number TEXT NOT NULL UNIQUE, - phone TEXT NOT NULL, - email TEXT, - school TEXT, - grade TEXT, - school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, - class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, - address TEXT, - emergency_contact TEXT, - emergency_phone TEXT, - native_place TEXT, - birth_date TEXT, - ethnicity TEXT, - postal_code TEXT, - guardian_name TEXT, - guardian_phone TEXT, - profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)), - status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), - review_note TEXT, - reviewed_at TEXT, - reviewer_id TEXT REFERENCES users(id) ON DELETE SET NULL, - updated_at TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS notices ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - summary TEXT NOT NULL, - content TEXT NOT NULL, - category TEXT NOT NULL, - pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), - status TEXT NOT NULL CHECK (status IN ('draft', 'published')), - publish_at TEXT, - created_at TEXT, - author TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS exams ( - id TEXT PRIMARY KEY, - code TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT NOT NULL, - registration_start TEXT NOT NULL, - registration_end TEXT NOT NULL, - exam_start TEXT NOT NULL, - exam_end TEXT NOT NULL, - admit_download_start TEXT NOT NULL, - admit_download_end TEXT NOT NULL, - location TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')), - created_at TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS exam_subjects ( - id TEXT PRIMARY KEY, - exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, - name TEXT NOT NULL, - subject_date TEXT NOT NULL, - start_time TEXT NOT NULL, - end_time TEXT NOT NULL, - fee REAL NOT NULL DEFAULT 0, - position INTEGER NOT NULL, - UNIQUE (exam_id, position) - ) STRICT; - - CREATE TABLE IF NOT EXISTS registrations ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, - status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), - payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')), - created_at TEXT NOT NULL, - reviewed_at TEXT, - review_note TEXT, - registration_number TEXT, - number_rule_id TEXT, - UNIQUE (user_id, exam_id) - ) STRICT; - - CREATE TABLE IF NOT EXISTS registration_subjects ( - registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, - subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, - PRIMARY KEY (registration_id, subject_id) - ) STRICT; - - CREATE TABLE IF NOT EXISTS admit_cards ( - registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE, - card_number TEXT NOT NULL UNIQUE, - test_center TEXT NOT NULL, - room TEXT NOT NULL, - seat TEXT NOT NULL, - generated_at TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS results ( - id TEXT PRIMARY KEY, - registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, - subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, - score REAL NOT NULL CHECK (score >= 0 AND score <= 150), - grade TEXT NOT NULL, - published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), - updated_at TEXT, - published_at TEXT, - UNIQUE (registration_id, subject_id) - ) STRICT; - - CREATE TABLE IF NOT EXISTS test_centers ( - id TEXT PRIMARY KEY, - school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - code TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - address TEXT NOT NULL, - contact TEXT, - manager_name TEXT, - manager_phone TEXT, - emergency_phone TEXT, - gate_open_time TEXT, - transport TEXT, - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), - notes TEXT, - rooms TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE (school_id, name) - ) STRICT; - - CREATE TABLE IF NOT EXISTS test_rooms ( - id TEXT PRIMARY KEY, - center_id TEXT NOT NULL REFERENCES test_centers(id) ON DELETE CASCADE, - code TEXT NOT NULL, - name TEXT NOT NULL, - building TEXT NOT NULL, - floor TEXT, - capacity INTEGER NOT NULL CHECK (capacity > 0), - seat_plan TEXT, - seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0), - seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start), - room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), - notes TEXT, - UNIQUE (center_id, code) - ) STRICT; - - CREATE TABLE IF NOT EXISTS center_change_requests ( - id TEXT PRIMARY KEY, - center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, - school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')), - code TEXT NOT NULL, - name TEXT NOT NULL, - address TEXT NOT NULL, - contact TEXT, - manager_name TEXT, - manager_phone TEXT, - emergency_phone TEXT, - gate_open_time TEXT, - transport TEXT, - center_status TEXT NOT NULL CHECK (center_status IN ('active', 'inactive')), - notes TEXT, - status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), - review_note TEXT, - requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, - created_at TEXT NOT NULL, - reviewed_at TEXT - ) STRICT; - - CREATE TABLE IF NOT EXISTS center_change_rooms ( - id TEXT PRIMARY KEY, - request_id TEXT NOT NULL REFERENCES center_change_requests(id) ON DELETE CASCADE, - room_id TEXT, - code TEXT NOT NULL, - name TEXT NOT NULL, - building TEXT NOT NULL, - floor TEXT, - capacity INTEGER NOT NULL CHECK (capacity > 0), - seat_plan TEXT, - seat_start INTEGER NOT NULL DEFAULT 1, - seat_end INTEGER NOT NULL, - room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), - status TEXT NOT NULL CHECK (status IN ('active', 'inactive')), - notes TEXT, - UNIQUE (request_id, code) - ) STRICT; - - CREATE TABLE IF NOT EXISTS number_rules ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - separator TEXT NOT NULL DEFAULT '', - active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0, 1)), - created_by TEXT REFERENCES users(id) ON DELETE SET NULL, - updated_at TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS number_rule_segments ( - id TEXT PRIMARY KEY, - rule_id TEXT NOT NULL REFERENCES number_rules(id) ON DELETE CASCADE, - position INTEGER NOT NULL, - type TEXT NOT NULL CHECK (type IN ('year', 'school_code', 'gender', 'sequence', 'literal')), - value TEXT, - width INTEGER NOT NULL DEFAULT 0, - UNIQUE (rule_id, position) - ) STRICT; - - CREATE TABLE IF NOT EXISTS candidate_account_batches ( - id TEXT PRIMARY KEY, - school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, - requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, - status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), - review_note TEXT, - created_at TEXT NOT NULL, - reviewed_at TEXT - ) STRICT; - - CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( - id TEXT PRIMARY KEY, - batch_id TEXT NOT NULL REFERENCES candidate_account_batches(id) ON DELETE CASCADE, - class_id TEXT NOT NULL REFERENCES school_classes(id) ON DELETE RESTRICT, - position INTEGER NOT NULL, - candidate_number TEXT UNIQUE, - initial_password TEXT, - user_id TEXT UNIQUE REFERENCES users(id) ON DELETE SET NULL, - created_at TEXT, - UNIQUE (batch_id, position) - ) STRICT; - - CREATE TABLE IF NOT EXISTS workflow_definitions ( - id TEXT PRIMARY KEY, - business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')), - name TEXT NOT NULL, - active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), - updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, - updated_at TEXT NOT NULL, - UNIQUE (business_type, active) - ) STRICT; - - CREATE TABLE IF NOT EXISTS workflow_steps ( - id TEXT PRIMARY KEY, - workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE, - position INTEGER NOT NULL, - name TEXT NOT NULL, - admin_level TEXT NOT NULL CHECK (admin_level IN ('school', 'super')), - UNIQUE (workflow_id, position) - ) STRICT; - - CREATE TABLE IF NOT EXISTS workflow_instances ( - id TEXT PRIMARY KEY, - workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE RESTRICT, - business_type TEXT NOT NULL, - business_id TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), - current_step INTEGER NOT NULL DEFAULT 1, - assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, - created_at TEXT NOT NULL, - completed_at TEXT - ) STRICT; - - CREATE TABLE IF NOT EXISTS workflow_actions ( - id TEXT PRIMARY KEY, - instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE, - actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, - action TEXT NOT NULL CHECK (action IN ('submit', 'approve', 'reject', 'transfer', 'return', 'supervise')), - note TEXT, - from_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, - to_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, - created_at TEXT NOT NULL - ) STRICT; - - CREATE TABLE IF NOT EXISTS audit_logs ( - id TEXT PRIMARY KEY, - actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, - action TEXT NOT NULL, - detail TEXT NOT NULL, - created_at TEXT NOT NULL - ) STRICT; - - CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status); - CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status); - CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at); - CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end); - CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position); - CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status); - CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id); - CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type); - CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code); - CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code); - CREATE INDEX IF NOT EXISTS idx_center_changes_school ON center_change_requests(school_id, status, created_at); - CREATE INDEX IF NOT EXISTS idx_account_batches_school ON candidate_account_batches(school_id, status, created_at); - CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position); - CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); - CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); -`; - -const mysqlSchema = [ - `CREATE TABLE IF NOT EXISTS schema_metadata ( - id TINYINT UNSIGNED NOT NULL, - schema_version INT UNSIGNED NOT NULL DEFAULT 1, - app_version INT UNSIGNED NOT NULL DEFAULT 1, - self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE, - created_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS organization ( - id TINYINT UNSIGNED NOT NULL, - name VARCHAR(120) NOT NULL, - code VARCHAR(60) NOT NULL, - phone VARCHAR(60) NOT NULL, - address VARCHAR(255) NOT NULL, - PRIMARY KEY (id), - CONSTRAINT chk_organization_singleton CHECK (id = 1) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS schools ( - id VARCHAR(64) NOT NULL, - name VARCHAR(160) NOT NULL, - code VARCHAR(40) NOT NULL, - address VARCHAR(255) NULL, - active BOOLEAN NOT NULL DEFAULT TRUE, - PRIMARY KEY (id), - UNIQUE KEY uq_schools_name (name), - UNIQUE KEY uq_schools_code (code) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS school_classes ( - id VARCHAR(64) NOT NULL, - school_id VARCHAR(64) NOT NULL, - name VARCHAR(100) NOT NULL, - grade VARCHAR(60) NOT NULL, - active BOOLEAN NOT NULL DEFAULT TRUE, - PRIMARY KEY (id), - UNIQUE KEY uq_classes_school_name (school_id, name), - CONSTRAINT fk_classes_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS users ( - id VARCHAR(64) NOT NULL, - username VARCHAR(100) NOT NULL, - candidate_number VARCHAR(120) NULL, - password_hash VARCHAR(255) NOT NULL, - role ENUM('admin', 'candidate') NOT NULL, - admin_level ENUM('super', 'school', 'class') NULL, - school_id VARCHAR(64) NULL, - class_id VARCHAR(64) NULL, - active BOOLEAN NOT NULL DEFAULT TRUE, - must_change_password BOOLEAN NOT NULL DEFAULT FALSE, - display_name VARCHAR(100) NOT NULL, - created_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_users_username (username), - UNIQUE KEY uq_users_candidate_number (candidate_number), - KEY idx_users_admin_scope (role, admin_level, school_id, class_id), - CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, - CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS candidate_profiles ( - id VARCHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - name VARCHAR(100) NOT NULL, - gender VARCHAR(20) NULL, - id_number VARCHAR(60) NOT NULL, - phone VARCHAR(60) NOT NULL, - email VARCHAR(160) NULL, - school VARCHAR(160) NULL, - grade VARCHAR(100) NULL, - school_id VARCHAR(64) NULL, - class_id VARCHAR(64) NULL, - address VARCHAR(255) NULL, - emergency_contact VARCHAR(100) NULL, - emergency_phone VARCHAR(60) NULL, - native_place VARCHAR(160) NULL, - birth_date VARCHAR(20) NULL, - ethnicity VARCHAR(60) NULL, - postal_code VARCHAR(20) NULL, - guardian_name VARCHAR(100) NULL, - guardian_phone VARCHAR(60) NULL, - profile_completed BOOLEAN NOT NULL DEFAULT FALSE, - status ENUM('pending', 'approved', 'rejected') NOT NULL, - review_note VARCHAR(500) NULL, - reviewed_at VARCHAR(35) NULL, - reviewer_id VARCHAR(64) NULL, - updated_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_profiles_user (user_id), - UNIQUE KEY uq_profiles_id_number (id_number), - KEY idx_profiles_status (status), - CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL, - CONSTRAINT fk_profiles_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, - CONSTRAINT fk_profiles_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS notices ( - id VARCHAR(64) NOT NULL, - title VARCHAR(240) NOT NULL, - summary VARCHAR(500) NOT NULL, - content TEXT NOT NULL, - category VARCHAR(60) NOT NULL, - pinned BOOLEAN NOT NULL DEFAULT FALSE, - status ENUM('draft', 'published') NOT NULL, - publish_at VARCHAR(35) NULL, - created_at VARCHAR(35) NULL, - author VARCHAR(100) NOT NULL, - PRIMARY KEY (id), - KEY idx_notices_status_publish (status, publish_at) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS exams ( - id VARCHAR(64) NOT NULL, - code VARCHAR(60) NOT NULL, - name VARCHAR(200) NOT NULL, - description TEXT NOT NULL, - registration_start VARCHAR(35) NOT NULL, - registration_end VARCHAR(35) NOT NULL, - exam_start VARCHAR(35) NOT NULL, - exam_end VARCHAR(35) NOT NULL, - admit_download_start VARCHAR(35) NOT NULL, - admit_download_end VARCHAR(35) NOT NULL, - location VARCHAR(200) NOT NULL, - status ENUM('draft', 'published', 'closed') NOT NULL, - created_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_exams_code (code), - KEY idx_exams_status_registration (status, registration_start, registration_end) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS exam_subjects ( - id VARCHAR(64) NOT NULL, - exam_id VARCHAR(64) NOT NULL, - name VARCHAR(100) NOT NULL, - subject_date VARCHAR(35) NOT NULL, - start_time VARCHAR(20) NOT NULL, - end_time VARCHAR(20) NOT NULL, - fee DOUBLE NOT NULL DEFAULT 0, - position INT UNSIGNED NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_subjects_exam_position (exam_id, position), - KEY idx_subjects_exam (exam_id, position), - CONSTRAINT fk_subjects_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS registrations ( - id VARCHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - exam_id VARCHAR(64) NOT NULL, - status ENUM('pending', 'approved', 'rejected') NOT NULL, - payment_status ENUM('unpaid', 'paid', 'refunded') NOT NULL, - created_at VARCHAR(35) NOT NULL, - reviewed_at VARCHAR(35) NULL, - review_note VARCHAR(500) NULL, - registration_number VARCHAR(120) NULL, - number_rule_id VARCHAR(64) NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_registrations_user_exam (user_id, exam_id), - KEY idx_registrations_status (status), - KEY idx_registrations_exam (exam_id), - CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS registration_subjects ( - registration_id VARCHAR(64) NOT NULL, - subject_id VARCHAR(64) NOT NULL, - PRIMARY KEY (registration_id, subject_id), - CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, - CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS admit_cards ( - registration_id VARCHAR(64) NOT NULL, - card_number VARCHAR(100) NOT NULL, - test_center VARCHAR(200) NOT NULL, - room VARCHAR(100) NOT NULL, - seat VARCHAR(30) NOT NULL, - generated_at VARCHAR(35) NOT NULL, - PRIMARY KEY (registration_id), - UNIQUE KEY uq_admit_cards_number (card_number), - CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS results ( - id VARCHAR(64) NOT NULL, - registration_id VARCHAR(64) NOT NULL, - subject_id VARCHAR(64) NOT NULL, - score DOUBLE NOT NULL, - grade VARCHAR(20) NOT NULL, - published BOOLEAN NOT NULL DEFAULT FALSE, - updated_at VARCHAR(35) NULL, - published_at VARCHAR(35) NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_results_registration_subject (registration_id, subject_id), - KEY idx_results_registration (registration_id, published), - CONSTRAINT chk_results_score CHECK (score >= 0 AND score <= 150), - CONSTRAINT fk_results_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, - CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS test_centers ( - id VARCHAR(64) NOT NULL, - school_id VARCHAR(64) NOT NULL, - code VARCHAR(40) NOT NULL, - name VARCHAR(200) NOT NULL, - address VARCHAR(255) NOT NULL, - contact VARCHAR(100) NULL, - manager_name VARCHAR(100) NULL, - manager_phone VARCHAR(60) NULL, - emergency_phone VARCHAR(60) NULL, - gate_open_time VARCHAR(40) NULL, - transport VARCHAR(500) NULL, - status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', - notes VARCHAR(1000) NULL, - rooms TEXT NOT NULL, - updated_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_centers_code (code), - UNIQUE KEY uq_centers_school_name (school_id, name), - CONSTRAINT fk_centers_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS test_rooms ( - id VARCHAR(64) NOT NULL, - center_id VARCHAR(64) NOT NULL, - code VARCHAR(40) NOT NULL, - name VARCHAR(120) NOT NULL, - building VARCHAR(120) NOT NULL, - floor VARCHAR(40) NULL, - capacity INT UNSIGNED NOT NULL, - seat_plan VARCHAR(500) NULL, - seat_start INT UNSIGNED NOT NULL DEFAULT 1, - seat_end INT UNSIGNED NOT NULL, - room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, - status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', - notes VARCHAR(500) NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_rooms_center_code (center_id, code), - KEY idx_rooms_center (center_id, status, code), - CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS center_change_requests ( - id VARCHAR(64) NOT NULL, - center_id VARCHAR(64) NULL, - school_id VARCHAR(64) NOT NULL, - request_type ENUM('create', 'update') NOT NULL, - code VARCHAR(40) NOT NULL, - name VARCHAR(200) NOT NULL, - address VARCHAR(255) NOT NULL, - contact VARCHAR(100) NULL, - manager_name VARCHAR(100) NULL, - manager_phone VARCHAR(60) NULL, - emergency_phone VARCHAR(60) NULL, - gate_open_time VARCHAR(40) NULL, - transport VARCHAR(500) NULL, - center_status ENUM('active', 'inactive') NOT NULL, - notes VARCHAR(1000) NULL, - status ENUM('pending', 'approved', 'rejected') NOT NULL, - review_note VARCHAR(500) NULL, - requested_by VARCHAR(64) NULL, - created_at VARCHAR(35) NOT NULL, - reviewed_at VARCHAR(35) NULL, - PRIMARY KEY (id), - KEY idx_center_changes_school (school_id, status, created_at), - CONSTRAINT fk_center_change_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL, - CONSTRAINT fk_center_change_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, - CONSTRAINT fk_center_change_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS center_change_rooms ( - id VARCHAR(64) NOT NULL, - request_id VARCHAR(64) NOT NULL, - room_id VARCHAR(64) NULL, - code VARCHAR(40) NOT NULL, - name VARCHAR(120) NOT NULL, - building VARCHAR(120) NOT NULL, - floor VARCHAR(40) NULL, - capacity INT UNSIGNED NOT NULL, - seat_plan VARCHAR(500) NULL, - seat_start INT UNSIGNED NOT NULL DEFAULT 1, - seat_end INT UNSIGNED NOT NULL, - room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, - status ENUM('active', 'inactive') NOT NULL, - notes VARCHAR(500) NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_center_change_rooms_code (request_id, code), - CONSTRAINT fk_center_change_rooms_request FOREIGN KEY (request_id) REFERENCES center_change_requests(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS number_rules ( - id VARCHAR(64) NOT NULL, - name VARCHAR(120) NOT NULL, - separator VARCHAR(10) NOT NULL DEFAULT '', - active BOOLEAN NOT NULL DEFAULT FALSE, - created_by VARCHAR(64) NULL, - updated_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - CONSTRAINT fk_number_rules_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS number_rule_segments ( - id VARCHAR(64) NOT NULL, - rule_id VARCHAR(64) NOT NULL, - position INT UNSIGNED NOT NULL, - type ENUM('year', 'school_code', 'gender', 'sequence', 'literal') NOT NULL, - value VARCHAR(60) NULL, - width INT UNSIGNED NOT NULL DEFAULT 0, - PRIMARY KEY (id), - UNIQUE KEY uq_rule_segments_position (rule_id, position), - CONSTRAINT fk_rule_segments_rule FOREIGN KEY (rule_id) REFERENCES number_rules(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS candidate_account_batches ( - id VARCHAR(64) NOT NULL, - school_id VARCHAR(64) NOT NULL, - requested_by VARCHAR(64) NULL, - status ENUM('pending', 'approved', 'rejected') NOT NULL, - review_note VARCHAR(500) NULL, - created_at VARCHAR(35) NOT NULL, - reviewed_at VARCHAR(35) NULL, - PRIMARY KEY (id), - KEY idx_account_batches_school (school_id, status, created_at), - CONSTRAINT fk_account_batches_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, - CONSTRAINT fk_account_batches_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( - id VARCHAR(64) NOT NULL, - batch_id VARCHAR(64) NOT NULL, - class_id VARCHAR(64) NOT NULL, - position INT UNSIGNED NOT NULL, - candidate_number VARCHAR(120) NULL, - initial_password VARCHAR(120) NULL, - user_id VARCHAR(64) NULL, - created_at VARCHAR(35) NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_account_batch_position (batch_id, position), - UNIQUE KEY uq_account_batch_number (candidate_number), - UNIQUE KEY uq_account_batch_user (user_id), - KEY idx_account_batch_items (batch_id, class_id, position), - CONSTRAINT fk_account_batch_items_batch FOREIGN KEY (batch_id) REFERENCES candidate_account_batches(id) ON DELETE CASCADE, - CONSTRAINT fk_account_batch_items_class FOREIGN KEY (class_id) REFERENCES school_classes(id), - CONSTRAINT fk_account_batch_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS workflow_definitions ( - id VARCHAR(64) NOT NULL, - business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL, - name VARCHAR(120) NOT NULL, - active BOOLEAN NOT NULL DEFAULT TRUE, - updated_by VARCHAR(64) NULL, - updated_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_workflow_type_active (business_type, active), - CONSTRAINT fk_workflow_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS workflow_steps ( - id VARCHAR(64) NOT NULL, - workflow_id VARCHAR(64) NOT NULL, - position INT UNSIGNED NOT NULL, - name VARCHAR(120) NOT NULL, - admin_level ENUM('school', 'super') NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uq_workflow_steps_position (workflow_id, position), - CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS workflow_instances ( - id VARCHAR(64) NOT NULL, - workflow_id VARCHAR(64) NOT NULL, - business_type VARCHAR(40) NOT NULL, - business_id VARCHAR(64) NOT NULL, - status ENUM('pending', 'approved', 'rejected') NOT NULL, - current_step INT UNSIGNED NOT NULL DEFAULT 1, - assignee_id VARCHAR(64) NULL, - created_at VARCHAR(35) NOT NULL, - completed_at VARCHAR(35) NULL, - PRIMARY KEY (id), - KEY idx_workflow_inbox (status, assignee_id, business_type), - CONSTRAINT fk_workflow_instance_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id), - CONSTRAINT fk_workflow_instance_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS workflow_actions ( - id VARCHAR(64) NOT NULL, - instance_id VARCHAR(64) NOT NULL, - actor_id VARCHAR(64) NULL, - action ENUM('submit', 'approve', 'reject', 'transfer', 'return', 'supervise') NOT NULL, - note VARCHAR(500) NULL, - from_assignee_id VARCHAR(64) NULL, - to_assignee_id VARCHAR(64) NULL, - created_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - KEY idx_workflow_actions_instance (instance_id, created_at), - CONSTRAINT fk_workflow_action_instance FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE, - CONSTRAINT fk_workflow_action_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL, - CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL, - CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS audit_logs ( - id VARCHAR(64) NOT NULL, - actor_id VARCHAR(64) NULL, - action VARCHAR(100) NOT NULL, - detail VARCHAR(1000) NOT NULL, - created_at VARCHAR(35) NOT NULL, - PRIMARY KEY (id), - KEY idx_audit_created (created_at), - CONSTRAINT fk_audit_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci` -]; - function validateState(state, source = '数据库') { const collections = [ 'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results', @@ -1931,574 +1192,20 @@ function createRepository({ client, location, read, transaction, close }) { }; } -async function createSqliteStore({ path, seed }) { - const { DatabaseSync } = await import('node:sqlite'); - await mkdir(dirname(path), { recursive: true }); - - const connection = new DatabaseSync(path, { timeout: 5000 }); - connection.exec('PRAGMA journal_mode = WAL;'); - connection.exec('PRAGMA synchronous = NORMAL;'); - const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); - const ensureColumns = (table, columns) => { - if (!tableExists(table)) return; - const existing = new Set(connection.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name)); - for (const [name, definition] of columns) { - if (!existing.has(name)) connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); - } - }; - ensureColumns('users', [ - ['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'], - ['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'] - ]); - ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]); - ensureColumns('candidate_profiles', [ - ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'], - ['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'] - ]); - ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]); - ensureColumns('test_centers', [ - ['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'], - ['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT'] - ]); - ensureColumns('test_rooms', [['seat_plan', 'TEXT']]); - ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]); - if (tableExists('workflow_definitions')) { - const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || ''; - if (!definitionSql.includes('candidate_account_batch')) { - connection.exec(` - PRAGMA foreign_keys = OFF; - BEGIN IMMEDIATE; - CREATE TABLE workflow_definitions_v5 ( - id TEXT PRIMARY KEY, - business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')), - name TEXT NOT NULL, - active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), - updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, - updated_at TEXT NOT NULL, - UNIQUE (business_type, active) - ) STRICT; - INSERT INTO workflow_definitions_v5 (id, business_type, name, active, updated_by, updated_at) - SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions; - DROP TABLE workflow_definitions; - ALTER TABLE workflow_definitions_v5 RENAME TO workflow_definitions; - COMMIT; - PRAGMA foreign_keys = ON; - `); - } - } - if (tableExists('registrations')) { - const registrationsSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registrations'").get()?.sql || ''; - if (/registration_number\s+TEXT\s+UNIQUE/i.test(registrationsSql)) { - connection.exec(` - PRAGMA foreign_keys = OFF; - BEGIN IMMEDIATE; - CREATE TABLE registrations_v4 ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, - status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), - payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')), - created_at TEXT NOT NULL, - reviewed_at TEXT, - review_note TEXT, - registration_number TEXT, - number_rule_id TEXT, - UNIQUE (user_id, exam_id) - ) STRICT; - INSERT INTO registrations_v4 ( - id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id - ) SELECT id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id FROM registrations; - DROP TABLE registrations; - ALTER TABLE registrations_v4 RENAME TO registrations; - COMMIT; - PRAGMA foreign_keys = ON; - `); - } - } - connection.exec(sqliteSchema); - connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)'); - - const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(); - if (existingSystem && Number(existingSystem.app_version || 1) < 2) { - const extension = seed(); - connection.exec('BEGIN IMMEDIATE'); - try { - for (const school of extension.schools) connection.prepare( - 'INSERT OR IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)' - ).run(school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1); - for (const schoolClass of extension.classes) connection.prepare( - 'INSERT OR IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)' - ).run(schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1); - connection.prepare("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, 1) WHERE role = 'admin'").run(); - for (const user of extension.users.filter(item => item.role === 'admin')) connection.prepare( - `INSERT OR IGNORE INTO users ( - id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at - ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)` - ).run(user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt); - for (const profile of extension.candidateProfiles) connection.prepare( - `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) - WHERE school = ? AND grade = ?` - ).run(optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade); - if (!connection.prepare('SELECT id FROM test_centers LIMIT 1').get()) { - for (const center of extension.testCenters) connection.prepare( - 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt); - } - if (!connection.prepare('SELECT id FROM number_rules LIMIT 1').get()) { - for (const rule of extension.numberRules) { - connection.prepare('INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt); - rule.segments.forEach((segment, index) => connection.prepare( - 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)' - ).run(segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0))); - } - } - if (!connection.prepare('SELECT id FROM workflow_definitions LIMIT 1').get()) { - for (const workflow of extension.workflows) { - connection.prepare( - 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' - ).run(workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt); - workflow.steps.forEach((step, index) => connection.prepare( - 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' - ).run(step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel)); - } - } - connection.prepare('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1').run(); - connection.exec('COMMIT'); - } catch (error) { - connection.exec('ROLLBACK'); - connection.close(); - throw error; - } - } - - if (existingSystem && Number(existingSystem.app_version || 1) < 3) { - const extension = seed(); - connection.exec('BEGIN IMMEDIATE'); - try { - for (const center of extension.testCenters) connection.prepare( - `UPDATE test_centers SET - code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?), - manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?), - gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?), - status = COALESCE(status, 'active'), notes = COALESCE(notes, ?) - WHERE id = ?` - ).run(center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), - optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id); - connection.prepare("UPDATE test_centers SET code = 'CENTER-' || substr(id, -8) WHERE code IS NULL OR code = ''").run(); - if (!connection.prepare('SELECT id FROM test_rooms LIMIT 1').get()) { - for (const room of extension.testRooms) connection.prepare( - `INSERT INTO test_rooms ( - id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run(room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), - Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)); - } - const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change'); - if (centerWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1").get()) { - connection.prepare( - 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' - ).run(centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt); - for (const [index, step] of centerWorkflow.steps.entries()) connection.prepare( - 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' - ).run(step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel); - } - connection.prepare('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1').run(); - connection.exec('COMMIT'); - } catch (error) { - connection.exec('ROLLBACK'); - connection.close(); - throw error; - } - } - - if (existingSystem && Number(existingSystem.app_version || 1) < 4) { - const extension = seed(); - connection.exec('BEGIN IMMEDIATE'); - try { - for (const user of extension.users.filter(item => item.role === 'candidate')) connection.prepare( - `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), - must_change_password = COALESCE(must_change_password, ?) WHERE id = ?` - ).run(optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id); - connection.prepare(`UPDATE users SET candidate_number = COALESCE( - (SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1), - 'CAND-' || substr(id, -10) - ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`).run(); - for (const profile of extension.candidateProfiles) connection.prepare( - `UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?), - ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?), - guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?` - ).run(optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), - optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id); - connection.prepare(`UPDATE registrations SET registration_number = ( - SELECT candidate_number FROM users WHERE users.id = registrations.user_id - ) WHERE registration_number IS NULL OR registration_number = ''`).run(); - connection.prepare('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1').run(); - connection.exec('COMMIT'); - } catch (error) { - connection.exec('ROLLBACK'); - connection.close(); - throw error; - } - } - - if (existingSystem && Number(existingSystem.app_version || 1) < 5) { - const extension = seed(); - const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); - connection.exec('BEGIN IMMEDIATE'); - try { - if (batchWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1").get()) { - connection.prepare( - 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' - ).run(batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt); - for (const [index, step] of batchWorkflow.steps.entries()) connection.prepare( - 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' - ).run(step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel); - } - connection.prepare('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1').run(); - connection.exec('COMMIT'); - } catch (error) { - connection.exec('ROLLBACK'); - connection.close(); - throw error; - } - } - - if (existingSystem && Number(existingSystem.app_version || 1) < 6) { - connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run(); - } - - if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { - const initialState = seed(); - connection.exec('BEGIN IMMEDIATE'); - try { - connection.prepare(` - INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 6, ?, ?, ?) - `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); - for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); - connection.exec('COMMIT'); - } catch (error) { - connection.exec('ROLLBACK'); - connection.close(); - throw error; - } - } - - const transaction = async operations => { - connection.exec('BEGIN IMMEDIATE'); - try { - for (const item of operations) connection.prepare(item.sql).run(...item.params); - connection.exec('COMMIT'); - } catch (error) { - connection.exec('ROLLBACK'); - throw error; - } - }; - return createRepository({ - client: 'sqlite', - location: path, - read: async () => stateFromRows(readSqliteRows(connection)), - transaction, - close: async () => connection.close() - }); -} - -async function createMysqlStore({ seed }) { - const { default: mysql } = await import('mysql2/promise'); - const connectionUrl = process.env.DATABASE_URL; - const database = process.env.MYSQL_DATABASE; - - if (!connectionUrl && (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !database)) { - throw new Error('MySQL 配置不完整:请设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE'); - } - - const pool = connectionUrl - ? mysql.createPool(connectionUrl) - : mysql.createPool({ - host: process.env.MYSQL_HOST, - port: Number(process.env.MYSQL_PORT || 3306), - user: process.env.MYSQL_USER, - password: process.env.MYSQL_PASSWORD || '', - database, - waitForConnections: true, - connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10), - charset: 'utf8mb4', - timezone: 'Z', - enableKeepAlive: true - }); - - for (const statement of mysqlSchema) await pool.execute(statement); - const mysqlColumnMigrations = [ - "ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_level ENUM('super', 'school', 'class') NULL", - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS candidate_number VARCHAR(120) NULL', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE', - 'ALTER TABLE schema_metadata ADD COLUMN IF NOT EXISTS self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS native_place VARCHAR(160) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS birth_date VARCHAR(20) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS ethnicity VARCHAR(60) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(100) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_phone VARCHAR(60) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE', - 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL', - 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS emergency_phone VARCHAR(60) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS gate_open_time VARCHAR(40) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL', - "ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'", - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL', - 'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', - 'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', - "ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL" - ]; - for (const statement of mysqlColumnMigrations) await pool.execute(statement); - const [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'"); - if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number'); - const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1'); - if (existing.length) { - const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1'); - if (Number(metadataRows[0]?.app_version || 1) < 2) { - const extension = seed(); - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - for (const school of extension.schools) await connection.execute( - 'INSERT IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', - [school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1] - ); - for (const schoolClass of extension.classes) await connection.execute( - 'INSERT IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', - [schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1] - ); - await connection.execute("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, TRUE) WHERE role = 'admin'"); - for (const user of extension.users.filter(item => item.role === 'admin')) await connection.execute( - `INSERT IGNORE INTO users ( - id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at - ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`, - [user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt] - ); - for (const profile of extension.candidateProfiles) await connection.execute( - `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) - WHERE school = ? AND grade = ?`, - [optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade] - ); - const [centerRows] = await connection.execute('SELECT id FROM test_centers LIMIT 1'); - if (!centerRows.length) for (const center of extension.testCenters) await connection.execute( - 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', - [center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt] - ); - const [ruleRows] = await connection.execute('SELECT id FROM number_rules LIMIT 1'); - if (!ruleRows.length) for (const rule of extension.numberRules) { - await connection.execute( - 'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - [rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt] - ); - for (const [index, segment] of rule.segments.entries()) await connection.execute( - 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)', - [segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)] - ); - } - const [workflowRows] = await connection.execute('SELECT id FROM workflow_definitions LIMIT 1'); - if (!workflowRows.length) for (const workflow of extension.workflows) { - await connection.execute( - 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - [workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt] - ); - for (const [index, step] of workflow.steps.entries()) await connection.execute( - 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', - [step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel] - ); - } - await connection.execute('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1'); - await connection.commit(); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - } - if (Number(metadataRows[0]?.app_version || 1) < 3) { - const extension = seed(); - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - for (const center of extension.testCenters) await connection.execute( - `UPDATE test_centers SET - code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?), - manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?), - gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?), - status = COALESCE(status, 'active'), notes = COALESCE(notes, ?) - WHERE id = ?`, - [center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), - optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id] - ); - await connection.execute("UPDATE test_centers SET code = CONCAT('CENTER-', RIGHT(id, 8)) WHERE code IS NULL OR code = ''"); - const [roomRows] = await connection.execute('SELECT id FROM test_rooms LIMIT 1'); - if (!roomRows.length) for (const room of extension.testRooms) await connection.execute( - `INSERT INTO test_rooms ( - id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), - Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)] - ); - const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change'); - const [centerWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1"); - if (centerWorkflow && !centerWorkflowRows.length) { - await connection.execute( - 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - [centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt] - ); - for (const [index, step] of centerWorkflow.steps.entries()) await connection.execute( - 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', - [step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] - ); - } - await connection.execute('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1'); - await connection.commit(); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - } - if (Number(metadataRows[0]?.app_version || 1) < 4) { - const extension = seed(); - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - for (const user of extension.users.filter(item => item.role === 'candidate')) await connection.execute( - `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), - must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`, - [optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id] - ); - await connection.execute(`UPDATE users SET candidate_number = COALESCE( - (SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1), - CONCAT('CAND-', RIGHT(id, 10)) - ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`); - for (const profile of extension.candidateProfiles) await connection.execute( - `UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?), - ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?), - guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?`, - [optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), - optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id] - ); - await connection.execute(`UPDATE registrations JOIN users ON users.id = registrations.user_id - SET registrations.registration_number = users.candidate_number - WHERE registrations.registration_number IS NULL OR registrations.registration_number = ''`); - await connection.execute('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1'); - await connection.commit(); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - } - if (Number(metadataRows[0]?.app_version || 1) < 5) { - const extension = seed(); - const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - const [batchWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1"); - if (batchWorkflow && !batchWorkflowRows.length) { - await connection.execute( - 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - [batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt] - ); - for (const [index, step] of batchWorkflow.steps.entries()) await connection.execute( - 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', - [step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] - ); - } - await connection.execute('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1'); - await connection.commit(); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - } - if (Number(metadataRows[0]?.app_version || 1) < 6) { - await pool.execute('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1'); - } - } - if (!existing.length) { - const initialState = seed(); - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - const [insert] = await connection.execute(` - INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 6, ?, ?, ?) - `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); - if (insert.affectedRows === 1) { - for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); - } - await connection.commit(); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - } - - const [centerCodeIndexes] = await pool.execute("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'"); - if (!centerCodeIndexes.length) { - await pool.execute('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)'); - } - const [candidateNumberIndexes] = await pool.execute("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'"); - if (!candidateNumberIndexes.length) { - await pool.execute('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)'); - } - - const transaction = async operations => { - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - for (const item of operations) await connection.execute(item.sql, item.params); - await connection.commit(); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }; - const read = async () => { - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - const state = stateFromRows(await readMysqlRows(connection)); - await connection.commit(); - return state; - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }; - return createRepository({ - client: 'mysql', - location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`, - read, - transaction, - close: async () => pool.end() - }); -} +const adapterContext = { + mkdir, + dirname, + sqliteSchema, + mysqlSchema, + optional, + buildSeedOperations, + stateFromRows, + readSqliteRows, + readMysqlRows, + createRepository +}; +const createSqliteStore = createSqliteAdapter(adapterContext); +const createMysqlStore = createMysqlAdapter(adapterContext); export async function createDatabase({ root, seed }) { const client = String(process.env.DATABASE_CLIENT || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase(); diff --git a/server.mjs b/server.mjs index e10baf6..01d5112 100644 --- a/server.mjs +++ b/server.mjs @@ -4,16 +4,35 @@ import { extname, join, normalize, resolve } from 'node:path'; import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto'; import { createDatabase } from './database.mjs'; import { buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs'; +import { createAdminRoutes } from './src/routes/admin.routes.mjs'; +import { createCandidateRoutes } from './src/routes/candidate.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 { createSeedDatabase } from './src/data/seed.mjs'; const root = resolve(process.cwd()); const port = Number(process.env.PORT || 4173); const host = process.env.HOST || '127.0.0.1'; const sessions = new Map(); -const staticFiles = new Set(['/index.html', '/styles.css', '/app.js']); +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/public-views.mjs', + '/src/client/state.mjs', + '/src/client/ui.mjs' +]); 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' }; @@ -38,270 +57,12 @@ function verifyPassword(password, stored) { return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer); } -function seedDatabase() { - const adminId = 'usr_admin'; - const schoolAdminId = 'usr_school_admin'; - const schoolAdmin2Id = 'usr_school_admin_2'; - const candidateId = 'usr_demo'; - const examId = 'exam_autumn_2026'; - const registrationId = 'reg_demo_2026'; - return { - meta: { version: 6, createdAt: nowIso() }, - settings: { selfRegistrationEnabled: false }, - organization: { - name: '海州市教育考试中心', - code: 'HZ-EDU-032', - phone: '0518-8602 3158', - address: '海州市清河区文教路 18 号' - }, - schools: [ - { id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '海州市清河区学府路 8 号', active: true }, - { id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '海州市滨河区育才路 16 号', active: true } - ], - classes: [ - { id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true }, - { id: 'class_hz1_302', schoolId: 'school_hz1', name: '高三(2)班', grade: '高三', active: true }, - { id: 'class_hz3_301', schoolId: 'school_hz3', name: '高三(1)班', grade: '高三', active: true } - ], - users: [ - { id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() }, - { id: 'usr_supervisor', username: 'supervisor', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() }, - { id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() }, - { id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() }, - { id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() }, - { id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: true, createdAt: nowIso() } - ], - candidateProfiles: [ - { - id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821', - phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302', - address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000', - nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false, - status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z' - } - ], - notices: [ - { id: 'notice_1', title: '2026 年秋季统一考试报名安排', summary: '报名时间为 7 月 1 日至 7 月 31 日,请考生完成实名认证后选报科目。', content: '2026 年秋季统一考试报名现已开放。考生须在规定时间内登录平台,核对个人信息并选择报考科目。逾期不再补报。', category: '报名通知', pinned: true, status: 'published', publishAt: '2026-07-01T01:00:00.000Z', author: '考试中心' }, - { id: 'notice_2', title: '准考证下载与考场规则说明', summary: '准考证开放下载后,请使用 A4 纸打印并妥善保管。', content: '准考证下载时间为 7 月 20 日至 8 月 16 日。考生须携带身份证和纸质准考证入场,开考 15 分钟后不得进入考点。', category: '考试须知', pinned: false, status: 'published', publishAt: '2026-07-15T02:30:00.000Z', author: '考试中心' }, - { id: 'notice_3', title: '市第三中学考点交通提示', summary: '考试期间考点周边实行临时交通管制,请提前规划路线。', content: '建议考生至少提前 50 分钟到达考点。考点不提供停车位,请优先选择公共交通出行。', category: '考点公告', pinned: false, status: 'published', publishAt: '2026-07-18T06:00:00.000Z', author: '考务组' } - ], - exams: [ - { - id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。', - registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z', - examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z', - admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z', - location: '海州市各指定考点', status: 'published', createdAt: '2026-06-18T02:00:00.000Z', - subjects: [ - { id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30 }, - { id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30 }, - { id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 }, - { id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 }, - { id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30 }, - { id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25 }, - { id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25 } - ] - }, - { - id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。', - registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z', - examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z', - admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z', - location: '考点待公布', status: 'draft', createdAt: nowIso(), subjects: [] - } - ], - registrations: [ - { - id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'], - status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', - admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' } - } - ], - results: [ - { id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }, - { id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' } - ], - testCenters: [ - { id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', address: '海州市清河区学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() }, - { id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() } - ], - testRooms: [ - { id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, - { id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, - { id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatPlan: '按终端编号编排', roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' }, - { id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴编排', roomType: 'standard', status: 'active', notes: '' }, - { id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '无障碍席位优先编排', roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' } - ], - centerChangeRequests: [], - centerChangeRooms: [], - candidateAccountBatches: [], - candidateAccountBatchItems: [], - numberRules: [ - { id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [ - { id: 'segment_year', position: 1, type: 'year', value: '', width: 4 }, - { id: 'segment_school', position: 2, type: 'school_code', value: '', width: 0 }, - { id: 'segment_gender', position: 3, type: 'gender', value: '', width: 0 }, - { id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 } - ] } - ], - workflows: [ - { id: 'workflow_profile', businessType: 'profile_change', name: '考生信息修改审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ - { id: 'workflow_profile_step_1', position: 1, name: '学校学籍复核', adminLevel: 'school' }, - { id: 'workflow_profile_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } - ] }, - { id: 'workflow_registration', businessType: 'registration_review', name: '考试报名审核', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ - { id: 'workflow_registration_step_1', position: 1, name: '学校报名初审', adminLevel: 'school' }, - { id: 'workflow_registration_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } - ] }, - { id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ - { id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' } - ] }, - { id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ - { id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' } - ] } - ], - workflowInstances: [], - workflowActions: [], - auditLogs: [ - { id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' } - ] - }; -} - +const seedDatabase = () => createSeedDatabase({ nowIso, hashPassword }); const database = await createDatabase({ root, seed: seedDatabase }); const readDb = () => database.read(); -function sendJson(response, status, payload, headers = {}) { - response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }); - response.end(JSON.stringify(payload)); -} - -function sendError(response, status, message, details) { - sendJson(response, status, { ok: false, message, ...(details ? { details } : {}) }); -} - -async function readJson(request) { - const chunks = []; - let size = 0; - for await (const chunk of request) { - size += chunk.length; - if (size > 1024 * 1024) throw Object.assign(new Error('请求内容过大'), { status: 413 }); - chunks.push(chunk); - } - if (!chunks.length) return {}; - try { - return JSON.parse(Buffer.concat(chunks).toString('utf8')); - } catch { - throw Object.assign(new Error('请求数据格式不正确'), { status: 400 }); - } -} - -async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) { - const chunks = []; - let size = 0; - for await (const chunk of request) { - size += chunk.length; - if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 }); - chunks.push(chunk); - } - if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 }); - return Buffer.concat(chunks); -} - -function sendWorkbook(response, buffer, filename) { - response.writeHead(200, { - 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`, - 'Content-Length': buffer.length, - 'Cache-Control': 'no-store' - }); - response.end(buffer); -} - -function parseCookies(request) { - return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => { - const index = part.indexOf('='); - return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))]; - })); -} - -async function currentUser(request) { - const token = parseCookies(request).hz_session; - const session = token && sessions.get(token); - if (!session || session.expiresAt < Date.now()) { - if (token) sessions.delete(token); - return null; - } - const db = await readDb(); - const user = db.users.find(item => item.id === session.userId) || null; - return user?.active === false ? null : user; -} - -function safeUser(user) { - return { - id: user.id, - username: user.username, - role: user.role, - adminLevel: user.adminLevel || null, - schoolId: user.schoolId || null, - classId: user.classId || null, - displayName: user.displayName, - candidateNumber: user.candidateNumber || null, - mustChangePassword: Boolean(user.mustChangePassword) - }; -} - -async function requireUser(request, response, role) { - const user = await currentUser(request); - if (!user) { - sendError(response, 401, '请先登录'); - return null; - } - if (role && user.role !== role) { - sendError(response, 403, '当前账号无权执行此操作'); - return null; - } - return user; -} - -const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' }; - -const permissionsByLevel = { - super: ['*'], - school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], - class: ['dashboard.read', 'candidates.read', 'registrations.read', 'results.read'] -}; - -function hasPermission(user, permission) { - if (user?.role !== 'admin') return false; - const permissions = permissionsByLevel[user.adminLevel || 'super'] || []; - return permissions.includes('*') || permissions.includes(permission); -} - -function requirePermission(user, response, permission) { - if (hasPermission(user, permission)) return true; - sendError(response, 403, '当前管理员层级无权执行此操作'); - return false; -} - -function profileInScope(user, profile) { - if (user.adminLevel === 'super') return true; - if (user.adminLevel === 'school') return Boolean(user.schoolId && profile.schoolId === user.schoolId); - return Boolean(user.classId && profile.classId === user.classId); -} - -function registrationInScope(db, user, registration) { - const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - return Boolean(profile && profileInScope(user, profile)); -} - -function adminScopeLabel(db, user) { - if (user.adminLevel === 'super') return '全部学校与班级'; - const school = db.schools.find(item => item.id === user.schoolId)?.name || '未绑定学校'; - if (user.adminLevel === 'school') return school; - const schoolClass = db.classes.find(item => item.id === user.classId)?.name || '未绑定班级'; - return `${school} · ${schoolClass}`; -} +const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError }); +const requirePermission = createPermissionGuard(sendError); function adminsForStep(db, adminLevel, profile) { return db.users.filter(item => { @@ -690,840 +451,60 @@ function admitCardHtml(db, user, profile, registration) { return `${escapeHtml(exam.name)}准考证
衡准 · 准考证

${escapeHtml(exam.name)}

${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
考点${escapeHtml(registration.admitCard.testCenter)}
考场 / 座位${escapeHtml(registration.admitCard.room)} / ${escapeHtml(registration.admitCard.seat)}
${rows}
科目日期时间考场
考试须知:请携带本人有效身份证件及本准考证,至少提前 40 分钟到达考点。严禁携带手机、智能手表等通讯设备进入考场。
`; } -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; -} - -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; -} - -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, '考生功能接口不存在'); -} - -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, '管理功能接口不存在'); -} +const routeContext = { + 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 +}; +const handlePublic = createPublicRoutes(routeContext); +const handleAuth = createAuthRoutes(routeContext); +const handleCandidate = createCandidateRoutes(routeContext); +const handleAdmin = createAdminRoutes(routeContext); async function serveStatic(response, pathname) { const requestPath = pathname === '/' ? '/index.html' : pathname; diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs new file mode 100644 index 0000000..cf08fab --- /dev/null +++ b/src/client/admin-views.mjs @@ -0,0 +1,153 @@ +export function createAdminViews(context) { + const { + state, + app, + h, + formatDate, + dateRange, + badge, + money, + statusLabels, + icons, + api, + renderError, + emptyState, + brand, + portalShell, + loadingPanel, + adminNavForUser + } = context; + + async function renderAdmin(page) { + if (state.user?.role !== 'admin') return navigate('login'); + const meta = { + dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], + registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], + notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'], + results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'], + admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], + centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], + organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'], + 'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'], + flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], + 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], + 'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'] + }; + const allowedPages = adminNavForUser().map(item => item[0]); + if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; + app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); + try { + const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page; + const data = await api(`/api/admin/${endpoint}`); + state.pageData = data; + const content = { + dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations), + exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data), + admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data), + 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data) + }[page](); + app.innerHTML = portalShell('admin', page, content, ...meta[page]); + } catch (error) { renderError(error); } + } + + function adminDashboard(data) { + const m = data.metrics; + const canFlow = state.user.adminLevel !== 'class'; + return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
待处理流程${m.pendingFlows ?? 0}${canFlow ? '进入流程中心办理' : '班级账号只读'}
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

${canFlow ? '当前工作入口' : '本班查询入口'}

${h(data.scopeLabel)}
${canFlow ? `` : ''}

最近操作

系统审计日志
${data.logs.map(log => `
${h((log.actorName || '系').slice(0,1))}

${h(log.actorName || '系统')} · ${h(log.action)}${h(log.detail)}

`).join('') || '

当前账号暂无操作记录

'}
`; + } + + function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) { + return `
${h(label)} Excel使用系统模板可获得逐行校验
${template ? `` : ''}${importable ? `` : ''}
`; + } + + function adminSchoolOrganization(data) { + const classes = data.classes || []; + const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0); + return `
SCHOOL ORGANIZATION

${h(data.school?.name)}

班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。

班级
${classes.length}
班级管理员
${activeAdmins}
在册考生
${classes.reduce((sum, item) => sum + item.candidateCount, 0)}
${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}
${classes.map(item => `
${h(item.grade)}

${h(item.name)}

${badge(item.active ? 'approved' : 'closed')}
${item.candidateCount}在册考生${item.admins.length}管理员
班级管理员
${item.admins.map(admin => ``).join('') || '

尚未配置班级管理员

'}
`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}
`; + } + + function adminCandidates(candidates) { + const readOnly = state.user.adminLevel === 'class'; + return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}
${candidates.map(item => ``).join('')}
报名号 / 考生证件号码学校 / 班级账户进度更新时间资料状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.candidateNumber || '待分配')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}${h(item.workflow?.assignee?.displayName || '')}${formatDate(item.updatedAt,true)}${item.profileCompleted ? badge(item.status) : '未完成'}
`; + } + + function adminRegistrations(registrations) { + const readOnly = state.user.adminLevel === 'class'; + return `
${registrations.map(reg => ``).join('')}
考生考试 / 科目账户报名号当前流程缴费状态操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.registrationNumber || '待同步账户号码')}各次考试保持一致${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.status)}
`; + } + + function adminExams(exams) { + return `
${exams.map(exam => `
${h(exam.code)}${badge(exam.status)}

${h(exam.name)}

${h(exam.description)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点
${h(exam.location)}
${exam.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('') || '科目待配置'}
`).join('')}
`; + } + + function adminNotices(notices) { + return `

发布状态会实时同步到公开首页和考生中心。

${notices.map(notice => ``).join('')}
通知标题分类作者发布时间展示状态操作
${h(notice.title)}${h(notice.summary)}${h(notice.category)}${h(notice.author)}${formatDate(notice.publishAt || notice.createdAt,true)}${notice.pinned ? '首页置顶' : '普通'}${badge(notice.status)}
`; + } + + function adminAdmit(registrations) { + const approved = registrations.filter(reg => reg.status === 'approved'); + return `

仅已通过报名审核的考生可生成准考证。

${approved.map(reg => ``).join('') || ''}
考生考试报考科目准考证号考点 / 考场下载时间操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.phone)}
${h(reg.exam.name)}${h(reg.exam.code)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.admitCard?.number || '尚未生成')}${reg.admitCard ? `${h(reg.admitCard.testCenter)}${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号` : '—'}${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
暂无已通过的考试报名
`; + } + + function adminResults(data) { + const entry = state.user.adminLevel === 'super' ? `

录入单科成绩

保存后可立即发布
` : ''; + return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}
${entry}

${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}

${data.results.length} 条记录
${data.results.slice(0, 50).map(result => `
${h((result.candidateName || '?').slice(0,1))}

${h(result.candidateName)} · ${h(result.subjectName)}${h(result.examName)}

${h(result.score)}${badge(result.published ? 'published' : 'draft')}
`).join('') || '

还没有成绩记录

'}
`; + } + + function adminUsers(data) { + return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}

管理员层级决定可见范围和可执行操作;同一级可创建多名账号。

${data.admins.map(item => ``).join('')}
管理员登录账号层级绑定范围状态
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
`; + } + + function adminCenters(data) { + const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' }; + const cards = data.centers.map(center => `
${h(center.schoolName)} · ${h(center.code)}

${h(center.name)}

${center.pendingChange ? '变更审批中' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}
结构化考场${center.rooms.length}
启用席位${center.totalCapacity}
开放时间${h(center.gateOpenTime || '未设')}
详细地址
${h(center.address)}
考点负责人
${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}
应急电话
${h(center.emergencyPhone || '未填写')}
交通提示
${h(center.transport || '未填写')}
${center.rooms.map(room => ``).join('')}
考场位置类型容量座位编排状态
${h(room.name)}${h(room.code)}${h(room.building)} · ${h(room.floor || '楼层未填')}${h(roomTypeNames[room.roomType] || room.roomType)}${h(room.capacity)} 席${h(room.seatPlan || '按现场座次表编排')}${badge(room.status === 'active' ? 'approved' : 'closed')}
`).join(''); + const requests = data.changeRequests || []; + return `${excelToolbar('centers', { label: '考点考场档案' })}
正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requests.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
暂无考点变更申请
`; + } + + const numberSegmentMeta = { + year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], + sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字'] + }; + + function adminAccountBatches(data) { + const classes = data.classes || []; + const batches = data.batches || []; + const form = `
SCHOOL ACCOUNT REQUEST

按班级申领报名号

只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。

单批上限500个账户
${classes.map(item => ``).join('')}

审批通过后生成固定报名号、随机初始密码、待补录考生账户

`; + const ledger = batches.map(batch => { + const resultRows = batch.status === 'approved' ? `
账号下发清单${batch.totalCount} 个账号 · 考生首次登录必须改密
${batch.items.map((item, index) => ``).join('')}
序号班级固定报名号 / 账户初始密码
${index + 1}${h(item.className)}${h(item.candidateNumber)}${h(item.initialPassword)}
` : ''; + return `
${h(batch.id)}

${h(batch.schoolName)} · ${batch.totalCount} 个报名号

${formatDate(batch.createdAt, true)} 由 ${h(batch.requesterName)} 提交

${badge(batch.status)}
${batch.quotas.map(item => `${h(item.className)}${item.count} 人`).join('')}
当前进度${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}
${batch.reviewNote ? `
审批意见${h(batch.reviewNote)}
` : ''}${resultRows}
`; + }).join(''); + return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}
REQUEST LEDGER

申领批次与返回结果

结果只在最终批准后生成;报名号随后作为考生长期账户。

${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}
`; + } + + function adminNumberRules(data) { + const rule = data.activeRule || { name: '自定义报名号规则', separator: '-', segments: [] }; + const byType = Object.fromEntries(rule.segments.map(item => [item.type, item])); + const types = Object.keys(numberSegmentMeta); + return `
ONE CANDIDATE · ONE NUMBER超级管理员只设计号码规则

校级管理员按班级提交申领,流程最终批准后系统才创建长期考生账户。

账户报名号组成

规则用于最终审批后的批量建号;流水号为必选字段。

当前规则
${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return ``; }).join('')}
`; + } + + function adminFlowDesign(workflows) { + const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE', candidate_account_batch: 'ACCOUNT BATCH' }; + return `
${workflows.map(workflow => `
${h(codes[workflow.businessType] || workflow.businessType)}

${h(workflow.name)}

${workflow.steps.map(step => workflowStepEditor(step)).join('')}
`).join('')}
`; + } + + function workflowStepEditor(step = {}) { + return `
`; + } + + function adminFlows(data) { + const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' }; + const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领' }; + return `
${data.instances.map(instance => { + const isCenter = instance.businessType === 'center_change'; + const isBatch = instance.businessType === 'candidate_account_batch'; + const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : instance.candidateName; + const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`; + return `
${h(typeNames[instance.businessType] || instance.businessType)}

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
`; + }).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
`; + } + + return { renderAdmin }; +} diff --git a/src/client/api.mjs b/src/client/api.mjs new file mode 100644 index 0000000..531ad31 --- /dev/null +++ b/src/client/api.mjs @@ -0,0 +1,13 @@ +export async function api(path, options = {}) { + const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData; + const response = await fetch(path, { + credentials: 'same-origin', + headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, + ...options, + body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body + }); + const type = response.headers.get('content-type') || ''; + const data = type.includes('application/json') ? await response.json() : await response.text(); + if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试'); + return data; +} diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs new file mode 100644 index 0000000..841ce2c --- /dev/null +++ b/src/client/candidate-views.mjs @@ -0,0 +1,143 @@ +export function createCandidateViews(context) { + const { + state, + app, + h, + formatDate, + dateRange, + badge, + money, + statusLabels, + icons, + api, + renderError, + emptyState, + brand + } = context; + + const candidateNav = [ + ['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'], + ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'] + ]; + function adminNavForUser() { + const level = state.user?.adminLevel || 'super'; + const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; + if (level === 'class') return core; + const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; + if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]]; + return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]]; + } + + function portalShell(role, page, content, title, description) { + const nav = role === 'admin' ? adminNavForUser() : candidateNav; + const roleName = role === 'admin' ? '管理后台' : '考生中心'; + const adminTitle = statusLabels[state.user?.adminLevel] || '管理员'; + return `
${roleName}/${h(title)}
${role === 'admin' && state.user?.adminLevel !== 'class' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; + } + + function portalHeadingAction(role, page) { + if (role === 'admin' && page === 'notices') return ``; + if (role === 'admin' && page === 'exams') return ``; + if (role === 'admin' && page === 'admins') return ``; + if (role === 'admin' && page === 'centers') return ``; + if (role === 'admin' && page === 'organization') return ``; + if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')}`; + return ''; + } + + function loadingPanel() { + return `
正在读取数据
`; + } + + function onboardingShell(stage, content) { + const passwordDone = stage !== 'password'; + return `
FIRST SIGN-IN

${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}

${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}

${content}
`; + } + + function passwordOnboardingForm() { + return `
新密码要求至少 8 位,且不能与初始密码相同。
`; + } + + async function renderCandidate(page) { + if (state.user?.role !== 'candidate') return navigate('login'); + if (state.user.mustChangePassword) { + app.innerHTML = onboardingShell('password', passwordOnboardingForm()); + return; + } + if (!state.profile?.profileCompleted) { + try { + const data = await api('/api/candidate/profile'); + state.pageData = data; state.profile = data.profile; + app.innerHTML = onboardingShell('profile', candidateProfile(data, true)); + } catch (error) { renderError(error); } + return; + } + const meta = { + dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'], + profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'], + exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'], + registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'], + admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'], + results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'], + notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'] + }; + if (!meta[page]) page = 'dashboard'; + app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]); + try { + const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations'; + const data = page === 'notices' ? { notices: state.publicData.notices } : await api(`/api/candidate/${endpoint}`); + state.pageData = data; + if (data.profile) state.profile = data.profile; + const content = { + dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data), + registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations), + results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices) + }[page](); + app.innerHTML = portalShell('candidate', page, content, ...meta[page]); + } catch (error) { renderError(error); } + } + + function candidateDashboard(data) { + const registration = data.registrations[0]; + const steps = [ + ['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'], + ['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'], + ['考试报名', Boolean(registration), registration ? '已报名' : '未报名'], + ['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'], + ['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length} 科` : '待发布'] + ]; + return `
${new Date().getHours() < 12 ? '上午好' : '下午好'}

${h(data.profile?.name || state.user.displayName)},下一步已为你标出。

${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}


${icons.user}
个人资料${statusLabels[data.profile?.status] || '未填写'}
${badge(data.profile?.status || 'pending')}
${icons.exam}
已报名考试${data.registrations.length} 场
${icons.ticket}
可下载准考证${data.registrations.filter(item => item.admitCard).length} 份
${icons.chart}
已发布成绩${data.results.length} 科

我的应考进度

自动更新
${steps.map((step, index) => `
${step[1] ? '✓' : index + 1}
${step[0]}${step[2]}
`).join('')}

最近通知

${data.notices.map(notice => ``).join('')}
`; + } + + function candidateProfile(data, onboarding = false) { + const { profile, schools = [], classes = [], workflow } = data; + const step = workflow?.currentStepDetail; + const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber; + return `
${workflow ? `
当前审批${h(step?.name || statusLabels[workflow.status])}${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}
` : ''}
01

身份信息

姓名和证件号码须与有效证件完全一致。

02

学校与班级

学校和班级决定资料审批范围。

03

家庭与联系信息

用于考试通知、身份复核和紧急联系。

${profile?.reviewNote ? `
审核意见

${h(profile.reviewNote)}

` : ''}

${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}

`; + } + + function candidateExams(data) { + return `
${data.exams.map(exam => `
${h(exam.code)}${badge(exam.registrationState)}
${exam.registrationCount || 0} 人已报名

${h(exam.name)}

${h(exam.description)}

报名期限
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点安排
${h(exam.location)}
选择报考科目可多选
${exam.subjects.map(subject => ``).join('') || '

科目安排尚未发布

'}
${exam.registration ? `
${icons.check}已提交报名 · ${exam.registration.subjectIds.length} 个科目${badge(exam.registration.status)}
` : `
已选 0合计 ¥0.00
`}
`).join('')}
`; + } + + function candidateRegistrations(registrations) { + return registrations.length ? `
${registrations.map(reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.status)}
账户报名号
${h(reg.registrationNumber || state.user.candidateNumber)}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}
`).join('')}
` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); + } + + function candidateAdmit(registrations) { + const cards = registrations.filter(reg => reg.admitCard); + return cards.length ? `
${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
考点
${h(reg.admitCard.testCenter)}
考场 / 座位
${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
下载后请使用 A4 纸打印
`; }).join('')}
` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态'); + } + + function candidateResults(results) { + if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知'); + const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {}); + return `
${Object.entries(grouped).map(([examName, items]) => `
${h(items[0].examCode)}

${h(examName)}

发布时间 ${formatDate(items[0].publishedAt, true)}
${items.map(item => `
${h(item.subjectName)}${h(item.score)}${h(item.grade)}满分 150
`).join('')}
`).join('')}
`; + } + + function candidateNotices(notices) { + return `
${notices.map(notice => ``).join('')}
`; + } + + return { adminNavForUser, portalShell, loadingPanel, renderCandidate }; +} diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs new file mode 100644 index 0000000..fb48001 --- /dev/null +++ b/src/client/public-views.mjs @@ -0,0 +1,71 @@ +export function createPublicViews(context) { + const { + state, + app, + h, + formatDate, + dateRange, + badge, + money, + statusLabels, + icons, + api, + renderError, + emptyState + } = context; + + function brand() { + return `衡准EXAM SERVICE`; + } + + function publicHeader() { + return `
${brand()}
`; + } + + function renderHome() { + const { notices, exams, stats, organization } = state.publicData; + const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; + const topNotice = notices[0]; + app.innerHTML = `${publicHeader()}
+
+
+
最新

HAIZHOU EXAMINATION SERVICE

一个报名号,
贯穿每一次考试。

使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。

${state.user?.role === 'candidate' ? `` : state.publicData.selfRegistrationEnabled ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
+ ${featured ? renderHeroTicket(featured) : '
暂无开放考试
'} +
+
+

NOTICE BOARD

通知公告

报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
+

OPEN EXAMINATIONS

考试报名

登录后选择考试,并按实际需要勾选报考科目。

${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
+

SERVICE FLOW

报名号是唯一账户

报名号不会随考试改变,每场考试只新增一条报名记录。

${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
+
`; + } + + function renderHeroTicket(exam) { + const status = exam.registrationState; + return `
${badge(status)}${h(exam.code)}

UPCOMING EXAM

${h(exam.name)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考试地点
${h(exam.location)}
${exam.subjects.slice(0, 5).map(subject => `${h(subject.name)}`).join('')}${exam.subjects.length > 5 ? `+${exam.subjects.length - 5}` : ''}
报名人数${h(exam.registrationCount || 0)}
`; + } + + function renderNoticeRow(notice) { + return ``; + } + + function renderPublicExam(exam) { + return `
${h(exam.code)}${badge(exam.registrationState)}

${h(exam.name)}

${h(exam.description)}

报名${dateRange(exam.registrationStart, exam.registrationEnd)}考试${dateRange(exam.examStart, exam.examEnd)}
`; + } + + function renderAuth(kind) { + const login = kind === 'login'; + const selfRegistration = state.publicData.selfRegistrationEnabled; + app.innerHTML = `
${brand()}

CANDIDATE SERVICE

${login ? '凭一个号码,' : '自主申请,'}
${login ? '办理每一次考试。' : '领取固定报名号。'}

报名号就是考生账户,不因考试、科目或年度报名而改变。

首次登录顺序

修改初始密码 → 补全个人信息 → 等待资料审核。

${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}

${login ? '报名号登录' : '自主申请报名号'}

${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}

${login ? loginForm() : selfRegistration ? registerForm() : '
自主注册已关闭学校管理员会为考生创建账户并下发初始密码。
'}${login && selfRegistration ? `
还没有报名号?
` : !login ? '
已经有报名号?
' : ''}${login ? `
演示账号
` : ''}
`; + } + + function loginForm() { + return `
`; + } + + function registerForm() { + const schools = state.publicData.schools || []; + return `
`; + } + + return { brand, renderHome, renderAuth }; +} diff --git a/src/client/state.mjs b/src/client/state.mjs new file mode 100644 index 0000000..4ca8962 --- /dev/null +++ b/src/client/state.mjs @@ -0,0 +1,9 @@ +export const state = { + user: null, + profile: null, + publicData: { organization: {}, notices: [], exams: [], stats: {} }, + permissions: [], + scopeLabel: '', + pageData: null, + loading: false +}; diff --git a/src/client/ui.mjs b/src/client/ui.mjs new file mode 100644 index 0000000..d1d5b5a --- /dev/null +++ b/src/client/ui.mjs @@ -0,0 +1,45 @@ +export const statusLabels = { + pending: '待审核', approved: '已通过', rejected: '需修改', + published: '已发布', draft: '草稿', closed: '已结束', + open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费', + super: '超级管理员', school: '校级管理员', class: '班级管理员' +}; + +export const icons = { + home: '', + user: '', + exam: '', + ticket: '', + chart: '', + bell: '', + users: '', + check: '', + plus: '', + logout: '', + menu: '', + search: '', + arrow: '' +}; + +export function h(value) { + return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); +} + +export function formatDate(value, withTime = false) { + if (!value) return '待定'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return h(value); + return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); +} + +export function dateRange(start, end) { + return `${formatDate(start)} — ${formatDate(end)}`; +} + +export function badge(status) { + return `${h(statusLabels[status] || status)}`; +} + +export function money(value) { + return `¥${Number(value || 0).toFixed(2)}`; +} diff --git a/src/data/seed.mjs b/src/data/seed.mjs new file mode 100644 index 0000000..9c66498 --- /dev/null +++ b/src/data/seed.mjs @@ -0,0 +1,129 @@ +export function createSeedDatabase({ nowIso, hashPassword }) { + const adminId = 'usr_admin'; + const schoolAdminId = 'usr_school_admin'; + const schoolAdmin2Id = 'usr_school_admin_2'; + const candidateId = 'usr_demo'; + const examId = 'exam_autumn_2026'; + const registrationId = 'reg_demo_2026'; + return { + meta: { version: 6, createdAt: nowIso() }, + settings: { selfRegistrationEnabled: false }, + organization: { + name: '海州市教育考试中心', + code: 'HZ-EDU-032', + phone: '0518-8602 3158', + address: '海州市清河区文教路 18 号' + }, + schools: [ + { id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '海州市清河区学府路 8 号', active: true }, + { id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '海州市滨河区育才路 16 号', active: true } + ], + classes: [ + { id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true }, + { id: 'class_hz1_302', schoolId: 'school_hz1', name: '高三(2)班', grade: '高三', active: true }, + { id: 'class_hz3_301', schoolId: 'school_hz3', name: '高三(1)班', grade: '高三', active: true } + ], + users: [ + { id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() }, + { id: 'usr_supervisor', username: 'supervisor', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() }, + { id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() }, + { id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() }, + { id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() }, + { id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: true, createdAt: nowIso() } + ], + candidateProfiles: [ + { + id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821', + phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302', + address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000', + nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false, + status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z' + } + ], + notices: [ + { id: 'notice_1', title: '2026 年秋季统一考试报名安排', summary: '报名时间为 7 月 1 日至 7 月 31 日,请考生完成实名认证后选报科目。', content: '2026 年秋季统一考试报名现已开放。考生须在规定时间内登录平台,核对个人信息并选择报考科目。逾期不再补报。', category: '报名通知', pinned: true, status: 'published', publishAt: '2026-07-01T01:00:00.000Z', author: '考试中心' }, + { id: 'notice_2', title: '准考证下载与考场规则说明', summary: '准考证开放下载后,请使用 A4 纸打印并妥善保管。', content: '准考证下载时间为 7 月 20 日至 8 月 16 日。考生须携带身份证和纸质准考证入场,开考 15 分钟后不得进入考点。', category: '考试须知', pinned: false, status: 'published', publishAt: '2026-07-15T02:30:00.000Z', author: '考试中心' }, + { id: 'notice_3', title: '市第三中学考点交通提示', summary: '考试期间考点周边实行临时交通管制,请提前规划路线。', content: '建议考生至少提前 50 分钟到达考点。考点不提供停车位,请优先选择公共交通出行。', category: '考点公告', pinned: false, status: 'published', publishAt: '2026-07-18T06:00:00.000Z', author: '考务组' } + ], + exams: [ + { + id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。', + registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z', + examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z', + admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z', + location: '海州市各指定考点', status: 'published', createdAt: '2026-06-18T02:00:00.000Z', + subjects: [ + { id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30 }, + { id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30 }, + { id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 }, + { id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 }, + { id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30 }, + { id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25 }, + { id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25 } + ] + }, + { + id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。', + registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z', + examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z', + admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z', + location: '考点待公布', status: 'draft', createdAt: nowIso(), subjects: [] + } + ], + registrations: [ + { + id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'], + status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', + admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' } + } + ], + results: [ + { id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }, + { id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' } + ], + testCenters: [ + { id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', address: '海州市清河区学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() }, + { id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() } + ], + testRooms: [ + { id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatPlan: '按终端编号编排', roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' }, + { id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '无障碍席位优先编排', roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' } + ], + centerChangeRequests: [], + centerChangeRooms: [], + candidateAccountBatches: [], + candidateAccountBatchItems: [], + numberRules: [ + { id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [ + { id: 'segment_year', position: 1, type: 'year', value: '', width: 4 }, + { id: 'segment_school', position: 2, type: 'school_code', value: '', width: 0 }, + { id: 'segment_gender', position: 3, type: 'gender', value: '', width: 0 }, + { id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 } + ] } + ], + workflows: [ + { id: 'workflow_profile', businessType: 'profile_change', name: '考生信息修改审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_profile_step_1', position: 1, name: '学校学籍复核', adminLevel: 'school' }, + { id: 'workflow_profile_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } + ] }, + { id: 'workflow_registration', businessType: 'registration_review', name: '考试报名审核', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_registration_step_1', position: 1, name: '学校报名初审', adminLevel: 'school' }, + { id: 'workflow_registration_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } + ] }, + { id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' } + ] }, + { id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' } + ] } + ], + workflowInstances: [], + workflowActions: [], + auditLogs: [ + { id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' } + ] + }; +} diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs new file mode 100644 index 0000000..adad765 --- /dev/null +++ b/src/database/mysql-adapter.mjs @@ -0,0 +1,313 @@ +export function createMysqlAdapter(context) { + const { + mkdir, + dirname, + sqliteSchema, + mysqlSchema, + optional, + buildSeedOperations, + stateFromRows, + readSqliteRows, + readMysqlRows, + createRepository + } = context; + + async function createMysqlStore({ seed }) { + const { default: mysql } = await import('mysql2/promise'); + const connectionUrl = process.env.DATABASE_URL; + const database = process.env.MYSQL_DATABASE; + + if (!connectionUrl && (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !database)) { + throw new Error('MySQL 配置不完整:请设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE'); + } + + const pool = connectionUrl + ? mysql.createPool(connectionUrl) + : mysql.createPool({ + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT || 3306), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD || '', + database, + waitForConnections: true, + connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10), + charset: 'utf8mb4', + timezone: 'Z', + enableKeepAlive: true + }); + + for (const statement of mysqlSchema) await pool.execute(statement); + const mysqlColumnMigrations = [ + "ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_level ENUM('super', 'school', 'class') NULL", + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS candidate_number VARCHAR(120) NULL', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE', + 'ALTER TABLE schema_metadata ADD COLUMN IF NOT EXISTS self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS native_place VARCHAR(160) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS birth_date VARCHAR(20) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS ethnicity VARCHAR(60) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(100) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_phone VARCHAR(60) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE', + 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL', + 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS emergency_phone VARCHAR(60) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS gate_open_time VARCHAR(40) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL', + "ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'", + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL', + 'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', + 'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', + "ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL" + ]; + for (const statement of mysqlColumnMigrations) await pool.execute(statement); + const [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'"); + if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number'); + const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1'); + if (existing.length) { + const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1'); + if (Number(metadataRows[0]?.app_version || 1) < 2) { + const extension = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const school of extension.schools) await connection.execute( + 'INSERT IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', + [school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1] + ); + for (const schoolClass of extension.classes) await connection.execute( + 'INSERT IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', + [schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1] + ); + await connection.execute("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, TRUE) WHERE role = 'admin'"); + for (const user of extension.users.filter(item => item.role === 'admin')) await connection.execute( + `INSERT IGNORE INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`, + [user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt] + ); + for (const profile of extension.candidateProfiles) await connection.execute( + `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) + WHERE school = ? AND grade = ?`, + [optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade] + ); + const [centerRows] = await connection.execute('SELECT id FROM test_centers LIMIT 1'); + if (!centerRows.length) for (const center of extension.testCenters) await connection.execute( + 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + [center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt] + ); + const [ruleRows] = await connection.execute('SELECT id FROM number_rules LIMIT 1'); + if (!ruleRows.length) for (const rule of extension.numberRules) { + await connection.execute( + 'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt] + ); + for (const [index, segment] of rule.segments.entries()) await connection.execute( + 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)', + [segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)] + ); + } + const [workflowRows] = await connection.execute('SELECT id FROM workflow_definitions LIMIT 1'); + if (!workflowRows.length) for (const workflow of extension.workflows) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt] + ); + for (const [index, step] of workflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 3) { + const extension = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const center of extension.testCenters) await connection.execute( + `UPDATE test_centers SET + code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?), + manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?), + gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?), + status = COALESCE(status, 'active'), notes = COALESCE(notes, ?) + WHERE id = ?`, + [center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), + optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id] + ); + await connection.execute("UPDATE test_centers SET code = CONCAT('CENTER-', RIGHT(id, 8)) WHERE code IS NULL OR code = ''"); + const [roomRows] = await connection.execute('SELECT id FROM test_rooms LIMIT 1'); + if (!roomRows.length) for (const room of extension.testRooms) await connection.execute( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), + Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)] + ); + const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change'); + const [centerWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1"); + if (centerWorkflow && !centerWorkflowRows.length) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt] + ); + for (const [index, step] of centerWorkflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 4) { + const extension = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const user of extension.users.filter(item => item.role === 'candidate')) await connection.execute( + `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), + must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`, + [optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id] + ); + await connection.execute(`UPDATE users SET candidate_number = COALESCE( + (SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1), + CONCAT('CAND-', RIGHT(id, 10)) + ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`); + for (const profile of extension.candidateProfiles) await connection.execute( + `UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?), + ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?), + guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?`, + [optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), + optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id] + ); + await connection.execute(`UPDATE registrations JOIN users ON users.id = registrations.user_id + SET registrations.registration_number = users.candidate_number + WHERE registrations.registration_number IS NULL OR registrations.registration_number = ''`); + await connection.execute('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 5) { + const extension = seed(); + const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [batchWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1"); + if (batchWorkflow && !batchWorkflowRows.length) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt] + ); + for (const [index, step] of batchWorkflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 6) { + await pool.execute('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1'); + } + } + if (!existing.length) { + const initialState = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [insert] = await connection.execute(` + INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) + VALUES (1, 6, ?, ?, ?) + `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); + if (insert.affectedRows === 1) { + for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); + } + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + + const [centerCodeIndexes] = await pool.execute("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'"); + if (!centerCodeIndexes.length) { + await pool.execute('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)'); + } + const [candidateNumberIndexes] = await pool.execute("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'"); + if (!candidateNumberIndexes.length) { + await pool.execute('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)'); + } + + const transaction = async operations => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const item of operations) await connection.execute(item.sql, item.params); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; + const read = async () => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const state = stateFromRows(await readMysqlRows(connection)); + await connection.commit(); + return state; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; + return createRepository({ + client: 'mysql', + location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`, + read, + transaction, + close: async () => pool.end() + }); + } + + return createMysqlStore; +} diff --git a/src/database/schema.mjs b/src/database/schema.mjs new file mode 100644 index 0000000..f0804a2 --- /dev/null +++ b/src/database/schema.mjs @@ -0,0 +1,741 @@ +export const sqliteSchema = ` + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS schema_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + schema_version INTEGER NOT NULL DEFAULT 1, + app_version INTEGER NOT NULL DEFAULT 1, + self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1)), + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS organization ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL, + code TEXT NOT NULL, + phone TEXT NOT NULL, + address TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS schools ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + code TEXT NOT NULL UNIQUE, + address TEXT, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) + ) STRICT; + + CREATE TABLE IF NOT EXISTS school_classes ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name TEXT NOT NULL, + grade TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + UNIQUE (school_id, name) + ) STRICT; + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + candidate_number TEXT UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')), + admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')), + school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, + class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)), + display_name TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS candidate_profiles ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + gender TEXT, + id_number TEXT NOT NULL UNIQUE, + phone TEXT NOT NULL, + email TEXT, + school TEXT, + grade TEXT, + school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, + class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, + address TEXT, + emergency_contact TEXT, + emergency_phone TEXT, + native_place TEXT, + birth_date TEXT, + ethnicity TEXT, + postal_code TEXT, + guardian_name TEXT, + guardian_phone TEXT, + profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)), + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + review_note TEXT, + reviewed_at TEXT, + reviewer_id TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS notices ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + content TEXT NOT NULL, + category TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), + status TEXT NOT NULL CHECK (status IN ('draft', 'published')), + publish_at TEXT, + created_at TEXT, + author TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS exams ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL, + registration_start TEXT NOT NULL, + registration_end TEXT NOT NULL, + exam_start TEXT NOT NULL, + exam_end TEXT NOT NULL, + admit_download_start TEXT NOT NULL, + admit_download_end TEXT NOT NULL, + location TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')), + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS exam_subjects ( + id TEXT PRIMARY KEY, + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + name TEXT NOT NULL, + subject_date TEXT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + fee REAL NOT NULL DEFAULT 0, + position INTEGER NOT NULL, + UNIQUE (exam_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS registrations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')), + created_at TEXT NOT NULL, + reviewed_at TEXT, + review_note TEXT, + registration_number TEXT, + number_rule_id TEXT, + UNIQUE (user_id, exam_id) + ) STRICT; + + CREATE TABLE IF NOT EXISTS registration_subjects ( + registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, + PRIMARY KEY (registration_id, subject_id) + ) STRICT; + + CREATE TABLE IF NOT EXISTS admit_cards ( + registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE, + card_number TEXT NOT NULL UNIQUE, + test_center TEXT NOT NULL, + room TEXT NOT NULL, + seat TEXT NOT NULL, + generated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS results ( + id TEXT PRIMARY KEY, + registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, + score REAL NOT NULL CHECK (score >= 0 AND score <= 150), + grade TEXT NOT NULL, + published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), + updated_at TEXT, + published_at TEXT, + UNIQUE (registration_id, subject_id) + ) STRICT; + + CREATE TABLE IF NOT EXISTS test_centers ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + address TEXT NOT NULL, + contact TEXT, + manager_name TEXT, + manager_phone TEXT, + emergency_phone TEXT, + gate_open_time TEXT, + transport TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + notes TEXT, + rooms TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (school_id, name) + ) STRICT; + + CREATE TABLE IF NOT EXISTS test_rooms ( + id TEXT PRIMARY KEY, + center_id TEXT NOT NULL REFERENCES test_centers(id) ON DELETE CASCADE, + code TEXT NOT NULL, + name TEXT NOT NULL, + building TEXT NOT NULL, + floor TEXT, + capacity INTEGER NOT NULL CHECK (capacity > 0), + seat_plan TEXT, + seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0), + seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start), + room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + notes TEXT, + UNIQUE (center_id, code) + ) STRICT; + + CREATE TABLE IF NOT EXISTS center_change_requests ( + id TEXT PRIMARY KEY, + center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')), + code TEXT NOT NULL, + name TEXT NOT NULL, + address TEXT NOT NULL, + contact TEXT, + manager_name TEXT, + manager_phone TEXT, + emergency_phone TEXT, + gate_open_time TEXT, + transport TEXT, + center_status TEXT NOT NULL CHECK (center_status IN ('active', 'inactive')), + notes TEXT, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + review_note TEXT, + requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + reviewed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS center_change_rooms ( + id TEXT PRIMARY KEY, + request_id TEXT NOT NULL REFERENCES center_change_requests(id) ON DELETE CASCADE, + room_id TEXT, + code TEXT NOT NULL, + name TEXT NOT NULL, + building TEXT NOT NULL, + floor TEXT, + capacity INTEGER NOT NULL CHECK (capacity > 0), + seat_plan TEXT, + seat_start INTEGER NOT NULL DEFAULT 1, + seat_end INTEGER NOT NULL, + room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), + status TEXT NOT NULL CHECK (status IN ('active', 'inactive')), + notes TEXT, + UNIQUE (request_id, code) + ) STRICT; + + CREATE TABLE IF NOT EXISTS number_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + separator TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0, 1)), + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS number_rule_segments ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES number_rules(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('year', 'school_code', 'gender', 'sequence', 'literal')), + value TEXT, + width INTEGER NOT NULL DEFAULT 0, + UNIQUE (rule_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS candidate_account_batches ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + review_note TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES candidate_account_batches(id) ON DELETE CASCADE, + class_id TEXT NOT NULL REFERENCES school_classes(id) ON DELETE RESTRICT, + position INTEGER NOT NULL, + candidate_number TEXT UNIQUE, + initial_password TEXT, + user_id TEXT UNIQUE REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT, + UNIQUE (batch_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_definitions ( + id TEXT PRIMARY KEY, + business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')), + name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL, + UNIQUE (business_type, active) + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + name TEXT NOT NULL, + admin_level TEXT NOT NULL CHECK (admin_level IN ('school', 'super')), + UNIQUE (workflow_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_instances ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE RESTRICT, + business_type TEXT NOT NULL, + business_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + current_step INTEGER NOT NULL DEFAULT 1, + assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + completed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_actions ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE, + actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL CHECK (action IN ('submit', 'approve', 'reject', 'transfer', 'return', 'supervise')), + note TEXT, + from_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + to_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT; + + CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status); + CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status); + CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at); + CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end); + CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position); + CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status); + CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id); + CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type); + CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code); + CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code); + CREATE INDEX IF NOT EXISTS idx_center_changes_school ON center_change_requests(school_id, status, created_at); + CREATE INDEX IF NOT EXISTS idx_account_batches_school ON candidate_account_batches(school_id, status, created_at); + CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position); + CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); + CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); +`; + +export const mysqlSchema = [ + `CREATE TABLE IF NOT EXISTS schema_metadata ( + id TINYINT UNSIGNED NOT NULL, + schema_version INT UNSIGNED NOT NULL DEFAULT 1, + app_version INT UNSIGNED NOT NULL DEFAULT 1, + self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS organization ( + id TINYINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + code VARCHAR(60) NOT NULL, + phone VARCHAR(60) NOT NULL, + address VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_organization_singleton CHECK (id = 1) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS schools ( + id VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL, + address VARCHAR(255) NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY uq_schools_name (name), + UNIQUE KEY uq_schools_code (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS school_classes ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + grade VARCHAR(60) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY uq_classes_school_name (school_id, name), + CONSTRAINT fk_classes_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(64) NOT NULL, + username VARCHAR(100) NOT NULL, + candidate_number VARCHAR(120) NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('admin', 'candidate') NOT NULL, + admin_level ENUM('super', 'school', 'class') NULL, + school_id VARCHAR(64) NULL, + class_id VARCHAR(64) NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + must_change_password BOOLEAN NOT NULL DEFAULT FALSE, + display_name VARCHAR(100) NOT NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_users_username (username), + UNIQUE KEY uq_users_candidate_number (candidate_number), + KEY idx_users_admin_scope (role, admin_level, school_id, class_id), + CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, + CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS candidate_profiles ( + id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + gender VARCHAR(20) NULL, + id_number VARCHAR(60) NOT NULL, + phone VARCHAR(60) NOT NULL, + email VARCHAR(160) NULL, + school VARCHAR(160) NULL, + grade VARCHAR(100) NULL, + school_id VARCHAR(64) NULL, + class_id VARCHAR(64) NULL, + address VARCHAR(255) NULL, + emergency_contact VARCHAR(100) NULL, + emergency_phone VARCHAR(60) NULL, + native_place VARCHAR(160) NULL, + birth_date VARCHAR(20) NULL, + ethnicity VARCHAR(60) NULL, + postal_code VARCHAR(20) NULL, + guardian_name VARCHAR(100) NULL, + guardian_phone VARCHAR(60) NULL, + profile_completed BOOLEAN NOT NULL DEFAULT FALSE, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + review_note VARCHAR(500) NULL, + reviewed_at VARCHAR(35) NULL, + reviewer_id VARCHAR(64) NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_profiles_user (user_id), + UNIQUE KEY uq_profiles_id_number (id_number), + KEY idx_profiles_status (status), + CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_profiles_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, + CONSTRAINT fk_profiles_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS notices ( + id VARCHAR(64) NOT NULL, + title VARCHAR(240) NOT NULL, + summary VARCHAR(500) NOT NULL, + content TEXT NOT NULL, + category VARCHAR(60) NOT NULL, + pinned BOOLEAN NOT NULL DEFAULT FALSE, + status ENUM('draft', 'published') NOT NULL, + publish_at VARCHAR(35) NULL, + created_at VARCHAR(35) NULL, + author VARCHAR(100) NOT NULL, + PRIMARY KEY (id), + KEY idx_notices_status_publish (status, publish_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS exams ( + id VARCHAR(64) NOT NULL, + code VARCHAR(60) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + registration_start VARCHAR(35) NOT NULL, + registration_end VARCHAR(35) NOT NULL, + exam_start VARCHAR(35) NOT NULL, + exam_end VARCHAR(35) NOT NULL, + admit_download_start VARCHAR(35) NOT NULL, + admit_download_end VARCHAR(35) NOT NULL, + location VARCHAR(200) NOT NULL, + status ENUM('draft', 'published', 'closed') NOT NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_exams_code (code), + KEY idx_exams_status_registration (status, registration_start, registration_end) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS exam_subjects ( + id VARCHAR(64) NOT NULL, + exam_id VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + subject_date VARCHAR(35) NOT NULL, + start_time VARCHAR(20) NOT NULL, + end_time VARCHAR(20) NOT NULL, + fee DOUBLE NOT NULL DEFAULT 0, + position INT UNSIGNED NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_subjects_exam_position (exam_id, position), + KEY idx_subjects_exam (exam_id, position), + CONSTRAINT fk_subjects_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS registrations ( + id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + exam_id VARCHAR(64) NOT NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + payment_status ENUM('unpaid', 'paid', 'refunded') NOT NULL, + created_at VARCHAR(35) NOT NULL, + reviewed_at VARCHAR(35) NULL, + review_note VARCHAR(500) NULL, + registration_number VARCHAR(120) NULL, + number_rule_id VARCHAR(64) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_registrations_user_exam (user_id, exam_id), + KEY idx_registrations_status (status), + KEY idx_registrations_exam (exam_id), + CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS registration_subjects ( + registration_id VARCHAR(64) NOT NULL, + subject_id VARCHAR(64) NOT NULL, + PRIMARY KEY (registration_id, subject_id), + CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, + CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS admit_cards ( + registration_id VARCHAR(64) NOT NULL, + card_number VARCHAR(100) NOT NULL, + test_center VARCHAR(200) NOT NULL, + room VARCHAR(100) NOT NULL, + seat VARCHAR(30) NOT NULL, + generated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (registration_id), + UNIQUE KEY uq_admit_cards_number (card_number), + CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS results ( + id VARCHAR(64) NOT NULL, + registration_id VARCHAR(64) NOT NULL, + subject_id VARCHAR(64) NOT NULL, + score DOUBLE NOT NULL, + grade VARCHAR(20) NOT NULL, + published BOOLEAN NOT NULL DEFAULT FALSE, + updated_at VARCHAR(35) NULL, + published_at VARCHAR(35) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_results_registration_subject (registration_id, subject_id), + KEY idx_results_registration (registration_id, published), + CONSTRAINT chk_results_score CHECK (score >= 0 AND score <= 150), + CONSTRAINT fk_results_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, + CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS test_centers ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(200) NOT NULL, + address VARCHAR(255) NOT NULL, + contact VARCHAR(100) NULL, + manager_name VARCHAR(100) NULL, + manager_phone VARCHAR(60) NULL, + emergency_phone VARCHAR(60) NULL, + gate_open_time VARCHAR(40) NULL, + transport VARCHAR(500) NULL, + status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', + notes VARCHAR(1000) NULL, + rooms TEXT NOT NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_centers_code (code), + UNIQUE KEY uq_centers_school_name (school_id, name), + CONSTRAINT fk_centers_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS test_rooms ( + id VARCHAR(64) NOT NULL, + center_id VARCHAR(64) NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(120) NOT NULL, + building VARCHAR(120) NOT NULL, + floor VARCHAR(40) NULL, + capacity INT UNSIGNED NOT NULL, + seat_plan VARCHAR(500) NULL, + seat_start INT UNSIGNED NOT NULL DEFAULT 1, + seat_end INT UNSIGNED NOT NULL, + room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, + status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', + notes VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_rooms_center_code (center_id, code), + KEY idx_rooms_center (center_id, status, code), + CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS center_change_requests ( + id VARCHAR(64) NOT NULL, + center_id VARCHAR(64) NULL, + school_id VARCHAR(64) NOT NULL, + request_type ENUM('create', 'update') NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(200) NOT NULL, + address VARCHAR(255) NOT NULL, + contact VARCHAR(100) NULL, + manager_name VARCHAR(100) NULL, + manager_phone VARCHAR(60) NULL, + emergency_phone VARCHAR(60) NULL, + gate_open_time VARCHAR(40) NULL, + transport VARCHAR(500) NULL, + center_status ENUM('active', 'inactive') NOT NULL, + notes VARCHAR(1000) NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + review_note VARCHAR(500) NULL, + requested_by VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + reviewed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_center_changes_school (school_id, status, created_at), + CONSTRAINT fk_center_change_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL, + CONSTRAINT fk_center_change_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, + CONSTRAINT fk_center_change_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS center_change_rooms ( + id VARCHAR(64) NOT NULL, + request_id VARCHAR(64) NOT NULL, + room_id VARCHAR(64) NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(120) NOT NULL, + building VARCHAR(120) NOT NULL, + floor VARCHAR(40) NULL, + capacity INT UNSIGNED NOT NULL, + seat_plan VARCHAR(500) NULL, + seat_start INT UNSIGNED NOT NULL DEFAULT 1, + seat_end INT UNSIGNED NOT NULL, + room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, + status ENUM('active', 'inactive') NOT NULL, + notes VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_center_change_rooms_code (request_id, code), + CONSTRAINT fk_center_change_rooms_request FOREIGN KEY (request_id) REFERENCES center_change_requests(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS number_rules ( + id VARCHAR(64) NOT NULL, + name VARCHAR(120) NOT NULL, + separator VARCHAR(10) NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT FALSE, + created_by VARCHAR(64) NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_number_rules_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS number_rule_segments ( + id VARCHAR(64) NOT NULL, + rule_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + type ENUM('year', 'school_code', 'gender', 'sequence', 'literal') NOT NULL, + value VARCHAR(60) NULL, + width INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uq_rule_segments_position (rule_id, position), + CONSTRAINT fk_rule_segments_rule FOREIGN KEY (rule_id) REFERENCES number_rules(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS candidate_account_batches ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + requested_by VARCHAR(64) NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + review_note VARCHAR(500) NULL, + created_at VARCHAR(35) NOT NULL, + reviewed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_account_batches_school (school_id, status, created_at), + CONSTRAINT fk_account_batches_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, + CONSTRAINT fk_account_batches_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( + id VARCHAR(64) NOT NULL, + batch_id VARCHAR(64) NOT NULL, + class_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + candidate_number VARCHAR(120) NULL, + initial_password VARCHAR(120) NULL, + user_id VARCHAR(64) NULL, + created_at VARCHAR(35) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_account_batch_position (batch_id, position), + UNIQUE KEY uq_account_batch_number (candidate_number), + UNIQUE KEY uq_account_batch_user (user_id), + KEY idx_account_batch_items (batch_id, class_id, position), + CONSTRAINT fk_account_batch_items_batch FOREIGN KEY (batch_id) REFERENCES candidate_account_batches(id) ON DELETE CASCADE, + CONSTRAINT fk_account_batch_items_class FOREIGN KEY (class_id) REFERENCES school_classes(id), + CONSTRAINT fk_account_batch_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_definitions ( + id VARCHAR(64) NOT NULL, + business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL, + name VARCHAR(120) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + updated_by VARCHAR(64) NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_workflow_type_active (business_type, active), + CONSTRAINT fk_workflow_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_steps ( + id VARCHAR(64) NOT NULL, + workflow_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + admin_level ENUM('school', 'super') NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_workflow_steps_position (workflow_id, position), + CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_instances ( + id VARCHAR(64) NOT NULL, + workflow_id VARCHAR(64) NOT NULL, + business_type VARCHAR(40) NOT NULL, + business_id VARCHAR(64) NOT NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + current_step INT UNSIGNED NOT NULL DEFAULT 1, + assignee_id VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + completed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_workflow_inbox (status, assignee_id, business_type), + CONSTRAINT fk_workflow_instance_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id), + CONSTRAINT fk_workflow_instance_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_actions ( + id VARCHAR(64) NOT NULL, + instance_id VARCHAR(64) NOT NULL, + actor_id VARCHAR(64) NULL, + action ENUM('submit', 'approve', 'reject', 'transfer', 'return', 'supervise') NOT NULL, + note VARCHAR(500) NULL, + from_assignee_id VARCHAR(64) NULL, + to_assignee_id VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + KEY idx_workflow_actions_instance (instance_id, created_at), + CONSTRAINT fk_workflow_action_instance FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE, + CONSTRAINT fk_workflow_action_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS audit_logs ( + id VARCHAR(64) NOT NULL, + actor_id VARCHAR(64) NULL, + action VARCHAR(100) NOT NULL, + detail VARCHAR(1000) NOT NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + KEY idx_audit_created (created_at), + CONSTRAINT fk_audit_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci` +]; diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs new file mode 100644 index 0000000..0854292 --- /dev/null +++ b/src/database/sqlite-adapter.mjs @@ -0,0 +1,288 @@ +export function createSqliteAdapter(context) { + const { + mkdir, + dirname, + sqliteSchema, + mysqlSchema, + optional, + buildSeedOperations, + stateFromRows, + readSqliteRows, + readMysqlRows, + createRepository + } = context; + + async function createSqliteStore({ path, seed }) { + const { DatabaseSync } = await import('node:sqlite'); + await mkdir(dirname(path), { recursive: true }); + + const connection = new DatabaseSync(path, { timeout: 5000 }); + connection.exec('PRAGMA journal_mode = WAL;'); + connection.exec('PRAGMA synchronous = NORMAL;'); + const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + const ensureColumns = (table, columns) => { + if (!tableExists(table)) return; + const existing = new Set(connection.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name)); + for (const [name, definition] of columns) { + if (!existing.has(name)) connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } + }; + ensureColumns('users', [ + ['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'], + ['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'] + ]); + ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]); + ensureColumns('candidate_profiles', [ + ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'], + ['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'] + ]); + ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]); + ensureColumns('test_centers', [ + ['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'], + ['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT'] + ]); + ensureColumns('test_rooms', [['seat_plan', 'TEXT']]); + ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]); + if (tableExists('workflow_definitions')) { + const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || ''; + if (!definitionSql.includes('candidate_account_batch')) { + connection.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE workflow_definitions_v5 ( + id TEXT PRIMARY KEY, + business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')), + name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL, + UNIQUE (business_type, active) + ) STRICT; + INSERT INTO workflow_definitions_v5 (id, business_type, name, active, updated_by, updated_at) + SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions; + DROP TABLE workflow_definitions; + ALTER TABLE workflow_definitions_v5 RENAME TO workflow_definitions; + COMMIT; + PRAGMA foreign_keys = ON; + `); + } + } + if (tableExists('registrations')) { + const registrationsSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registrations'").get()?.sql || ''; + if (/registration_number\s+TEXT\s+UNIQUE/i.test(registrationsSql)) { + connection.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE registrations_v4 ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')), + created_at TEXT NOT NULL, + reviewed_at TEXT, + review_note TEXT, + registration_number TEXT, + number_rule_id TEXT, + UNIQUE (user_id, exam_id) + ) STRICT; + INSERT INTO registrations_v4 ( + id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id + ) SELECT id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id FROM registrations; + DROP TABLE registrations; + ALTER TABLE registrations_v4 RENAME TO registrations; + COMMIT; + PRAGMA foreign_keys = ON; + `); + } + } + connection.exec(sqliteSchema); + connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)'); + + const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(); + if (existingSystem && Number(existingSystem.app_version || 1) < 2) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const school of extension.schools) connection.prepare( + 'INSERT OR IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)' + ).run(school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1); + for (const schoolClass of extension.classes) connection.prepare( + 'INSERT OR IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)' + ).run(schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1); + connection.prepare("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, 1) WHERE role = 'admin'").run(); + for (const user of extension.users.filter(item => item.role === 'admin')) connection.prepare( + `INSERT OR IGNORE INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)` + ).run(user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt); + for (const profile of extension.candidateProfiles) connection.prepare( + `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) + WHERE school = ? AND grade = ?` + ).run(optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade); + if (!connection.prepare('SELECT id FROM test_centers LIMIT 1').get()) { + for (const center of extension.testCenters) connection.prepare( + 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt); + } + if (!connection.prepare('SELECT id FROM number_rules LIMIT 1').get()) { + for (const rule of extension.numberRules) { + connection.prepare('INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt); + rule.segments.forEach((segment, index) => connection.prepare( + 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)' + ).run(segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0))); + } + } + if (!connection.prepare('SELECT id FROM workflow_definitions LIMIT 1').get()) { + for (const workflow of extension.workflows) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt); + workflow.steps.forEach((step, index) => connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel)); + } + } + connection.prepare('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 3) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const center of extension.testCenters) connection.prepare( + `UPDATE test_centers SET + code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?), + manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?), + gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?), + status = COALESCE(status, 'active'), notes = COALESCE(notes, ?) + WHERE id = ?` + ).run(center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), + optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id); + connection.prepare("UPDATE test_centers SET code = 'CENTER-' || substr(id, -8) WHERE code IS NULL OR code = ''").run(); + if (!connection.prepare('SELECT id FROM test_rooms LIMIT 1').get()) { + for (const room of extension.testRooms) connection.prepare( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), + Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)); + } + const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change'); + if (centerWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1").get()) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt); + for (const [index, step] of centerWorkflow.steps.entries()) connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel); + } + connection.prepare('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 4) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const user of extension.users.filter(item => item.role === 'candidate')) connection.prepare( + `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), + must_change_password = COALESCE(must_change_password, ?) WHERE id = ?` + ).run(optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id); + connection.prepare(`UPDATE users SET candidate_number = COALESCE( + (SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1), + 'CAND-' || substr(id, -10) + ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`).run(); + for (const profile of extension.candidateProfiles) connection.prepare( + `UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?), + ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?), + guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?` + ).run(optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), + optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id); + connection.prepare(`UPDATE registrations SET registration_number = ( + SELECT candidate_number FROM users WHERE users.id = registrations.user_id + ) WHERE registration_number IS NULL OR registration_number = ''`).run(); + connection.prepare('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 5) { + const extension = seed(); + const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); + connection.exec('BEGIN IMMEDIATE'); + try { + if (batchWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1").get()) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt); + for (const [index, step] of batchWorkflow.steps.entries()) connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel); + } + connection.prepare('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 6) { + connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run(); + } + + if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { + const initialState = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + connection.prepare(` + INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) + VALUES (1, 6, ?, ?, ?) + `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); + for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + const transaction = async operations => { + connection.exec('BEGIN IMMEDIATE'); + try { + for (const item of operations) connection.prepare(item.sql).run(...item.params); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + throw error; + } + }; + return createRepository({ + client: 'sqlite', + location: path, + read: async () => stateFromRows(readSqliteRows(connection)), + transaction, + close: async () => connection.close() + }); + } + + return createSqliteStore; +} diff --git a/src/http/responses.mjs b/src/http/responses.mjs new file mode 100644 index 0000000..44429e7 --- /dev/null +++ b/src/http/responses.mjs @@ -0,0 +1,46 @@ +export function sendJson(response, status, payload, headers = {}) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }); + response.end(JSON.stringify(payload)); +} + +export function sendError(response, status, message, details) { + sendJson(response, status, { ok: false, message, ...(details ? { details } : {}) }); +} + +export async function readJson(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 1024 * 1024) throw Object.assign(new Error('请求内容过大'), { status: 413 }); + chunks.push(chunk); + } + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw Object.assign(new Error('请求数据格式不正确'), { status: 400 }); + } +} + +export async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 }); + chunks.push(chunk); + } + if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 }); + return Buffer.concat(chunks); +} + +export function sendWorkbook(response, buffer, filename) { + response.writeHead(200, { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`, + 'Content-Length': buffer.length, + 'Cache-Control': 'no-store' + }); + response.end(buffer); +} diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs new file mode 100644 index 0000000..849e28f --- /dev/null +++ b/src/routes/admin.routes.mjs @@ -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; +} diff --git a/src/routes/auth.routes.mjs b/src/routes/auth.routes.mjs new file mode 100644 index 0000000..c7dbe9a --- /dev/null +++ b/src/routes/auth.routes.mjs @@ -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; +} diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs new file mode 100644 index 0000000..604220c --- /dev/null +++ b/src/routes/candidate.routes.mjs @@ -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; +} diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs new file mode 100644 index 0000000..161e59c --- /dev/null +++ b/src/routes/public.routes.mjs @@ -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; +} diff --git a/src/security/authorization.mjs b/src/security/authorization.mjs new file mode 100644 index 0000000..2eaee90 --- /dev/null +++ b/src/security/authorization.mjs @@ -0,0 +1,40 @@ +export const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' }; + +export const permissionsByLevel = { + super: ['*'], + school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], + class: ['dashboard.read', 'candidates.read', 'registrations.read', 'results.read'] +}; + +export function hasPermission(user, permission) { + if (user?.role !== 'admin') return false; + const permissions = permissionsByLevel[user.adminLevel || 'super'] || []; + return permissions.includes('*') || permissions.includes(permission); +} + +export function createPermissionGuard(sendError) { + return function requirePermission(user, response, permission) { + if (hasPermission(user, permission)) return true; + sendError(response, 403, '当前管理员层级无权执行此操作'); + return false; + }; +} + +export function profileInScope(user, profile) { + if (user.adminLevel === 'super') return true; + if (user.adminLevel === 'school') return Boolean(user.schoolId && profile.schoolId === user.schoolId); + return Boolean(user.classId && profile.classId === user.classId); +} + +export function registrationInScope(db, user, registration) { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + return Boolean(profile && profileInScope(user, profile)); +} + +export function adminScopeLabel(db, user) { + if (user.adminLevel === 'super') return '全部学校与班级'; + const school = db.schools.find(item => item.id === user.schoolId)?.name || '未绑定学校'; + if (user.adminLevel === 'school') return school; + const schoolClass = db.classes.find(item => item.id === user.classId)?.name || '未绑定班级'; + return `${school} · ${schoolClass}`; +} diff --git a/src/security/session.mjs b/src/security/session.mjs new file mode 100644 index 0000000..a4a9513 --- /dev/null +++ b/src/security/session.mjs @@ -0,0 +1,49 @@ +export function createSessionManager({ sessions, readDb, sendError }) { + function parseCookies(request) { + return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => { + const index = part.indexOf('='); + return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))]; + })); + } + + async function currentUser(request) { + const token = parseCookies(request).hz_session; + const session = token && sessions.get(token); + if (!session || session.expiresAt < Date.now()) { + if (token) sessions.delete(token); + return null; + } + const db = await readDb(); + const user = db.users.find(item => item.id === session.userId) || null; + return user?.active === false ? null : user; + } + + function safeUser(user) { + return { + id: user.id, + username: user.username, + role: user.role, + adminLevel: user.adminLevel || null, + schoolId: user.schoolId || null, + classId: user.classId || null, + displayName: user.displayName, + candidateNumber: user.candidateNumber || null, + mustChangePassword: Boolean(user.mustChangePassword) + }; + } + + async function requireUser(request, response, role) { + const user = await currentUser(request); + if (!user) { + sendError(response, 401, '请先登录'); + return null; + } + if (role && user.role !== role) { + sendError(response, 403, '当前账号无权执行此操作'); + return null; + } + return user; + } + + return { parseCookies, currentUser, safeUser, requireUser }; +} diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 50e6aba..5b7fc9d 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -67,7 +67,18 @@ try { const sqliteFile = await readFile(testDb); assert.equal(sqliteFile.subarray(0, 16).toString(), 'SQLite format 3\0', '测试持久化文件必须是真实 SQLite 数据库'); - const appSource = await readFile(resolve(root, 'app.js'), 'utf8'); + const clientSources = await Promise.all([ + 'app.js', + 'src/client/admin-views.mjs', + 'src/client/candidate-views.mjs', + 'src/client/public-views.mjs' + ].map(file => readFile(resolve(root, file), 'utf8'))); + const appSource = clientSources.join('\n'); + for (const modulePath of ['/src/client/api.mjs', '/src/client/ui.mjs', '/src/client/public-views.mjs', '/src/client/candidate-views.mjs', '/src/client/admin-views.mjs']) { + const moduleResponse = await anonymous.request(modulePath); + assert.equal(moduleResponse.response.status, 200, `${modulePath} 应作为浏览器模块提供`); + assert.match(moduleResponse.response.headers.get('content-type') || '', /text\/javascript/, `${modulePath} 应返回 JavaScript MIME 类型`); + } assert.doesNotMatch(appSource, /onclick=["']event\.stopPropagation\(\)/, '弹窗不得截断内部按钮的委托点击事件'); assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭'); assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');