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 ``;
-}
-
-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()}
-
-
-
最新 ${h(topNotice?.title || '欢迎使用衡准考试服务平台')}
HAIZHOU EXAMINATION SERVICE
一个报名号,贯穿每一次考试。 使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。
${state.user?.role === 'candidate' ? `进入考生中心 ${icons.arrow} ` : state.publicData.selfRegistrationEnabled ? `申请报名号 ${icons.arrow} ` : `使用报名号登录 ${icons.arrow} `}查看开放考试
${h(stats.candidates || 0)} 在册考生
${h(stats.registrations || 0)} 报名记录
${h(stats.exams || 0)} 开放考试
- ${featured ? renderHeroTicket(featured) : '
暂无开放考试
'}
-
-
- 报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。
${topNotice ? `${h(topNotice.category)} ${h(topNotice.title)} ${h(topNotice.summary)}
${formatDate(topNotice.publishAt)} 阅读通知 ${icons.arrow} ` : '暂无通知
'} ${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
- ${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
- 报名号不会随考试改变,每场考试只新增一条报名记录。
${[['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)} ${status === 'open' ? '立即报名' : '查看详情'}
`;
-}
-
-function renderNoticeRow(notice) {
- return `${formatDate(notice.publishAt)} ${h(notice.category)} ${h(notice.title)} ${h(notice.summary)} ${icons.arrow} `;
-}
-
-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)}
${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名 ${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow} `;
-}
-
-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 ? `
演示账号 考生:2026-HZ01-F-0001 / Candidate123! 超级管理员:admin / Admin123! 校级管理员:school_admin / School123! 班级管理员:class_admin / Class123!
` : ''}
`;
-}
-
-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' ? `${icons.bell} ` : ''}${h((state.user?.displayName || '用').slice(0, 1))} ${h(state.user?.displayName)} ${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`} ${icons.logout}
${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}
${h(title)} ${h(description)}
${portalHeadingAction(role, page)}
${content} `;
-}
-
-function portalHeadingAction(role, page) {
- if (role === 'admin' && page === 'notices') return `${icons.plus} 发布通知 `;
- if (role === 'admin' && page === 'exams') return `${icons.plus} 创建考试 `;
- if (role === 'admin' && page === 'admins') return `${icons.plus} 添加管理员 `;
- if (role === 'admin' && page === 'centers') return `${icons.plus} 提交新考点 `;
- if (role === 'admin' && page === 'organization') return `${icons.plus} 新增班级 `;
- if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')} `;
- return '';
-}
-
-function loadingPanel() {
- return `正在读取数据
`;
-}
-
-function onboardingShell(stage, content) {
- const passwordDone = stage !== 'password';
- return `${brand()}固定报名号 ${h(state.user.candidateNumber)} 这个号码就是你的考生账户。以后参加不同考试,仍然使用同一个报名号。
${passwordDone ? '✓' : '1'} 修改初始密码 设置仅本人知道的新密码
2 补全个人信息 实名、籍贯、住址和学籍信息
3 等待资料审核 审核通过后开始考试报名
退出当前账户 FIRST SIGN-IN ${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'} ${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}
${content} `;
-}
-
-function passwordOnboardingForm() {
- return ``;
-}
-
-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 => `${formatDate(notice.publishAt)} ${h(notice.title)} `).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 ``;
-}
-
-function candidateExams(data) {
- return `${data.exams.map(exam => `
${h(exam.name)} ${h(exam.description)}
报名期限 ${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间 ${dateRange(exam.examStart, exam.examEnd)}
考点安排 ${h(exam.location)} `).join('')}
`;
-}
-
-function candidateRegistrations(registrations) {
- return registrations.length ? `${registrations.map(reg => `
账户报名号 ${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 ${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'} 下载后请使用 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]) => `
${items.map(item => `
${h(item.subjectName)} ${h(item.score)} ${h(item.grade)} 满分 150 `).join('')}
`).join('')}
`;
-}
-
-function candidateNotices(notices) {
- return `${notices.map(notice => `${new Date(notice.publishAt).getDate()} ${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})} ${h(notice.category)} ${h(notice.title)} ${h(notice.summary)} ${notice.pinned ? '置顶 ' : ''}${icons.arrow} `).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)} ${m.pendingCandidates} ${state.user.adminLevel === 'class' ? '查看本班考生' : '考生资料流程'} 身份、学籍与联系方式 ${icons.arrow}${m.pendingRegistrations} ${state.user.adminLevel === 'class' ? '查看报名状态' : '考试报名流程'} 考试、科目和报名号 ${icons.arrow} ${canFlow ? `${m.pendingFlows ?? 0} 流程中心 处理、转交与监督审批 ${icons.arrow} ` : ''}成 ${state.user.adminLevel === 'super' ? '录入与发布成绩' : '查看范围内成绩'} 成绩可见范围由权限控制 ${icons.arrow}
最近操作 系统审计日志 ${data.logs.map(log => `${h((log.actorName || '系').slice(0,1))} ${h(log.actorName || '系统')} · ${h(log.action)} ${h(log.detail)}
${formatDate(log.createdAt,true)} `).join('') || '当前账号暂无操作记录
'} `;
-}
-
-function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) {
- return ``;
-}
-
-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 => `${item.candidateCount} 在册考生 ${item.admins.length} 管理员
班级管理员 添加管理员
${item.admins.map(admin => `${h(admin.displayName.slice(0,1))} ${h(admin.displayName)} ${h(admin.username)} ${badge(admin.active ? 'approved' : 'closed')} `).join('') || '尚未配置班级管理员
'}编辑班级 ${item.active ? '停用班级' : '重新启用'} `).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')} `;
-}
-
-function adminCandidates(candidates) {
- const readOnly = state.user.adminLevel === 'class';
- return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}`;
-}
-
-function adminRegistrations(registrations) {
- const readOnly = state.user.adminLevel === 'class';
- return ``;
-}
-
-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 ``;
-}
-
-function adminAdmit(registrations) {
- const approved = registrations.filter(reg => reg.status === 'approved');
- return ``;
-}
-
-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 ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}
`;
-}
-
-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 => `${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('')}
${h(center.notes || '无补充说明')} 更新于 ${formatDate(center.updatedAt, true)} `).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('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}
考点变更台账 新增和修改均保留申请快照,审批通过后才更新正式档案。
进入流程中心 `;
-}
-
-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 个账户
`;
- const ledger = batches.map(batch => {
- const resultRows = batch.status === 'approved' ? `` : '';
- return `${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 超级管理员只设计号码规则 校级管理员按班级提交申领,流程最终批准后系统才创建长期考生账户。
账户报名号组成 规则用于最终审批后的批量建号;流水号为必选字段。
当前规则 `;
-}
-
-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 => `
`).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])}
${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'} 查看与处理 `;
- }).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
`;
-}
-
function emptyState(title, description, route, action) {
return `${icons.ticket} ${h(title)} ${h(description)}
${route ? `${h(action)} ` : ''} `;
}
@@ -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)}准考证 考试须知:请携带本人有效身份证件及本准考证,至少提前 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)} ${m.pendingCandidates} ${state.user.adminLevel === 'class' ? '查看本班考生' : '考生资料流程'} 身份、学籍与联系方式 ${icons.arrow}${m.pendingRegistrations} ${state.user.adminLevel === 'class' ? '查看报名状态' : '考试报名流程'} 考试、科目和报名号 ${icons.arrow} ${canFlow ? `${m.pendingFlows ?? 0} 流程中心 处理、转交与监督审批 ${icons.arrow} ` : ''}成 ${state.user.adminLevel === 'super' ? '录入与发布成绩' : '查看范围内成绩'} 成绩可见范围由权限控制 ${icons.arrow}
最近操作 系统审计日志 ${data.logs.map(log => `${h((log.actorName || '系').slice(0,1))} ${h(log.actorName || '系统')} · ${h(log.action)} ${h(log.detail)}
${formatDate(log.createdAt,true)} `).join('') || '当前账号暂无操作记录
'} `;
+ }
+
+ function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) {
+ return ``;
+ }
+
+ 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 => `${item.candidateCount} 在册考生 ${item.admins.length} 管理员
班级管理员 添加管理员
${item.admins.map(admin => `${h(admin.displayName.slice(0,1))} ${h(admin.displayName)} ${h(admin.username)} ${badge(admin.active ? 'approved' : 'closed')} `).join('') || '尚未配置班级管理员
'}编辑班级 ${item.active ? '停用班级' : '重新启用'} `).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')} `;
+ }
+
+ function adminCandidates(candidates) {
+ const readOnly = state.user.adminLevel === 'class';
+ return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}`;
+ }
+
+ function adminRegistrations(registrations) {
+ const readOnly = state.user.adminLevel === 'class';
+ return ``;
+ }
+
+ 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 ``;
+ }
+
+ function adminAdmit(registrations) {
+ const approved = registrations.filter(reg => reg.status === 'approved');
+ return ``;
+ }
+
+ 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 ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}
`;
+ }
+
+ 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 => `${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('')}
${h(center.notes || '无补充说明')} 更新于 ${formatDate(center.updatedAt, true)} `).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('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}
考点变更台账 新增和修改均保留申请快照,审批通过后才更新正式档案。
进入流程中心 `;
+ }
+
+ 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 个账户
`;
+ const ledger = batches.map(batch => {
+ const resultRows = batch.status === 'approved' ? `` : '';
+ return `${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 超级管理员只设计号码规则 校级管理员按班级提交申领,流程最终批准后系统才创建长期考生账户。
账户报名号组成 规则用于最终审批后的批量建号;流水号为必选字段。
当前规则 `;
+ }
+
+ 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 => `
`).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])}
${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'} 查看与处理 `;
+ }).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' ? `${icons.bell} ` : ''}${h((state.user?.displayName || '用').slice(0, 1))} ${h(state.user?.displayName)} ${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`} ${icons.logout}
${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}
${h(title)} ${h(description)}
${portalHeadingAction(role, page)}
${content} `;
+ }
+
+ function portalHeadingAction(role, page) {
+ if (role === 'admin' && page === 'notices') return `${icons.plus} 发布通知 `;
+ if (role === 'admin' && page === 'exams') return `${icons.plus} 创建考试 `;
+ if (role === 'admin' && page === 'admins') return `${icons.plus} 添加管理员 `;
+ if (role === 'admin' && page === 'centers') return `${icons.plus} 提交新考点 `;
+ if (role === 'admin' && page === 'organization') return `${icons.plus} 新增班级 `;
+ if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')} `;
+ return '';
+ }
+
+ function loadingPanel() {
+ return `正在读取数据
`;
+ }
+
+ function onboardingShell(stage, content) {
+ const passwordDone = stage !== 'password';
+ return `${brand()}固定报名号 ${h(state.user.candidateNumber)} 这个号码就是你的考生账户。以后参加不同考试,仍然使用同一个报名号。
${passwordDone ? '✓' : '1'} 修改初始密码 设置仅本人知道的新密码
2 补全个人信息 实名、籍贯、住址和学籍信息
3 等待资料审核 审核通过后开始考试报名
退出当前账户 FIRST SIGN-IN ${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'} ${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}
${content} `;
+ }
+
+ function passwordOnboardingForm() {
+ return ``;
+ }
+
+ 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 => `${formatDate(notice.publishAt)} ${h(notice.title)} `).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 ``;
+ }
+
+ function candidateExams(data) {
+ return `${data.exams.map(exam => `
${h(exam.name)} ${h(exam.description)}
报名期限 ${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间 ${dateRange(exam.examStart, exam.examEnd)}
考点安排 ${h(exam.location)} `).join('')}
`;
+ }
+
+ function candidateRegistrations(registrations) {
+ return registrations.length ? `${registrations.map(reg => `
账户报名号 ${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 ${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'} 下载后请使用 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]) => `
${items.map(item => `
${h(item.subjectName)} ${h(item.score)} ${h(item.grade)} 满分 150 `).join('')}
`).join('')}
`;
+ }
+
+ function candidateNotices(notices) {
+ return `${notices.map(notice => `${new Date(notice.publishAt).getDate()} ${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})} ${h(notice.category)} ${h(notice.title)} ${h(notice.summary)} ${notice.pinned ? '置顶 ' : ''}${icons.arrow} `).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 ``;
+ }
+
+ 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()}
+
+
+
最新 ${h(topNotice?.title || '欢迎使用衡准考试服务平台')}
HAIZHOU EXAMINATION SERVICE
一个报名号,贯穿每一次考试。 使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。
${state.user?.role === 'candidate' ? `进入考生中心 ${icons.arrow} ` : state.publicData.selfRegistrationEnabled ? `申请报名号 ${icons.arrow} ` : `使用报名号登录 ${icons.arrow} `}查看开放考试
${h(stats.candidates || 0)} 在册考生
${h(stats.registrations || 0)} 报名记录
${h(stats.exams || 0)} 开放考试
+ ${featured ? renderHeroTicket(featured) : '
暂无开放考试
'}
+
+
+ 报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。
${topNotice ? `${h(topNotice.category)} ${h(topNotice.title)} ${h(topNotice.summary)}
${formatDate(topNotice.publishAt)} 阅读通知 ${icons.arrow} ` : '暂无通知
'} ${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
+ ${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
+ 报名号不会随考试改变,每场考试只新增一条报名记录。
${[['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)} ${status === 'open' ? '立即报名' : '查看详情'}
`;
+ }
+
+ function renderNoticeRow(notice) {
+ return `${formatDate(notice.publishAt)} ${h(notice.category)} ${h(notice.title)} ${h(notice.summary)} ${icons.arrow} `;
+ }
+
+ 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)}
${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名 ${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow} `;
+ }
+
+ 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 ? `
演示账号 考生:2026-HZ01-F-0001 / Candidate123! 超级管理员:admin / Admin123! 校级管理员:school_admin / School123! 班级管理员:class_admin / Class123!
` : ''}
`;
+ }
+
+ 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"/, '考试草稿应提供编辑入口');