已完成迁移验收和清理。

删除全部 Node.js 后端、.mjs 测试、辅助脚本、package*.json、node_modules 和迁移对照工具。
浏览器前端保留并迁入 [Eis.Web/wwwroot](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/wwwroot/index.html),统一改为 .js 模块。
修复一个隐藏遗漏:.NET 仍依赖 schema.mjs,且 MySQL 结构解析可能被转义反引号截断。现已改为原生 [SQLite SQL](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/Schema.sqlite.sql) 和 [MySQL SQL](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/Schema.mysql.sql) 资源。
更新了 [DatabaseInitializer.cs](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/DatabaseInitializer.cs)、项目配置、README 和迁移文档。
data 目录和现有数据库未修改;测试仅使用系统临时 SQLite 数据库。
This commit is contained in:
2026-07-23 17:49:57 +08:00 Unverified
parent f3ffc18aae
commit b139834396
79 changed files with 695 additions and 18757 deletions
-2
View File
@@ -1,7 +1,6 @@
.git
.agents
.codex
node_modules
tests
.env
@@ -13,7 +12,6 @@ data/*
!data/.gitkeep
*.log
npm-debug.log*
Dockerfile*
compose*.yml
compose*.yaml
-3
View File
@@ -1,9 +1,6 @@
data/db.json
data/test-db.json
data/*.sqlite
data/*.sqlite-shm
data/*.sqlite-wal
node_modules/
.env
.env.docker
*.log
+6 -3
View File
@@ -1,6 +1,6 @@
# ASP.NET Core 10 迁移
系统业务 API、认证、管理后台、审批流、考务编排、招生录取、Excel、文书和缓存均已迁移到 ASP.NET Core 10。生产宿主和容器不再启动或转发到 Node.js;旧 `.mjs` 服务端源码仅保留作历史行为对照
系统业务 API、认证、管理后台、审批流、考务编排、招生录取、Excel、文书和缓存均已迁移到 ASP.NET Core 10。生产宿主和容器只运行 .NET;旧 Node.js 服务端源码、依赖、测试和迁移对照工具已从仓库移除
## 完成情况
@@ -11,8 +11,11 @@
- [x] 指标资格、招生账户、志愿、计划、投档、录取、报到和退档
- [x] ClosedXML 文件导入导出、文书模板和 Redis/本机缓存
- [x] SQLite / MySQL 自动建库和基础配置初始化
- [x] SQLite / MySQL 结构改为 .NET 内嵌原生 SQL 资源
- [x] 可发布的 .NET 数据库重建与 1200 人演示数据导入工具
- [x] 容器入口切换及 Node.js 后端运行依赖移除
- [x] 浏览器静态资源归档到 `src/Eis.Web/wwwroot`
- [x] Node.js 源码、依赖和测试清理
## 本地运行
@@ -36,7 +39,7 @@ dotnet run --project .\src\Eis.Web\Eis.Web.csproj
- ClosedXML 处理班级、管理员、报名号、考生、缴费、考点考场、成绩、志愿、录取、报到和正式录取名册工作簿。
- 成绩导入先预检再确认,报到导入按招生学校和可维护批次限定范围。
- 录取通知书模板由原生接口保存;成绩单、准考证和录取通知书的数据及防伪验真均由 ASP.NET Core 提供。
- CKEditor 浏览器资源已随 Web 项目发布,不依赖 `node_modules`
- CKEditor 和全部浏览器资源均位于 `src/Eis.Web/wwwroot`,发布时由 Web SDK 自动收集
## 验证
@@ -48,4 +51,4 @@ pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\smoke-dotnet-native.
docker compose up --build --detach
```
`Eis.Tools.dll` 提供 `database init/reset/seed`;破坏性命令要求 `--confirm-target`,SQLite 自动保留原库备份,MySQL 拒绝系统库和未显式强制覆盖的非演示业务数据。`scripts/smoke-dotnet-native.ps1` 会发布并启动纯 .NET 产物,在操作系统临时目录创建一次性 SQLite 数据库,检查健康状态、首页、CKEditor、原生登录和未知 API 404 后清理。`scripts/smoke-dotnet-migration.ps1` 保留为迁移期 Node/.NET 行为对照工具,不属于生产启动链路。
`Eis.Tools.dll` 提供 `database init/reset/seed`;破坏性命令要求 `--confirm-target`,SQLite 自动保留原库备份,MySQL 拒绝系统库和未显式强制覆盖的非演示业务数据。`scripts/smoke-dotnet-native.ps1` 会发布并启动纯 .NET 产物,在操作系统临时目录创建一次性 SQLite 数据库,检查健康状态、首页、CKEditor、原生登录和未知 API 404 后清理。
+4 -7
View File
@@ -360,7 +360,7 @@ dotnet test .\Eis.slnx
pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\smoke-dotnet-native.ps1
```
xUnit 测试使用独立临时 SQLite 数据库,覆盖认证兼容性、迁移开关约束、文书防伪码、文件处理、缓存以及空库 v20 初始化;测试不会连接或修改生产数据库。
xUnit 测试使用独立临时 SQLite 数据库,覆盖认证与历史数据兼容性、端点启用约束、文书防伪码、文件处理、缓存以及空库 v20 初始化;测试不会连接或修改生产数据库。
## 项目结构
@@ -372,15 +372,12 @@ src/Eis.Domain 领域模型与业务值
src/Eis.Application 应用服务契约
src/Eis.Infrastructure SQLite/MySQL、认证、缓存、Excel 与业务实现
src/Eis.Tools 可独立发布的数据库初始化、重建和演示数据工具
src/Eis.Web ASP.NET Core 宿主、端点和发布静态资源
src/Eis.Web ASP.NET Core 宿主、端点和静态资源
src/Eis.Web/wwwroot 浏览器入口、样式、前端模块及 CKEditor
tests/Eis.Infrastructure.Tests xUnit 自动化测试
index.html / styles.css / app.js 前端入口、样式和控制器
src/client 公共、考生、管理及招生前端模块
src/database/schema.mjs 兼容 SQLite/MySQL 的 v20 建库定义
scripts/publish.ps1 Web 与数据库工具统一发布脚本
scripts 迁移验证及演示数据包维护工具
server.mjs / src/routes 仅保留的旧 Node 行为对照源码
scripts/smoke-dotnet-native.ps1 纯 .NET 发布产物烟测
data/exam.sqlite 本地运行后生成的 SQLite 数据库
.env.example 开发与生产环境变量模板
```
-1596
View File
File diff suppressed because it is too large Load Diff
-401
View File
@@ -1,401 +0,0 @@
import ExcelJS from 'exceljs';
const resourceSpecs = {
classes: {
title: '班级台账', sheet: '班级',
columns: [
['schoolCode', '学校代码*', 16, 'HZ01'], ['grade', '年级*', 14, '高三'],
['name', '班级名称*', 22, '高三(3)班'], ['status', '状态*', 12, '启用']
],
validations: { status: ['启用', '停用'] }
},
class_admins: {
title: '班级管理员台账', sheet: '班级管理员',
columns: [
['schoolCode', '学校代码*', 16, 'HZ01'], ['className', '班级名称*', 22, '高三(1)班'],
['displayName', '管理员姓名*', 18, '张老师'], ['username', '登录账号*', 20, 'hz01_g301'],
['initialPassword', '初始密码(新建必填)', 24, 'ChangeMe123!'], ['status', '状态*', 12, '启用']
],
validations: { status: ['启用', '停用'] }
},
account_quotas: {
title: '批量报名号申领配额', sheet: '班级配额',
columns: [['className', '班级名称*', 24, '高三(1)班'], ['count', '申领数量*', 16, 30]],
numberColumns: ['count']
},
account_results: {
title: '报名号下发清单', sheet: '账号结果',
columns: [
['batchId', '批次编号', 30, ''], ['className', '班级', 22, ''],
['candidateNumber', '固定报名号', 26, ''], ['initialPassword', '初始密码', 22, '']
]
},
candidates: {
title: '考生资料台账', sheet: '考生资料',
columns: [
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['name', '姓名*', 16, '李明'],
['gender', '性别*', 10, '男'], ['idNumber', '证件号码*', 24, '320101200801011234'],
['phone', '手机号*', 18, '13800138000'], ['email', '邮箱', 24, 'student@example.com'],
['nativePlace', '籍贯', 18, '江苏海州'],
['provinceCode', '省级代码*', 14, '320000'], ['provinceName', '省份', 18, '江苏省'],
['cityCode', '市级代码*', 14, '320700'], ['cityName', '城市', 18, '连云港市'],
['districtCode', '区县代码*', 14, '320706'], ['districtName', '区县', 18, '海州区'],
['address', '详细住址*', 32, '示例路 1 号'],
['className', '班级*', 22, '高三(1)班'], ['ethnicity', '民族', 12, '汉族'],
['birthDate', '出生日期', 16, '2008-01-01'], ['postalCode', '邮编', 14, '222000'],
['guardianName', '监护人', 16, '李家长'], ['guardianPhone', '监护人电话', 18, '13900139000']
],
validations: { gender: ['男', '女'] }
},
payments: {
title: '考试缴费名单', sheet: '缴费名单',
columns: [
['examCode', '考试代码', 18, ''], ['examName', '考试名称', 28, ''],
['schoolName', '学校', 24, ''], ['className', '班级', 20, ''],
['candidateNumber', '报名号', 26, ''], ['candidateName', '考生姓名', 16, ''],
['subjectNames', '报考科目', 34, ''], ['amountDue', '应缴金额(元)', 18, ''],
['paymentStatus', '缴费状态', 14, ''], ['paidAt', '确认时间', 24, ''],
['paidByName', '确认人', 16, '']
],
numberColumns: ['amountDue'],
numberFormats: { amountDue: '0.00' }
},
centers: {
title: '考点考场档案', sheet: '考点考场',
columns: [
['schoolCode', '学校代码*', 14, 'HZ01'], ['centerCode', '考点代码*', 18, 'HZ01-C02'],
['centerName', '考点名称*', 24, '第一中学东区考点'],
['provinceCode', '省级代码*', 14, '320000'], ['provinceName', '省份', 18, '江苏省'],
['cityCode', '市级代码*', 14, '320700'], ['cityName', '城市', 18, '连云港市'],
['districtCode', '区县代码*', 14, '320706'], ['districtName', '区县', 18, '海州区'],
['address', '详细地址*', 30, '示例路 8 号'],
['managerName', '负责人', 16, '王老师'], ['managerPhone', '负责人手机', 18, '13800138000'],
['contact', '值班电话', 18, '0518-86020000'], ['emergencyPhone', '应急电话', 18, '0518-120'],
['gateOpenTime', '开放时间', 14, '07:00'], ['transport', '交通提示', 30, '东门入场'],
['centerStatus', '考点状态*', 14, '启用'], ['centerNotes', '考点备注', 26, ''],
['roomCode', '考场代码*', 16, '001'], ['roomName', '考场名称*', 22, '第 001 考场'],
['building', '楼栋*', 18, '教学楼 A'], ['floor', '楼层', 12, '1 层'],
['capacity', '容量*', 12, 30], ['seatPlan', '座位编排说明', 28, '按教室现场座次表编排'],
['roomType', '考场类型*', 16, '标准考场'], ['roomStatus', '考场状态*', 14, '启用'],
['roomNotes', '考场备注', 26, '']
],
validations: { centerStatus: ['启用', '停用'], roomStatus: ['启用', '停用'], roomType: ['标准考场', '机考考场', '无障碍考场', '备用考场'] },
numberColumns: ['capacity']
},
results: {
title: '考试成绩台账', sheet: '成绩',
columns: [
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['cardNumber', '准考证号(只读参考)', 24, ''],
['candidateName', '姓名(只读参考)', 16, ''], ['schoolName', '学校(只读参考)', 24, ''], ['className', '班级(只读参考)', 20, ''],
['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['examName', '考试名称(只读参考)', 28, ''],
['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''],
['passRule', '单科及格规则(只读参考)', 26, ''], ['passScore', '实际及格分(只读参考)', 20, ''],
['score', '成绩*', 12, 120], ['rank', '本科排名(导出计算)', 18, ''],
['rankPercent', '排名百分位(导出计算)', 20, ''], ['qualified', '单科达线(导出计算)', 16, ''], ['grade', '排名等级(自动计算)', 18, ''],
['published', '发布状态*', 14, '发布'], ['updatedAt', '更新时间(只读参考)', 24, '']
],
validations: { published: ['发布', '不发布'] },
numberColumns: ['fullScore', 'passScore', 'score', 'rank', 'rankPercent'],
numberFormats: { fullScore: '0.00', passScore: '0.00', score: '0.00', rank: '0', rankPercent: '0.00' }
},
admit_cards: {
title: '准考证信息台账', sheet: '准考证信息',
columns: [
['schoolName', '考生学校', 24, ''], ['className', '班级', 20, ''],
['candidateNumber', '报名号', 24, ''], ['candidateName', '姓名', 14, ''], ['idNumber', '证件号码', 22, ''],
['examCode', '考试代码', 18, ''], ['examName', '考试名称', 28, ''], ['cardNumber', '准考证号', 22, ''],
['centerCode', '考点代码', 16, ''], ['centerName', '考点名称', 26, ''], ['centerAddress', '考点详细地址', 38, ''],
['subjectName', '科目', 14, ''], ['subjectDate', '日期', 14, ''], ['subjectTime', '时间', 16, ''],
['examRoomCode', '考试考场序号', 16, ''], ['roomName', '考场通用名称', 20, ''], ['roomCode', '物理场地代码', 16, ''],
['building', '楼栋', 16, ''], ['floor', '楼层', 12, ''], ['seat', '座位号', 12, '']
]
},
admitted_candidates: {
title: '录取考生信息表', sheet: '录取考生',
columns: [
['candidateNumber', '报名号', 26, ''], ['name', '姓名', 14, ''], ['gender', '性别', 10, ''],
['idNumber', '证件号码', 24, ''], ['phone', '手机号', 18, ''], ['email', '邮箱', 24, ''],
['birthDate', '出生日期', 14, ''], ['ethnicity', '民族', 12, ''], ['nativePlace', '籍贯', 18, ''],
['sourceSchoolCode', '生源学校代码', 16, ''], ['sourceSchool', '生源学校', 26, ''], ['className', '班级', 18, ''],
['address', '家庭住址', 36, ''], ['guardianName', '监护人', 14, ''], ['guardianPhone', '监护人电话', 18, ''],
['specialty', '特长生资格', 20, ''], ['specialtyCertificate', '特长证明编号', 20, ''], ['policyEligibility', '政策资格说明', 24, ''],
['featureScore', '特征分', 12, ''], ['subjectScores', '各科成绩', 42, ''], ['totalScore', '考生总成绩', 14, ''],
['admittedSchool', '录取学校', 26, ''], ['categoryName', '录取类别', 20, ''], ['preferenceOrder', '志愿序号', 12, '']
],
numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'],
numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
},
admission_preferences: {
title: '考生志愿填报实时台账', sheet: '志愿填报情况',
columns: [
['examCode', '考试代码', 20, 'EX-2026-ZK'], ['examName', '考试名称', 30, '初中学业水平考试'],
['round', '填报轮次', 12, 1], ['fillStatus', '填报状态', 16, '已填报'], ['lockStatus', '锁定状态', 16, '未锁定'],
['submissionCount', '已提交次数', 14, 1], ['maxSubmissions', '提交次数上限', 16, 3],
['candidateNumber', '报名号', 26, '2026-HZ01-F-0001'], ['candidateName', '姓名', 14, '张同学'],
['sourceSchoolCode', '生源学校代码', 18, 'HZ01'], ['sourceSchoolName', '生源学校', 26, '海州市第一中学'], ['className', '班级', 18, '九年级一班'],
['specialty', '特长生资格', 22, '普通生'], ['indicatorStatus', '指标分配资格', 18, '有资格'],
['preferenceOrder', '志愿顺序', 14, 1], ['preferenceType', '志愿类型', 14, '普通志愿'],
['targetSchoolCode', '志愿学校代码', 18, 'AD01'], ['targetSchoolName', '志愿学校', 28, '海州市高级中学'],
['categoryName', '招生类别', 20, '普通生'], ['submittedAt', '最近提交时间', 24, '2026-07-22 10:18']
],
numberColumns: ['round', 'submissionCount', 'maxSubmissions', 'preferenceOrder'],
numberFormats: { round: '0', submissionCount: '0', maxSubmissions: '0', preferenceOrder: '0' }
},
admission_placements: {
title: '招生录取情况台账', sheet: '录取情况',
columns: [
['examCode', '考试代码', 20, 'EX-2026-ZK'], ['examName', '考试名称', 30, '初中学业水平考试'], ['round', '录取轮次', 12, 1],
['candidateNumber', '报名号', 26, '2026-HZ01-F-0001'], ['candidateName', '姓名', 14, '张同学'],
['sourceSchoolCode', '生源学校代码', 18, 'HZ01'], ['sourceSchoolName', '生源学校', 26, '海州市第一中学'], ['className', '班级', 18, '九年级一班'],
['specialty', '特长生资格', 22, '普通生'], ['culturalScore', '文化课总分', 14, 560], ['featureScore', '特征分', 12, 0], ['totalScore', '投档总分', 14, 560],
['preferenceOrder', '命中志愿序号', 16, 1], ['admissionSchoolCode', '招生学校代码', 18, 'AD01'], ['admissionSchoolName', '招生学校', 28, '海州市高级中学'],
['categoryName', '招生类别', 20, '普通生'], ['quotaBucket', '计划类型', 18, '普通计划'], ['admissionStatus', '录取状态', 18, '正式录取'],
['reportingStatus', '报到状态', 16, '已报到'], ['noticeNumber', '录取通知书编号', 34, 'AD01-EX-2026-ZK-000001'],
['withdrawalReason', '退档或放弃原因', 34, ''], ['updatedAt', '状态更新时间', 24, '2026-07-22 10:18']
],
numberColumns: ['round', 'culturalScore', 'featureScore', 'totalScore', 'preferenceOrder'],
numberFormats: { round: '0', culturalScore: '0.00', featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
},
admission_reporting: {
title: '录取考生报到状态维护表', sheet: '考生报到',
columns: [
['noticeNumber', '录取通知书编号*', 34, 'AD01-EX-2026-ZK-000001'],
['candidateNumber', '报名号*', 26, '2026-HZ01-F-0001'], ['name', '姓名(只读)', 14, '张同学'],
['examCode', '考试代码(只读)', 20, 'EX-2026-ZK'], ['schoolCode', '招生学校代码(只读)', 18, 'AD01'],
['categoryName', '录取类别(只读)', 20, '普通生'],
['reportingStatusCode', '报到状态码*Y/N/P', 22, 'P'], ['reportingNote', '报到备注', 36, '']
],
validations: { reportingStatusCode: ['Y', 'N', 'P'] }
}
};
export function hasExcelResource(resource) {
return Boolean(resourceSpecs[resource]);
}
function cellValue(cell) {
const value = cell.value;
if (value == null) return '';
if (value instanceof Date) return value.toISOString().slice(0, 10);
if (typeof value === 'object') {
if ('text' in value) return String(value.text || '').trim();
if ('result' in value) return String(value.result ?? '').trim();
}
return typeof value === 'number' ? value : String(value).trim();
}
export async function parseWorkbook(resource, buffer) {
const spec = resourceSpecs[resource];
if (!spec) throw Object.assign(new Error('不支持的 Excel 数据类型'), { status: 404 });
const workbook = new ExcelJS.Workbook();
try {
await workbook.xlsx.load(buffer);
} catch {
throw Object.assign(new Error('无法读取 Excel 文件,请使用系统下载的 .xlsx 模板'), { status: 400 });
}
const sheet = workbook.getWorksheet(spec.sheet) || workbook.worksheets[0];
if (!sheet) throw Object.assign(new Error('Excel 文件中没有可读取的工作表'), { status: 400 });
const headerMap = new Map();
sheet.getRow(2).eachCell((cell, col) => headerMap.set(String(cell.value || '').replace(/\*/g, '').trim(), col));
const missing = spec.columns.filter(([, label]) => !headerMap.has(label.replace(/\*/g, '').trim()));
if (missing.length) throw Object.assign(new Error(`模板列不完整:缺少 ${missing.map(([, label]) => label).join('、')}`), { status: 400 });
const rows = [];
for (let rowNumber = 3; rowNumber <= sheet.rowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
const item = { __row: rowNumber };
let populated = false;
for (const [key, label] of spec.columns) {
const value = cellValue(row.getCell(headerMap.get(label.replace(/\*/g, '').trim())));
item[key] = value;
if (value !== '') populated = true;
}
if (populated) rows.push(item);
}
if (!rows.length) throw Object.assign(new Error('Excel 中没有可导入的数据行'), { status: 400 });
return rows;
}
export async function buildWorkbook(resource, rows = [], { template = false, subtitle = '' } = {}) {
const spec = resourceSpecs[resource];
if (!spec) throw Object.assign(new Error('不支持的 Excel 数据类型'), { status: 404 });
const workbook = new ExcelJS.Workbook();
workbook.creator = '衡准考试信息管理系统';
workbook.created = new Date();
const sheet = workbook.addWorksheet(spec.sheet, { views: [{ state: 'frozen', ySplit: 2, showGridLines: false }] });
const lastColumn = spec.columns.length;
sheet.columns = spec.columns.map(([key, , width]) => ({ key, width }));
spec.columns.forEach(([key], index) => {
sheet.getColumn(index + 1).numFmt = spec.numberFormats?.[key] || (spec.numberColumns?.includes(key) ? '0' : '@');
});
sheet.mergeCells(1, 1, 1, lastColumn);
const titleCell = sheet.getCell(1, 1);
titleCell.value = subtitle ? `${spec.title}${subtitle}` : spec.title;
titleCell.font = { name: '微软雅黑', size: 16, bold: true, color: { argb: 'FFFFFFFF' } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } };
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
sheet.getRow(1).height = 34;
const header = sheet.getRow(2);
header.values = spec.columns.map(([, label]) => label);
header.height = 25;
header.font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFFFFF' } };
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2C7080' } };
header.alignment = { vertical: 'middle', horizontal: 'center' };
const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : [];
for (const item of outputRows) {
const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, resource === 'admission_reporting' && key === 'reportingNote' && !item[key] ? null : item[key] ?? ''])));
row.height = 23;
row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
row.alignment = { vertical: 'middle' };
row.eachCell(cell => {
cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } };
});
}
if (resource === 'admission_reporting') {
const statusColumn = spec.columns.findIndex(([key]) => key === 'reportingStatusCode') + 1;
const noteColumn = spec.columns.findIndex(([key]) => key === 'reportingNote') + 1;
for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) {
for (const column of [statusColumn, noteColumn]) sheet.getCell(row, column).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF3CD' } };
}
sheet.getCell('A1').value = `${spec.title}|仅修改黄色列;Y=已报到,N=未报到,P=待确认`;
}
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } };
for (const [key, values] of Object.entries(spec.validations || {})) {
const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1;
for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) {
sheet.getCell(row, col).dataValidation = { type: 'list', allowBlank: false, formulae: [`"${values.join(',')}"`] };
}
}
const requiredColumns = spec.columns.map(([, label], index) => label.includes('*') ? index + 1 : 0).filter(Boolean);
for (const col of requiredColumns) header.getCell(col).font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFE7A3' } };
return workbook.xlsx.writeBuffer();
}
function addOperationalSheet(workbook, name, title, columns, rows, { landscape = true } = {}) {
const sheet = workbook.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 2, showGridLines: false }] });
sheet.columns = columns.map(([key, , width]) => ({ key, width }));
sheet.mergeCells(1, 1, 1, columns.length);
const titleCell = sheet.getCell(1, 1);
titleCell.value = title;
titleCell.font = { name: '微软雅黑', size: 16, bold: true, color: { argb: 'FFFFFFFF' } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } };
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
sheet.getRow(1).height = 34;
const header = sheet.getRow(2);
header.values = columns.map(([, label]) => label);
header.height = 28;
header.font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFFFFF' } };
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2C7080' } };
header.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
rows.forEach((item, index) => {
const row = sheet.addRow(Object.fromEntries(columns.map(([key]) => [key, item[key] ?? ''])));
row.height = 28;
row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
row.alignment = { vertical: 'middle', wrapText: true };
if (index % 2 === 1) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F7F8' } };
row.eachCell(cell => { cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } }; });
});
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: columns.length } };
sheet.pageSetup = { orientation: landscape ? 'landscape' : 'portrait', paperSize: 9, fitToPage: true, fitToWidth: 1, fitToHeight: 0, margins: { left: .25, right: .25, top: .4, bottom: .4, header: .2, footer: .2 } };
sheet.printTitlesRow = '1:2';
return sheet;
}
function addDeskStickerSheet(workbook, rows) {
const sheet = workbook.addWorksheet('桌贴', { views: [{ showGridLines: false, zoomScale: 85 }] });
const labelColumns = [14.5, 14.5, 14.5, 3, 14.5, 14.5, 14.5];
labelColumns.forEach((width, index) => { sheet.getColumn(index + 1).width = width; });
sheet.pageSetup = {
orientation: 'portrait', paperSize: 9, fitToPage: true, fitToWidth: 1, fitToHeight: 0,
margins: { left: .25, right: .25, top: .25, bottom: .25, header: 0, footer: 0 },
horizontalCentered: true, verticalCentered: false
};
sheet.headerFooter = { oddFooter: '&C第 &P / &N 页' };
const rowsPerLabel = 8;
const labelsPerPage = 6;
const rowsPerPage = rowsPerLabel * 3;
const borderColor = { argb: 'FF173F60' };
const paleBlue = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF1F5' } };
const paleGold = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF1C7' } };
const mergeLine = (row, startColumn) => {
sheet.mergeCells(row, startColumn, row, startColumn + 2);
return sheet.getCell(row, startColumn);
};
const styleLine = (cell, { size = 10, bold = false, color = 'FF243B4A', fill = null } = {}) => {
cell.font = { name: '微软雅黑', size, bold, color: { argb: color } };
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true, shrinkToFit: true };
if (fill) cell.fill = fill;
};
rows.forEach((item, index) => {
const page = Math.floor(index / labelsPerPage);
const slot = index % labelsPerPage;
const startRow = page * rowsPerPage + Math.floor(slot / 2) * rowsPerLabel + 1;
const startColumn = slot % 2 === 0 ? 1 : 5;
for (let row = startRow; row < startRow + rowsPerLabel; row += 1) sheet.getRow(row).height = 32.5;
const cells = Array.from({ length: rowsPerLabel }, (_, offset) => mergeLine(startRow + offset, startColumn));
cells[0].value = item.examName;
styleLine(cells[0], { size: 11, bold: true, color: 'FFFFFFFF', fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } } });
cells[1].value = `${item.subjectName}${item.subjectDate} ${item.subjectTime}`;
styleLine(cells[1], { size: 9, bold: true, fill: paleBlue });
cells[2].value = '考试考场序号';
styleLine(cells[2], { size: 9, bold: true, color: 'FF2C7080' });
cells[3].value = { richText: [{ text: String(item.examRoomCode || '') }] };
cells[3].numFmt = '@';
styleLine(cells[3], { size: 25, bold: true, color: 'FF173F60' });
cells[4].value = `座位号 ${item.seat}`;
styleLine(cells[4], { size: 20, bold: true, color: 'FF8A4B08', fill: paleGold });
cells[5].value = `${item.roomName}(场地 ${item.roomCode})|${item.building} ${item.floor}`;
styleLine(cells[5], { size: 9, bold: true });
cells[6].value = `${item.candidateName}${item.schoolName} ${item.className}`;
styleLine(cells[6], { size: 10, bold: true, fill: paleBlue });
cells[7].value = `${item.centerName}|准考证号 ${item.cardNumber}`;
styleLine(cells[7], { size: 8, color: 'FF526576' });
for (let row = startRow; row < startRow + rowsPerLabel; row += 1) {
for (let column = startColumn; column < startColumn + 3; column += 1) {
const cell = sheet.getCell(row, column);
cell.border = {
top: { style: row === startRow ? 'medium' : 'hair', color: borderColor },
bottom: { style: row === startRow + rowsPerLabel - 1 ? 'medium' : 'hair', color: borderColor },
left: { style: column === startColumn ? 'medium' : 'hair', color: borderColor },
right: { style: column === startColumn + 2 ? 'medium' : 'hair', color: borderColor }
};
}
}
if ((index + 1) % labelsPerPage === 0 && index + 1 < rows.length) sheet.getRow(startRow + rowsPerLabel - 1).addPageBreak();
});
if (rows.length) sheet.pageSetup.printArea = `A1:G${Math.ceil(rows.length / labelsPerPage) * rowsPerPage}`;
return sheet;
}
export async function buildCenterMaterialsWorkbook(rows, subtitle = '') {
const workbook = new ExcelJS.Workbook();
workbook.creator = '衡准考试信息管理系统';
workbook.created = new Date();
const sorted = [...rows].sort((a, b) => String(a.centerCode).localeCompare(String(b.centerCode))
|| String(a.subjectDate).localeCompare(String(b.subjectDate)) || String(a.subjectTime).localeCompare(String(b.subjectTime))
|| String(a.examRoomCode).localeCompare(String(b.examRoomCode)) || String(a.seat).localeCompare(String(b.seat)));
const baseTitle = subtitle ? `${subtitle}` : '';
addDeskStickerSheet(workbook, sorted);
const doorMap = new Map();
for (const item of sorted) {
const key = [item.examCode, item.subjectName, item.centerCode, item.examRoomCode, item.roomCode].join('|');
const current = doorMap.get(key) || { ...item, candidateCount: 0 };
current.candidateCount += 1;
doorMap.set(key, current);
}
addOperationalSheet(workbook, '门贴', `考场门贴${baseTitle}`, [
['examName', '考试', 28], ['subjectName', '科目', 14], ['subjectDate', '日期', 14], ['subjectTime', '时间', 16],
['centerName', '考点', 24], ['examRoomCode', '考试考场序号', 16], ['roomName', '考场通用名称', 20],
['roomCode', '场地代码', 14], ['building', '楼栋', 16], ['floor', '楼层', 10], ['candidateCount', '人数', 10]
], [...doorMap.values()]);
addOperationalSheet(workbook, '考场签名单', `考场考生签名单${baseTitle}`, [
['examName', '考试', 26], ['subjectName', '科目', 14], ['subjectDate', '日期', 14], ['subjectTime', '时间', 16],
['examRoomCode', '考试考场序号', 16], ['roomName', '考场通用名称', 20], ['building', '楼栋', 16], ['floor', '楼层', 10],
['seat', '座位号', 10], ['cardNumber', '准考证号', 22], ['candidateName', '姓名', 14],
['schoolName', '考生学校', 24], ['className', '班级', 18], ['signature', '考生签名', 18]
], sorted.map(item => ({ ...item, signature: '' })));
return workbook.xlsx.writeBuffer();
}
-4355
View File
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
{
"name": "hengzhun-exam-system",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "dotnet run --project src/Eis.Web/Eis.Web.csproj",
"test": "dotnet test Eis.slnx",
"test:legacy": "node tests/client-auth.test.mjs && node tests/api-dedup.test.mjs && node tests/cache.test.mjs && node tests/auth-state.test.mjs && node tests/state-cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
"test:cache": "node tests/cache.test.mjs",
"reset-db": "dotnet run --project src/Eis.Tools -- database reset",
"seed-test-data": "dotnet run --project src/Eis.Tools -- database seed",
"seed-test-data:sqlite": "dotnet run --project src/Eis.Tools -- database seed --sqlite",
"seed-test-data:mysql": "dotnet run --project src/Eis.Tools -- database seed --mysql",
"initialize-system": "dotnet run --project src/Eis.Tools -- database reset",
"initialize-system:sqlite": "dotnet run --project src/Eis.Tools -- database reset --sqlite",
"initialize-system:mysql": "dotnet run --project src/Eis.Tools -- database reset --mysql"
},
"dependencies": {
"ckeditor5": "^48.3.1",
"exceljs": "^4.4.0",
"mysql2": "^3.14.2",
"qrcode": "1.5.4",
"redis": "^5.12.1",
"sanitize-html": "^2.17.6"
},
"overrides": {
"exceljs": {
"uuid": "^11.1.1"
}
},
"engines": {
"node": ">=22.5"
}
}
-55
View File
@@ -1,55 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const input = resolve(process.argv[2] || 'ok_data_level3.csv');
const output = resolve(process.argv[3] || 'src/data/china-regions.mjs');
function parseCsvLine(line) {
const values = [];
let value = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (char === '"') {
if (quoted && line[index + 1] === '"') { value += '"'; index += 1; }
else quoted = !quoted;
} else if (char === ',' && !quoted) {
values.push(value);
value = '';
} else value += char;
}
values.push(value);
return values;
}
const source = await readFile(input, 'utf8');
const rows = source.replace(/^\uFEFF/, '').trim().split(/\r?\n/).slice(1).map(line => {
const [id, pid, deep, , , , extId, extName] = parseCsvLine(line);
return { id, pid, deep: Number(deep), code: extId.slice(0, 6), name: extName };
});
const provinces = rows.filter(item => item.deep === 0 && item.code !== '0').map(province => ({
code: province.code,
name: province.name,
cities: rows.filter(city => city.deep === 1 && city.pid === province.id).map(city => ({
code: city.code,
name: city.name,
districts: rows.filter(district => district.deep === 2 && district.pid === city.id).map(district => ({
code: district.code,
name: district.name
}))
}))
}));
const hotan = provinces.find(item => item.code === '650000')?.cities.find(item => item.code === '653200');
for (const district of [
{ code: '653228', name: '和康县' },
{ code: '653229', name: '和安县' }
]) {
if (hotan && !hotan.districts.some(item => item.code === district.code)) hotan.districts.push(district);
}
hotan?.districts.sort((a, b) => a.code.localeCompare(b.code));
const banner = `// Generated from AreaCity-JsSpider-StatsGov release 2025.251231.260403.\n// Source snapshot: 国家地名信息库 2025-12-31; generated 2026-07-20.\n// Manual official additions: 和康县 653228, 和安县 653229.\n`;
await writeFile(output, `${banner}export const chinaRegionsVersion = '2025-12-31';\nexport const chinaRegions = ${JSON.stringify(provinces)};\n`, 'utf8');
console.log(`Generated ${provinces.length} provinces at ${output}`);
-44
View File
@@ -1,44 +0,0 @@
import { pbkdf2Sync } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { gzipSync } from 'node:zlib';
import { buildSeedOperations } from '../database.mjs';
import { createSeedDatabase } from '../src/data/seed.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const outputPath = join(root, 'src', 'Eis.Infrastructure', 'Data', 'demo-seed-operations.json.gz');
const generatedAt = '2026-07-23T00:00:00.000Z';
const deterministicSalt = 'eis-dotnet-demo-seed';
const hashPassword = password => {
const hash = pbkdf2Sync(password, deterministicSalt, 120000, 32, 'sha256').toString('hex');
return `${deterministicSalt}:${hash}`;
};
const database = createSeedDatabase({
nowIso: () => generatedAt,
hashPassword
});
database.meta.version = 20;
const operations = buildSeedOperations(database);
const payload = {
version: 1,
schemaVersion: 20,
generatedAt,
summary: {
schools: database.schools.length,
users: database.users.length,
candidates: database.candidateProfiles.length,
registrations: database.registrations.length,
results: database.results.length,
admissionRecords: database.admissionRecords.length,
firstRoundPreferences: database.admissionRecords.filter(
item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1
).length,
operations: operations.length
},
operations
};
const compressed = gzipSync(Buffer.from(JSON.stringify(payload)), { level: 9 });
await writeFile(outputPath, compressed);
console.log(JSON.stringify({ outputPath, compressedBytes: compressed.length, ...payload.summary }));
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -81,6 +81,9 @@ try {
$migration = Invoke-RestMethod -Uri "$baseUrl/health/migration" -TimeoutSec 5
$homeResponse = Invoke-WebRequest -Uri "$baseUrl/" -TimeoutSec 5
$applicationScript = Invoke-WebRequest -Uri "$baseUrl/js/app.js" -TimeoutSec 10
$clientApiModule = Invoke-WebRequest -Uri "$baseUrl/js/client/api.js" -TimeoutSec 10
$regionDataModule = Invoke-WebRequest -Uri "$baseUrl/js/data/china-regions.js" -TimeoutSec 10
$ckeditor = Invoke-WebRequest -Uri "$baseUrl/vendor/ckeditor5/ckeditor5.js" -TimeoutSec 10
$session = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$loginBody = @{
@@ -99,13 +102,18 @@ try {
-WebSession $session `
-TimeoutSec 5
$missing = Invoke-WebRequest `
-Uri "$baseUrl/api/removed-node-route" `
-Uri "$baseUrl/api/removed-legacy-route" `
-SkipHttpErrorCheck `
-TimeoutSec 5
if ($live.status -ne 'healthy' -or
$migration.legacyApiRemoved -ne $true -or
$homeResponse.StatusCode -ne 200 -or
$homeResponse.Content -notmatch '/js/app\.js' -or
$applicationScript.RawContentLength -lt 100000 -or
$applicationScript.Content -notmatch "from './client/api\.js'" -or
$clientApiModule.RawContentLength -lt 1000 -or
$regionDataModule.RawContentLength -lt 100000 -or
$ckeditor.RawContentLength -lt 100000 -or
$login.user.role -ne 'admin' -or
$login.user.adminLevel -ne 'super' -or
@@ -119,6 +127,7 @@ try {
Live = $live.status
LegacyRemoved = $migration.legacyApiRemoved
HomeStatus = $homeResponse.StatusCode
FrontendBytes = $applicationScript.RawContentLength
CkeditorBytes = $ckeditor.RawContentLength
LoginRole = $login.user.role
CurrentUser = $currentUser.user.username
-1042
View File
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,7 @@ internal sealed class RegionCatalog
{
var assembly = typeof(RegionCatalog).Assembly;
var resourceName = assembly.GetManifestResourceNames()
.Single(name => name.EndsWith("china-regions.mjs", StringComparison.Ordinal));
.Single(name => name.EndsWith("china-regions.js", StringComparison.Ordinal));
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException("无法读取内嵌行政区划数据");
using var reader = new StreamReader(stream);
@@ -2,32 +2,34 @@ using System.Data.Common;
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Eis.Infrastructure.Authentication;
using Microsoft.Extensions.DependencyInjection;
namespace Eis.Infrastructure.Data;
internal sealed partial class DatabaseInitializer(
internal sealed class DatabaseInitializer(
IRelationalConnectionFactory connectionFactory,
DatabaseOptions options,
PasswordCompatibilityService passwords)
{
private const int SchemaVersion = 20;
private const string MySqlStatementSeparator = "\n-- EIS-STATEMENT\n";
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var source = await ReadSchemaSourceAsync(cancellationToken);
var schema = await ReadSchemaAsync(options.Client, cancellationToken);
if (options.Client == "sqlite")
{
await ExecuteAsync(connection, ExtractSqliteSchema(source), cancellationToken);
await ExecuteAsync(connection, schema, cancellationToken);
}
else
{
foreach (Match match in MySqlStatementPattern().Matches(ExtractMySqlSection(source)))
foreach (var statement in schema.ReplaceLineEndings("\n").Split(
MySqlStatementSeparator,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
await ExecuteAsync(connection, match.Groups["sql"].Value, cancellationToken);
await ExecuteAsync(connection, statement, cancellationToken);
}
}
await SeedBaseStateAsync(connection, null, cancellationToken);
@@ -264,39 +266,18 @@ internal sealed partial class DatabaseInitializer(
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<string> ReadSchemaSourceAsync(CancellationToken cancellationToken)
private static async Task<string> ReadSchemaAsync(string client, CancellationToken cancellationToken)
{
var assembly = typeof(DatabaseInitializer).Assembly;
var resourceSuffix = $"Schema.{client}.sql";
var name = assembly.GetManifestResourceNames()
.Single(item => item.EndsWith("database-schema.mjs", StringComparison.Ordinal));
.Single(item => item.EndsWith(resourceSuffix, StringComparison.Ordinal));
await using var stream = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException("内置数据库结构资源不存在");
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync(cancellationToken);
}
private static string ExtractSqliteSchema(string source)
{
const string startToken = "export const sqliteSchema = `";
var start = source.IndexOf(startToken, StringComparison.Ordinal);
var mysqlStart = source.IndexOf("export const mysqlSchema", StringComparison.Ordinal);
var end = mysqlStart < 0 ? -1 : source.LastIndexOf('`', mysqlStart);
if (start < 0 || end <= start) throw new InvalidOperationException("无法读取 SQLite 数据库结构");
return source[(start + startToken.Length)..end];
}
private static string ExtractMySqlSection(string source)
{
const string startToken = "export const mysqlSchema = [";
var start = source.IndexOf(startToken, StringComparison.Ordinal);
var end = source.IndexOf("];", start, StringComparison.Ordinal);
if (start < 0 || end <= start) throw new InvalidOperationException("无法读取 MySQL 数据库结构");
return source[(start + startToken.Length)..end];
}
[GeneratedRegex(@"`(?<sql>[\s\S]*?)`\s*,?", RegexOptions.CultureInvariant)]
private static partial Regex MySqlStatementPattern();
private sealed record AdmissionRule(
string Id, string Code, string Name, string Description, string Example, AdmissionSegment[] Segments);
private sealed record AdmissionSegment(string Source, string Label, int? Width);
@@ -1,509 +1,4 @@
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,
is_source_school INTEGER NOT NULL DEFAULT 1 CHECK (is_source_school IN (0, 1)),
is_admission_school INTEGER NOT NULL DEFAULT 1 CHECK (is_admission_school IN (0, 1)),
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 school_student_partitions (
school_id TEXT PRIMARY KEY REFERENCES schools(id) ON DELETE CASCADE,
partition_key TEXT NOT NULL UNIQUE,
students_table TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) 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', 'admission_school')),
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)),
totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)),
totp_secret_encrypted TEXT,
totp_recovery_codes TEXT NOT NULL DEFAULT '[]',
totp_last_used_step INTEGER,
archived_at TEXT,
archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
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,
province_code TEXT,
province_name TEXT,
city_code TEXT,
city_name TEXT,
district_code TEXT,
district_name TEXT,
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,
specialty_category TEXT,
specialty_type TEXT,
specialty_types TEXT NOT NULL DEFAULT '[]',
specialty_certificate TEXT,
policy_eligibility 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,
pass_policy TEXT NOT NULL DEFAULT 'rank_percent' CHECK (pass_policy IN ('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none')),
pass_value REAL NOT NULL DEFAULT 60,
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')),
archived_at TEXT,
archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS exam_data_partitions (
exam_id TEXT PRIMARY KEY REFERENCES exams(id) ON DELETE CASCADE,
partition_key TEXT NOT NULL UNIQUE,
candidates_table TEXT NOT NULL UNIQUE,
admissions_table TEXT NOT NULL UNIQUE,
results_table TEXT NOT NULL UNIQUE,
centers_table TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_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,
full_score REAL NOT NULL DEFAULT 150,
pass_score REAL NOT NULL DEFAULT 90,
pass_rule TEXT NOT NULL DEFAULT 'fixed_score' CHECK (pass_rule IN ('fixed_score', 'score_ratio', 'none')),
pass_value REAL NOT NULL DEFAULT 90,
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')),
paid_at TEXT,
paid_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
reviewed_at TEXT,
review_note TEXT,
registration_number TEXT,
number_rule_id TEXT,
feature_score REAL NOT NULL DEFAULT 0 CHECK (feature_score >= 0),
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 admission_number_rules (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
separator TEXT NOT NULL DEFAULT '',
segments_json TEXT NOT NULL,
example TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS exam_arrangement_plans (
id TEXT PRIMARY KEY,
exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE,
number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id),
mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')),
random_seed TEXT NOT NULL,
candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0),
center_count INTEGER NOT NULL CHECK (center_count >= 0),
subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0),
subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0),
same_school_center_rate REAL NOT NULL,
warnings_json TEXT NOT NULL,
generated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS admit_cards (
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
card_number TEXT NOT NULL UNIQUE,
center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
test_center TEXT NOT NULL,
center_code TEXT NOT NULL,
center_address TEXT NOT NULL,
generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS admit_card_subjects (
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL,
room TEXT NOT NULL,
room_code TEXT NOT NULL,
exam_room_code TEXT NOT NULL,
building TEXT NOT NULL,
floor TEXT NOT NULL,
seat TEXT NOT NULL,
subject_signature TEXT NOT NULL,
PRIMARY KEY (registration_id, subject_id),
UNIQUE (subject_id, room_id, seat)
) 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),
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,
province_code TEXT NOT NULL,
province_name TEXT NOT NULL,
city_code TEXT NOT NULL,
city_name TEXT NOT NULL,
district_code TEXT NOT NULL,
district_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,
province_code TEXT NOT NULL,
province_name TEXT NOT NULL,
city_code TEXT NOT NULL,
city_name TEXT NOT NULL,
district_code TEXT NOT NULL,
district_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', 'score_appeal')),
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 ('class', '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 TABLE IF NOT EXISTS admission_records (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')),
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
status TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
CREATE INDEX IF NOT EXISTS idx_users_archive_scope ON users(role, school_id, class_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_exams_archive ON exams(archived_at, exam_end);
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_payment ON registrations(payment_status, paid_at);
CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id);
CREATE INDEX IF NOT EXISTS idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
CREATE INDEX IF NOT EXISTS idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
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);
CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_insert
BEFORE INSERT ON results
WHEN EXISTS (
SELECT 1 FROM registrations registration
JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id = NEW.registration_id AND exam.archived_at IS NOT NULL
)
BEGIN
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
END;
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_update
BEFORE UPDATE ON results
WHEN EXISTS (
SELECT 1 FROM registrations registration
JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id IN (OLD.registration_id, NEW.registration_id) AND exam.archived_at IS NOT NULL
)
BEGIN
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
END;
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_delete
BEFORE DELETE ON results
WHEN EXISTS (
SELECT 1 FROM registrations registration
JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id = OLD.registration_id AND exam.archived_at IS NOT NULL
)
BEGIN
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
END;
`;
export const mysqlSchema = [
`CREATE TABLE IF NOT EXISTS schema_metadata (
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,
@@ -511,8 +6,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS organization (
id TINYINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
code VARCHAR(60) NOT NULL,
@@ -520,8 +16,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS schools (
id VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
code VARCHAR(40) NOT NULL,
@@ -532,8 +29,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS school_classes (
id VARCHAR(64) NOT NULL,
school_id VARCHAR(64) NOT NULL,
name VARCHAR(100) NOT NULL,
@@ -542,8 +40,9 @@ export const mysqlSchema = [
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 school_student_partitions (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS school_student_partitions (
school_id VARCHAR(64) NOT NULL,
partition_key VARCHAR(32) NOT NULL,
students_table VARCHAR(64) NOT NULL,
@@ -553,8 +52,9 @@ export const mysqlSchema = [
UNIQUE KEY uq_school_partitions_key (partition_key),
UNIQUE KEY uq_school_partitions_table (students_table),
CONSTRAINT fk_school_partitions_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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) NOT NULL,
username VARCHAR(100) NOT NULL,
candidate_number VARCHAR(120) NULL,
@@ -581,8 +81,9 @@ export const mysqlSchema = [
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,
CONSTRAINT fk_users_archiver FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS candidate_profiles (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS candidate_profiles (
id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
name VARCHAR(100) NOT NULL,
@@ -628,8 +129,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS notices (
id VARCHAR(64) NOT NULL,
title VARCHAR(240) NOT NULL,
summary VARCHAR(500) NOT NULL,
@@ -642,8 +144,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS exams (
id VARCHAR(64) NOT NULL,
code VARCHAR(60) NOT NULL,
name VARCHAR(200) NOT NULL,
@@ -666,8 +169,9 @@ export const mysqlSchema = [
KEY idx_exams_status_registration (status, registration_start, registration_end),
KEY idx_exams_archive (archived_at, exam_end),
CONSTRAINT fk_exams_archived_by FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS exam_data_partitions (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS exam_data_partitions (
exam_id VARCHAR(64) NOT NULL,
partition_key VARCHAR(32) NOT NULL,
candidates_table VARCHAR(64) NOT NULL,
@@ -683,8 +187,9 @@ export const mysqlSchema = [
UNIQUE KEY uq_exam_partitions_results (results_table),
UNIQUE KEY uq_exam_partitions_centers (centers_table),
CONSTRAINT fk_exam_partitions_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 exam_subjects (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS exam_subjects (
id VARCHAR(64) NOT NULL,
exam_id VARCHAR(64) NOT NULL,
name VARCHAR(100) NOT NULL,
@@ -701,8 +206,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS registrations (
id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
exam_id VARCHAR(64) NOT NULL,
@@ -724,15 +230,17 @@ export const mysqlSchema = [
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,
CONSTRAINT fk_registrations_paid_by FOREIGN KEY (paid_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS registration_subjects (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
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 results (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS results (
id VARCHAR(64) NOT NULL,
registration_id VARCHAR(64) NOT NULL,
subject_id VARCHAR(64) NOT NULL,
@@ -747,8 +255,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS test_centers (
id VARCHAR(64) NOT NULL,
school_id VARCHAR(64) NOT NULL,
code VARCHAR(40) NOT NULL,
@@ -774,8 +283,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS test_rooms (
id VARCHAR(64) NOT NULL,
center_id VARCHAR(64) NOT NULL,
code VARCHAR(40) NOT NULL,
@@ -793,21 +303,23 @@ export const mysqlSchema = [
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 admission_number_rules (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS admission_number_rules (
id VARCHAR(64) NOT NULL,
code VARCHAR(80) NOT NULL,
name VARCHAR(160) NOT NULL,
description VARCHAR(500) NOT NULL,
\`separator\` VARCHAR(10) NOT NULL DEFAULT '',
`separator` VARCHAR(10) NOT NULL DEFAULT '',
segments_json JSON NOT NULL,
example VARCHAR(120) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at VARCHAR(35) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_admission_rules_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS exam_arrangement_plans (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS exam_arrangement_plans (
id VARCHAR(64) NOT NULL,
exam_id VARCHAR(64) NOT NULL,
number_rule_id VARCHAR(64) NOT NULL,
@@ -826,8 +338,9 @@ export const mysqlSchema = [
CONSTRAINT fk_arrangement_plans_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
CONSTRAINT fk_arrangement_plans_rule FOREIGN KEY (number_rule_id) REFERENCES admission_number_rules(id),
CONSTRAINT fk_arrangement_plans_generator FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS admit_cards (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS admit_cards (
registration_id VARCHAR(64) NOT NULL,
plan_id VARCHAR(64) NOT NULL,
card_number VARCHAR(100) NOT NULL,
@@ -841,8 +354,9 @@ export const mysqlSchema = [
CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
CONSTRAINT fk_admit_cards_plan FOREIGN KEY (plan_id) REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
CONSTRAINT fk_admit_cards_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS admit_card_subjects (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS admit_card_subjects (
registration_id VARCHAR(64) NOT NULL,
subject_id VARCHAR(64) NOT NULL,
room_id VARCHAR(64) NULL,
@@ -859,8 +373,9 @@ export const mysqlSchema = [
CONSTRAINT fk_admit_subjects_card FOREIGN KEY (registration_id) REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
CONSTRAINT fk_admit_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE,
CONSTRAINT fk_admit_subjects_room FOREIGN KEY (room_id) REFERENCES test_rooms(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS center_change_requests (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS center_change_requests (
id VARCHAR(64) NOT NULL,
center_id VARCHAR(64) NULL,
school_id VARCHAR(64) NOT NULL,
@@ -892,8 +407,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS center_change_rooms (
id VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
room_id VARCHAR(64) NULL,
@@ -911,18 +427,20 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS number_rules (
id VARCHAR(64) NOT NULL,
name VARCHAR(120) NOT NULL,
\`separator\` VARCHAR(10) NOT NULL DEFAULT '',
`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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS number_rule_segments (
id VARCHAR(64) NOT NULL,
rule_id VARCHAR(64) NOT NULL,
position INT UNSIGNED NOT NULL,
@@ -932,8 +450,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS candidate_account_batches (
id VARCHAR(64) NOT NULL,
school_id VARCHAR(64) NOT NULL,
requested_by VARCHAR(64) NULL,
@@ -945,8 +464,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
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,
@@ -963,8 +483,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS workflow_definitions (
id VARCHAR(64) NOT NULL,
business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch', 'score_appeal') NOT NULL,
name VARCHAR(120) NOT NULL,
@@ -974,8 +495,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS workflow_steps (
id VARCHAR(64) NOT NULL,
workflow_id VARCHAR(64) NOT NULL,
position INT UNSIGNED NOT NULL,
@@ -984,8 +506,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS workflow_instances (
id VARCHAR(64) NOT NULL,
workflow_id VARCHAR(64) NOT NULL,
business_type VARCHAR(40) NOT NULL,
@@ -999,8 +522,9 @@ export const mysqlSchema = [
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 (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS workflow_actions (
id VARCHAR(64) NOT NULL,
instance_id VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NULL,
@@ -1015,8 +539,9 @@ export const mysqlSchema = [
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 admission_records (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS admission_records (
id VARCHAR(64) NOT NULL,
kind ENUM('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication') NOT NULL,
exam_id VARCHAR(64) NOT NULL,
@@ -1031,8 +556,9 @@ export const mysqlSchema = [
CONSTRAINT fk_admission_record_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
CONSTRAINT fk_admission_record_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_admission_record_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 audit_logs (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
-- EIS-STATEMENT
CREATE TABLE IF NOT EXISTS audit_logs (
id VARCHAR(64) NOT NULL,
actor_id VARCHAR(64) NULL,
action VARCHAR(100) NOT NULL,
@@ -1041,5 +567,4 @@ export const mysqlSchema = [
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`
];
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
@@ -0,0 +1,501 @@
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,
is_source_school INTEGER NOT NULL DEFAULT 1 CHECK (is_source_school IN (0, 1)),
is_admission_school INTEGER NOT NULL DEFAULT 1 CHECK (is_admission_school IN (0, 1)),
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 school_student_partitions (
school_id TEXT PRIMARY KEY REFERENCES schools(id) ON DELETE CASCADE,
partition_key TEXT NOT NULL UNIQUE,
students_table TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) 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', 'admission_school')),
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)),
totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)),
totp_secret_encrypted TEXT,
totp_recovery_codes TEXT NOT NULL DEFAULT '[]',
totp_last_used_step INTEGER,
archived_at TEXT,
archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
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,
province_code TEXT,
province_name TEXT,
city_code TEXT,
city_name TEXT,
district_code TEXT,
district_name TEXT,
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,
specialty_category TEXT,
specialty_type TEXT,
specialty_types TEXT NOT NULL DEFAULT '[]',
specialty_certificate TEXT,
policy_eligibility 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,
pass_policy TEXT NOT NULL DEFAULT 'rank_percent' CHECK (pass_policy IN ('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none')),
pass_value REAL NOT NULL DEFAULT 60,
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')),
archived_at TEXT,
archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS exam_data_partitions (
exam_id TEXT PRIMARY KEY REFERENCES exams(id) ON DELETE CASCADE,
partition_key TEXT NOT NULL UNIQUE,
candidates_table TEXT NOT NULL UNIQUE,
admissions_table TEXT NOT NULL UNIQUE,
results_table TEXT NOT NULL UNIQUE,
centers_table TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_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,
full_score REAL NOT NULL DEFAULT 150,
pass_score REAL NOT NULL DEFAULT 90,
pass_rule TEXT NOT NULL DEFAULT 'fixed_score' CHECK (pass_rule IN ('fixed_score', 'score_ratio', 'none')),
pass_value REAL NOT NULL DEFAULT 90,
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')),
paid_at TEXT,
paid_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
reviewed_at TEXT,
review_note TEXT,
registration_number TEXT,
number_rule_id TEXT,
feature_score REAL NOT NULL DEFAULT 0 CHECK (feature_score >= 0),
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 admission_number_rules (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
separator TEXT NOT NULL DEFAULT '',
segments_json TEXT NOT NULL,
example TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS exam_arrangement_plans (
id TEXT PRIMARY KEY,
exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE,
number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id),
mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')),
random_seed TEXT NOT NULL,
candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0),
center_count INTEGER NOT NULL CHECK (center_count >= 0),
subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0),
subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0),
same_school_center_rate REAL NOT NULL,
warnings_json TEXT NOT NULL,
generated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS admit_cards (
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
card_number TEXT NOT NULL UNIQUE,
center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
test_center TEXT NOT NULL,
center_code TEXT NOT NULL,
center_address TEXT NOT NULL,
generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS admit_card_subjects (
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL,
room TEXT NOT NULL,
room_code TEXT NOT NULL,
exam_room_code TEXT NOT NULL,
building TEXT NOT NULL,
floor TEXT NOT NULL,
seat TEXT NOT NULL,
subject_signature TEXT NOT NULL,
PRIMARY KEY (registration_id, subject_id),
UNIQUE (subject_id, room_id, seat)
) 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),
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,
province_code TEXT NOT NULL,
province_name TEXT NOT NULL,
city_code TEXT NOT NULL,
city_name TEXT NOT NULL,
district_code TEXT NOT NULL,
district_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,
province_code TEXT NOT NULL,
province_name TEXT NOT NULL,
city_code TEXT NOT NULL,
city_name TEXT NOT NULL,
district_code TEXT NOT NULL,
district_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', 'score_appeal')),
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 ('class', '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 TABLE IF NOT EXISTS admission_records (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')),
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
status TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
CREATE INDEX IF NOT EXISTS idx_users_archive_scope ON users(role, school_id, class_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_exams_archive ON exams(archived_at, exam_end);
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_payment ON registrations(payment_status, paid_at);
CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id);
CREATE INDEX IF NOT EXISTS idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
CREATE INDEX IF NOT EXISTS idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
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);
CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_insert
BEFORE INSERT ON results
WHEN EXISTS (
SELECT 1 FROM registrations registration
JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id = NEW.registration_id AND exam.archived_at IS NOT NULL
)
BEGIN
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
END;
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_update
BEFORE UPDATE ON results
WHEN EXISTS (
SELECT 1 FROM registrations registration
JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id IN (OLD.registration_id, NEW.registration_id) AND exam.archived_at IS NOT NULL
)
BEGIN
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
END;
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_delete
BEFORE DELETE ON results
WHEN EXISTS (
SELECT 1 FROM registrations registration
JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id = OLD.registration_id AND exam.archived_at IS NOT NULL
)
BEGIN
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
END;
@@ -17,8 +17,9 @@
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\data\china-regions.mjs" Link="Data\china-regions.mjs" />
<EmbeddedResource Include="..\..\src\database\schema.mjs" Link="Data\database-schema.mjs" />
<EmbeddedResource Include="..\Eis.Web\wwwroot\js\data\china-regions.js" Link="Data\china-regions.js" />
<EmbeddedResource Include="Data\Schema.sqlite.sql" />
<EmbeddedResource Include="Data\Schema.mysql.sql" />
<EmbeddedResource Include="Data\demo-seed-operations.json.gz" />
</ItemGroup>
</Project>
@@ -106,7 +106,7 @@ public sealed partial class PublicQueryService(
public async Task<JsonObject?> GetNoticeAsync(string id, CancellationToken cancellationToken)
{
// Detail reads deliberately bypass the list cache. During the compatibility
// window Node.js may publish a notice in another process without a shared
// window another application instance may publish a notice without a shared
// Redis cache, and a just-created notice must still be immediately readable.
var home = await LoadHomeAsync(cancellationToken);
var notices = home["notices"]?.AsArray();
@@ -9,8 +9,7 @@ public static class ApplicationPaths
var directory = new DirectoryInfo(Path.GetFullPath(start));
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Eis.slnx")) ||
File.Exists(Path.Combine(directory.FullName, "package.json")))
if (File.Exists(Path.Combine(directory.FullName, "Eis.slnx")))
{
return directory.FullName;
}
-8
View File
@@ -8,12 +8,4 @@
<ProjectReference Include="..\Eis.Application\Eis.Application.csproj" />
<ProjectReference Include="..\Eis.Infrastructure\Eis.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\index.html" Link="wwwroot\index.html" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\styles.css" Link="wwwroot\styles.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\app.js" Link="wwwroot\app.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\client\**\*.mjs" Link="wwwroot\src\client\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\china-regions.mjs" Link="wwwroot\src\data\china-regions.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\specialty-types.mjs" Link="wwwroot\src\data\specialty-types.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+3 -67
View File
@@ -1,78 +1,14 @@
using Microsoft.AspNetCore.StaticFiles;
namespace Eis.Web.Frontend;
public static class FrontendAssets
{
private static readonly string[] ClientModules =
[
"api.mjs",
"admin-views.mjs",
"candidate-views.mjs",
"admission-views.mjs",
"admission-plan-editor.mjs",
"public-views.mjs",
"state.mjs",
"ui.mjs",
"table-state.mjs",
"pdf-export.mjs",
"region-select.mjs"
];
public static void MapFrontendAssets(this WebApplication app)
{
app.UseDefaultFiles();
app.UseStaticFiles(CreateStaticOptions());
var repositoryRoot = FindRepositoryRoot(app.Environment.ContentRootPath);
if (repositoryRoot is null)
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = context => context.Context.Response.Headers.CacheControl = "no-cache"
});
app.MapFallbackToFile("index.html");
return;
}
var clientRoot = Path.Combine(repositoryRoot, "src", "client");
foreach (var fileName in ClientModules)
{
MapFile(app, $"/src/client/{fileName}", Path.Combine(clientRoot, fileName), "text/javascript; charset=utf-8");
}
MapFile(app, "/src/data/china-regions.mjs", Path.Combine(repositoryRoot, "src", "data", "china-regions.mjs"), "text/javascript; charset=utf-8");
MapFile(app, "/src/data/specialty-types.mjs", Path.Combine(repositoryRoot, "src", "data", "specialty-types.mjs"), "text/javascript; charset=utf-8");
MapFile(app, "/styles.css", Path.Combine(repositoryRoot, "styles.css"), "text/css; charset=utf-8");
MapFile(app, "/app.js", Path.Combine(repositoryRoot, "app.js"), "text/javascript; charset=utf-8");
var indexPath = Path.Combine(repositoryRoot, "index.html");
MapFile(app, "/", indexPath, "text/html; charset=utf-8");
MapFile(app, "/index.html", indexPath, "text/html; charset=utf-8");
app.MapFallback(async context =>
{
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.Headers.CacheControl = "no-cache";
await context.Response.SendFileAsync(indexPath, context.RequestAborted);
});
}
private static void MapFile(WebApplication app, string route, string path, string contentType) =>
app.MapGet(route, (HttpContext context) =>
{
context.Response.Headers.CacheControl = "no-cache";
return Results.File(path, contentType);
});
private static StaticFileOptions CreateStaticOptions() => new()
{
OnPrepareResponse = SetNoCache
};
private static void SetNoCache(StaticFileResponseContext context) =>
context.Context.Response.Headers.CacheControl = "no-cache";
private static string? FindRepositoryRoot(string contentRoot)
{
var candidate = Path.GetFullPath(Path.Combine(contentRoot, "..", ".."));
return File.Exists(Path.Combine(candidate, "package.json")) && File.Exists(Path.Combine(candidate, "index.html"))
? candidate
: null;
}
}
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#132451" />
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
<title>衡准 · 考试信息管理系统</title>
<link rel="stylesheet" href="/styles.css?v=20260722" />
<link rel="stylesheet" href="/styles.css?v=20260723" />
</head>
<body>
<div id="app" aria-live="polite">
@@ -21,6 +21,6 @@
<span class="toast-icon"></span>
<div><strong>操作成功</strong><small>更改已保存</small></div>
</div>
<script type="module" src="/app.js?v=20260722"></script>
<script type="module" src="/js/app.js?v=20260723"></script>
</body>
</html>
+12 -12
View File
@@ -1,15 +1,15 @@
import { api } from './src/client/api.mjs';
import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
import { createCandidateViews } from './src/client/candidate-views.mjs';
import { createAdmissionViews } from './src/client/admission-views.mjs';
import { admissionCategoryEditor, indicatorAllocationEditor } from './src/client/admission-plan-editor.mjs';
import { createPublicViews } from './src/client/public-views.mjs';
import { state } from './src/client/state.mjs';
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs';
import { specialtyCatalog } from './src/data/specialty-types.mjs';
import { getTableControl, setTableControl } from './src/client/table-state.mjs';
import { downloadAdmissionNotice, downloadScoreReport } from './src/client/pdf-export.mjs';
import { api } from './client/api.js';
import { createAdminViews, numberSegmentMeta } from './client/admin-views.js';
import { createCandidateViews } from './client/candidate-views.js';
import { createAdmissionViews } from './client/admission-views.js';
import { admissionCategoryEditor, indicatorAllocationEditor } from './client/admission-plan-editor.js';
import { createPublicViews } from './client/public-views.js';
import { state } from './client/state.js';
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './client/ui.js';
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './client/region-select.js';
import { specialtyCatalog } from './data/specialty-types.js';
import { getTableControl, setTableControl } from './client/table-state.js';
import { downloadAdmissionNotice, downloadScoreReport } from './client/pdf-export.js';
const app = document.querySelector('#app');
const modalRoot = document.querySelector('#modalRoot');
@@ -1,7 +1,7 @@
import { formatRegionAddress } from './region-select.mjs';
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
import { filterTableItems } from './table-state.mjs';
import { formatRegionAddress } from './region-select.js';
import { admissionCategoriesEditor } from './admission-plan-editor.js';
import { specialtyLabel } from '../data/specialty-types.js';
import { filterTableItems } from './table-state.js';
export const numberSegmentMeta = {
year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'],
@@ -1,4 +1,4 @@
import { specialtyCatalog } from '../data/specialty-types.mjs';
import { specialtyCatalog } from '../data/specialty-types.js';
export function indicatorAllocationEditor(h, sourceSchools = [], allocation = {}) {
return `<div class="indicator-allocation-row"><label><span>生源学校</span><select name="indicatorSchool"><option value="">请选择生源校</option>${sourceSchools.map(school => `<option value="${h(school.id)}" ${school.id === allocation.sourceSchoolId ? 'selected' : ''}>${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><label><span>分配名额</span><input name="indicatorQuota" type="number" min="1" step="1" value="${h(allocation.quota || '')}" placeholder="人数"></label><button type="button" class="row-action" data-action="remove-indicator-allocation">移除</button></div>`;
@@ -82,6 +82,6 @@ export function createAdmissionViews(context) {
}
return { renderAdmission };
}
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
import { filterTableItems } from './table-state.mjs';
import { admissionCategoriesEditor } from './admission-plan-editor.js';
import { specialtyLabel } from '../data/specialty-types.js';
import { filterTableItems } from './table-state.js';
@@ -1,5 +1,5 @@
import { mountRegionSelects } from './region-select.mjs';
import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.mjs';
import { mountRegionSelects } from './region-select.js';
import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.js';
export function createCandidateViews(context) {
const {
@@ -1,4 +1,4 @@
import { filterTableItems } from './table-state.mjs';
import { filterTableItems } from './table-state.js';
export function createPublicViews(context) {
const {
@@ -1,4 +1,4 @@
import { chinaRegions } from '../data/china-regions.mjs';
import { chinaRegions } from '../data/china-regions.js';
const option = (value, label, selected = false) => `<option value="${value}" ${selected ? 'selected' : ''}>${label}</option>`;
-181
View File
@@ -1,181 +0,0 @@
import { createClient } from 'redis';
function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
}
function disabledCache(status = 'disabled', { ttlSeconds = 60, maxEntries = 200 } = {}) {
const values = new Map();
const pending = new Map();
const generations = new Map();
const cacheKey = (namespace, key) => `${namespace}:${key}`;
const generation = namespace => generations.get(namespace) || 0;
const prune = () => {
const now = Date.now();
for (const [key, entry] of values) if (entry.expiresAt <= now) values.delete(key);
while (values.size > maxEntries) values.delete(values.keys().next().value);
};
return {
enabled: false,
status,
async remember(namespace, key, loader, options = {}) {
const fullKey = cacheKey(namespace, key);
const cached = values.get(fullKey);
if (cached && cached.expiresAt > Date.now()) return cached.value;
if (cached) values.delete(fullKey);
const startedGeneration = generation(namespace);
const active = pending.get(fullKey);
if (active?.generation === startedGeneration) return active.promise;
const loading = Promise.resolve(loader()).then(value => {
if (generation(namespace) === startedGeneration) {
const lifetime = positiveInteger(options.ttlSeconds, ttlSeconds, 86400);
values.set(fullKey, { value, expiresAt: Date.now() + lifetime * 1000 });
prune();
}
return value;
}).finally(() => {
if (pending.get(fullKey)?.promise === loading) pending.delete(fullKey);
});
pending.set(fullKey, { generation: startedGeneration, promise: loading });
return loading;
},
async invalidate(namespace) {
generations.set(namespace, generation(namespace) + 1);
const prefix = `${namespace}:`;
for (const key of values.keys()) if (key.startsWith(prefix)) values.delete(key);
// Preserve the public meaning of this return value: no Redis namespace
// was refreshed, even though the local fallback was invalidated.
return false;
},
async close() {
values.clear();
pending.clear();
}
};
}
export async function createRedisCache({ env = process.env, logger = console, clientFactory = createClient } = {}) {
const url = String(env.REDIS_URL || '').trim();
const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
const localMaxEntries = positiveInteger(env.LOCAL_CACHE_MAX_ENTRIES, 200, 5000);
if (!url) return disabledCache('disabled', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
const fallback = disabledCache('unavailable', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
const prefix = String(env.REDIS_CACHE_PREFIX || 'exam-information')
.trim()
.replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information';
const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000);
const pending = new Map();
let warningReported = false;
const warn = error => {
if (warningReported) return;
warningReported = true;
logger.warn(`Redis 缓存暂不可用,已回源数据库:${error?.message || error}`);
};
const client = clientFactory({
url,
socket: {
connectTimeout,
reconnectStrategy(retries) {
return retries >= 3 ? false : Math.min(100 * 2 ** retries, 1000);
}
}
});
client.on('error', warn);
client.on('ready', () => {
warningReported = false;
});
try {
await client.connect();
} catch (error) {
warn(error);
if (client.isOpen) client.destroy();
return fallback;
}
const versionKey = namespace => `${prefix}:namespace:${namespace}`;
async function namespaceVersion(namespace) {
const key = versionKey(namespace);
const current = await client.get(key);
if (current) return current;
await client.set(key, '1', { NX: true });
return (await client.get(key)) || '1';
}
return {
get enabled() {
return client.isReady;
},
get status() {
return client.isReady ? 'ready' : 'unavailable';
},
async remember(namespace, key, loader, { ttlSeconds = defaultTtlSeconds } = {}) {
if (!client.isReady) return fallback.remember(namespace, key, loader, { ttlSeconds });
try {
const version = await namespaceVersion(namespace);
const cacheKey = `${prefix}:${namespace}:${version}:${key}`;
const cached = await client.get(cacheKey);
if (cached !== null) return JSON.parse(cached);
if (pending.has(cacheKey)) return pending.get(cacheKey);
const loading = Promise.resolve(loader()).then(async value => {
if (client.isReady) {
try {
await client.set(cacheKey, JSON.stringify(value), {
EX: positiveInteger(ttlSeconds, defaultTtlSeconds, 86400)
});
} catch (error) {
warn(error);
}
}
return value;
}).finally(() => pending.delete(cacheKey));
pending.set(cacheKey, loading);
return loading;
} catch (error) {
warn(error);
return fallback.remember(namespace, key, loader, { ttlSeconds });
}
},
async invalidate(namespace) {
await fallback.invalidate(namespace);
if (!client.isReady) return false;
try {
await client.incr(versionKey(namespace));
return true;
} catch (error) {
warn(error);
return false;
}
},
async close() {
await fallback.close();
if (client.isOpen) await client.quit();
}
};
}
export function withCacheInvalidation(database, cache, namespaces = ['public']) {
const resolveNamespaces = typeof namespaces === 'function' ? namespaces : () => namespaces;
return new Proxy(database, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== 'function') return value;
if (property === 'read' || property === 'close') return value.bind(target);
return async (...args) => {
const result = await value.apply(target, args);
const affected = [...new Set(resolveNamespaces(property, args, result) || [])];
await Promise.all(affected.map(namespace => cache.invalidate(namespace)));
return result;
};
}
});
}
-90
View File
@@ -1,90 +0,0 @@
import { CURRENT_SCHEMA_VERSION } from '../database/version.mjs';
const admissionNumberRules = (nowIso) => [
{
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
description: '适合县区统一组织,号码直接反映县区、考试考场与座位。', separator: '', example: '32070603108', active: true, createdAt: nowIso(),
segments: [
{ source: 'district_code', label: '县区编号', width: 6 },
{ source: 'exam_room_code', label: '考场号', width: 3 },
{ source: 'seat', label: '座位号', width: 2 }
]
},
{
id: 'admit_rule_district_room_sequence', code: 'district_room_sequence', name: '县区号 + 考场号 + 流水号',
description: '以县区为流水边界,适合不希望座位号直接出现在号码中的场景。', separator: '', example: '3207060310028', active: true, createdAt: nowIso(),
segments: [
{ source: 'district_code', label: '县区号', width: 6 },
{ source: 'exam_room_code', label: '考场号', width: 3 },
{ source: 'sequence', label: '流水号', width: 4 }
]
},
{
id: 'admit_rule_center_school_room_seat', code: 'center_school_room_seat', name: '考点学校代码 + 考场号 + 座位号',
description: '号码前缀取考点所属学校代码,便于考点现场快速识别。', separator: '', example: 'HZ0303108', active: true, createdAt: nowIso(),
segments: [
{ source: 'center_school_code', label: '考点学校代码' },
{ source: 'exam_room_code', label: '考场号', width: 3 },
{ source: 'seat', label: '座位号', width: 2 }
]
},
{
id: 'admit_rule_candidate_school_room_seat', code: 'candidate_school_room_seat', name: '考生学校代码 + 考场号 + 座位号',
description: '号码前缀保留考生学籍学校代码,适合按生源学校归档。', separator: '', example: 'HZ0103108', active: true, createdAt: nowIso(),
segments: [
{ source: 'candidate_school_code', label: '考生学校代码' },
{ source: 'exam_room_code', label: '考场号', width: 3 },
{ source: 'seat', label: '座位号', width: 2 }
]
}
];
const workflows = (adminId, nowIso) => [
{ 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' }
] },
{ id: 'workflow_score_appeal', businessType: 'score_appeal', name: '考生成绩复议', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
{ id: 'workflow_score_appeal_step_1', position: 1, name: '班级情况核验', adminLevel: 'class' },
{ id: 'workflow_score_appeal_step_2', position: 2, name: '学校成绩复核', adminLevel: 'school' },
{ id: 'workflow_score_appeal_step_3', position: 3, name: '考试中心终审', adminLevel: 'super' }
] }
];
export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} }) {
const adminId = 'usr_admin';
const createdAt = nowIso();
return {
meta: { version: CURRENT_SCHEMA_VERSION, createdAt },
settings: { selfRegistrationEnabled: false },
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
schools: [], classes: [],
users: [{
id: adminId, username: initialAdmin.username || 'admin', passwordHash: hashPassword(initialAdmin.password || 'Admin123!'),
role: 'admin', adminLevel: 'super', displayName: initialAdmin.displayName || '系统管理员', active: true, createdAt
}],
candidateProfiles: [], notices: [], exams: [], registrations: [], results: [],
testCenters: [], testRooms: [], centerChangeRequests: [], centerChangeRooms: [],
admissionNumberRules: admissionNumberRules(nowIso), arrangementPlans: [],
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: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], admissionRecords: [], auditLogs: []
};
}
-25
View File
@@ -1,25 +0,0 @@
import { chinaRegions } from './china-regions.mjs';
export function resolveRegion(input = {}) {
const provinceCode = String(input.provinceCode || '').trim();
const cityCode = String(input.cityCode || '').trim();
const districtCode = String(input.districtCode || '').trim();
const province = chinaRegions.find(item => item.code === provinceCode);
const city = province?.cities.find(item => item.code === cityCode);
const district = city?.districts.find(item => item.code === districtCode);
if (!province || !city || !district) return null;
return {
provinceCode: province.code,
provinceName: province.name,
cityCode: city.code,
cityName: city.name,
districtCode: district.code,
districtName: district.name
};
}
export function regionLabel(region = {}) {
return [region.provinceName, region.cityName, region.districtName]
.filter((value, index, values) => value && value !== values[index - 1])
.join('');
}
-396
View File
@@ -1,396 +0,0 @@
export function createSeedDatabase({ nowIso, hashPassword, candidateCount = 1200 }) {
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';
const testPasswordHash = hashPassword('12345678');
const mainSubjectDefinitions = [
{ id: 'sub_chinese', name: '语文', date: '2026-06-20', start: '09:00', end: '11:00', fullScore: 120 },
{ id: 'sub_math', name: '数学', date: '2026-06-20', start: '14:30', end: '16:30', fullScore: 120 },
{ id: 'sub_english', name: '外语', date: '2026-06-21', start: '09:00', end: '11:00', fullScore: 120 },
{ id: 'sub_history', name: '历史', date: '2026-06-21', start: '14:30', end: '15:45', fullScore: 75 },
{ id: 'sub_politics', name: '政治', date: '2026-06-21', start: '16:10', end: '17:25', fullScore: 75 },
{ id: 'sub_physics', name: '物理', date: '2026-06-22', start: '09:00', end: '10:20', fullScore: 80 },
{ id: 'sub_chemistry', name: '化学', date: '2026-06-22', start: '10:45', end: '12:00', fullScore: 70 },
{ id: 'sub_experiment', name: '实验', date: '2026-06-22', start: '14:30', end: '15:00', fullScore: 20 },
{ id: 'sub_it', name: '信息技术', date: '2026-06-22', start: '15:30', end: '16:00', fullScore: 10 }
].map((subject, index) => ({
...subject, fee: 0, passRule: 'fixed_score', passValue: subject.fullScore * 0.6,
passScore: subject.fullScore * 0.6, order: index + 1
}));
const mainSubjectIds = mainSubjectDefinitions.map(subject => subject.id);
const mainCandidateCount = Math.max(1, Math.trunc(Number(candidateCount) || 1200));
const specialtyCandidateCount = Math.min(150, mainCandidateCount);
// 固定种子使每次导入得到相同的近似正态成绩,便于复现测试。
let randomState = 0x20260620;
const seededRandom = () => {
randomState = (randomState + 0x6D2B79F5) >>> 0;
let value = randomState;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
const normalRandom = () => {
const first = Math.max(seededRandom(), Number.EPSILON);
return Math.sqrt(-2 * Math.log(first)) * Math.cos(2 * Math.PI * seededRandom());
};
const normalScore = fullScore => Number(Math.min(fullScore, Math.max(0, fullScore * 0.72 + fullScore * 0.14 * normalRandom())).toFixed(1));
const scoreGrade = (score, fullScore) => score >= fullScore * 0.9 ? 'A' : score >= fullScore * 0.75 ? 'B' : score >= fullScore * 0.6 ? 'C' : 'D';
const database = {
meta: { version: 15, 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 号', isSourceSchool: true, isAdmissionSchool: false, active: true },
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', isSourceSchool: true, isAdmissionSchool: false, 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: testPasswordHash, role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() },
{ id: 'usr_supervisor', username: 'supervisor', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() },
{ id: schoolAdminId, username: 'school_admin', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() },
{ id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() },
{ id: 'usr_class_admin', username: 'class_admin', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() },
{ id: 'usr_class_admin_2', username: 'class_admin_2', passwordHash: testPasswordHash, 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: testPasswordHash, role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: false, 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',
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区',
address: '学府路 8 号', emergencyContact: '周建国', emergencyPhone: '13900139000',
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000',
specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyTypes: ['fine_arts'], specialtyCertificate: 'ART-DEMO-0001', policyEligibility: '特长生资格已核验', profileCompleted: true,
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-ZK', name: '2026 年海州市初中学业水平考试', description: '演示数据主考试:覆盖成绩发布、特长生和第一轮志愿填报。',
registrationStart: '2026-04-01T00:00:00.000Z', registrationEnd: '2026-04-30T15:59:59.000Z',
examStart: '2026-06-20T01:00:00.000Z', examEnd: '2026-06-22T08:00:00.000Z',
admitDownloadStart: '2026-06-10T00:00:00.000Z', admitDownloadEnd: '2026-06-20T00:45:00.000Z',
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-03-18T02:00:00.000Z',
subjects: mainSubjectDefinitions
},
{
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: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: [
{ id: 'mock_sub_chinese', name: '语文', date: '2026-11-08', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
{ id: 'mock_sub_math', name: '数学', date: '2026-11-08', start: '14:30', end: '16:30', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
{ id: 'mock_sub_english', name: '外语', date: '2026-11-09', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 }
]
}
],
registrations: [
{
id: registrationId, userId: candidateId, examId, subjectIds: mainSubjectIds,
status: 'approved', paymentStatus: 'paid', paidAt: '2026-05-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-04-08T05:18:00.000Z', reviewedAt: '2026-04-18T08:32:00.000Z', reviewNote: '报名审核通过', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', featureScore: 90
}
],
results: [],
testCenters: [
{ id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区', address: '学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A001、002;实验楼:机考 01', updatedAt: nowIso() },
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320703', districtName: '连云区', 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: [],
admissionNumberRules: [
{
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
description: '适合县区统一组织,号码直接反映县区、考试考场与座位。', separator: '', example: '32070603108', active: true, createdAt: nowIso(),
segments: [
{ source: 'district_code', label: '县区编号', width: 6 },
{ source: 'exam_room_code', label: '考场号', width: 3 },
{ source: 'seat', label: '座位号', width: 2 }
]
},
{
id: 'admit_rule_district_room_sequence', code: 'district_room_sequence', name: '县区号 + 考场号 + 流水号',
description: '以县区为流水边界,适合不希望座位号直接出现在号码中的场景。', separator: '', example: '3207060310028', active: true, createdAt: nowIso(),
segments: [
{ source: 'district_code', label: '县区号', width: 6 },
{ source: 'exam_room_code', label: '考场号', width: 3 },
{ source: 'sequence', label: '流水号', width: 4 }
]
},
{
id: 'admit_rule_center_school_room_seat', code: 'center_school_room_seat', name: '考点学校代码 + 考场号 + 座位号',
description: '号码前缀取考点所属学校代码,便于考点现场快速识别。', separator: '', example: 'HZ0303108', active: true, createdAt: nowIso(),
segments: [
{ source: 'center_school_code', label: '考点学校代码' },
{ source: 'exam_room_code', label: '考场号', width: 3 },
{ source: 'seat', label: '座位号', width: 2 }
]
},
{
id: 'admit_rule_candidate_school_room_seat', code: 'candidate_school_room_seat', name: '考生学校代码 + 考场号 + 座位号',
description: '号码前缀保留考生学籍学校代码,适合按生源学校归档。', separator: '', example: 'HZ0103108', active: true, createdAt: nowIso(),
segments: [
{ source: 'candidate_school_code', label: '考生学校代码' },
{ source: 'exam_room_code', label: '考场号', width: 3 },
{ source: 'seat', label: '座位号', width: 2 }
]
}
],
arrangementPlans: [],
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' }
] },
{ id: 'workflow_score_appeal', businessType: 'score_appeal', name: '考生成绩复议', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
{ id: 'workflow_score_appeal_step_1', position: 1, name: '班级情况核验', adminLevel: 'class' },
{ id: 'workflow_score_appeal_step_2', position: 2, name: '学校成绩复核', adminLevel: 'school' },
{ id: 'workflow_score_appeal_step_3', position: 3, name: '考试中心终审', adminLevel: 'super' }
] }
],
workflowInstances: [],
workflowActions: [],
admissionRecords: [],
auditLogs: [
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
]
};
const sourceSchools = [
{ key: 'hz1', id: 'school_hz1', code: 'HZ01', name: '海州市第一中学', districtCode: '320706', districtName: '海州区', address: '学府路 8 号' },
{ key: 'hz3', id: 'school_hz3', code: 'HZ03', name: '海州市第三中学', districtCode: '320703', districtName: '连云区', address: '育才路 16 号' },
{ key: 'hz5', id: 'school_hz5', code: 'HZ05', name: '海州市第五中学', districtCode: '320707', districtName: '赣榆区', address: '青口路 28 号' },
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' },
{ key: 'hz9', id: 'school_hz9', code: 'HZ09', name: '海州市第九中学', districtCode: '320724', districtName: '灌南县', address: '新安路 39 号' }
];
const admissionSchools = [
{ key: 'admission_1', id: 'school_admission_1', code: 'AD01', name: '海州市高级中学', address: '江苏省连云港市海州区苍梧路 100 号' },
{ key: 'admission_2', id: 'school_admission_2', code: 'AD02', name: '海州市实验高级中学', address: '江苏省连云港市连云区海棠路 88 号' },
{ key: 'admission_3', id: 'school_admission_3', code: 'AD03', name: '海州市外国语高级中学', address: '江苏省连云港市赣榆区黄海路 66 号' }
];
for (const school of sourceSchools) {
if (!database.schools.some(item => item.id === school.id)) {
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, isSourceSchool: true, isAdmissionSchool: false, active: true });
}
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
const classId = `class_${school.key}_30${classIndex}`;
if (!database.classes.some(item => item.id === classId)) {
database.classes.push({ id: classId, schoolId: school.id, name: `高三(${classIndex})班`, grade: '高三', active: true });
}
}
}
for (const school of admissionSchools) {
database.schools.push({ id: school.id, name: school.name, code: school.code, address: school.address, isSourceSchool: false, isAdmissionSchool: true, active: true });
database.users.push({
id: `usr_${school.key}`, username: `${school.key}_admin`, passwordHash: testPasswordHash, role: 'admission_school',
schoolId: school.id, displayName: `${school.name}招生办`, active: true, mustChangePassword: false, createdAt: nowIso()
});
}
for (const school of sourceSchools) {
if (school.id !== 'school_hz1') {
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
database.users.push({
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
});
}
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
const classId = `class_${school.key}_30${classIndex}`;
if (classId === 'class_hz1_302') continue;
database.users.push({
id: `usr_test_class_admin_${school.key}_${classIndex}`, username: `test_class_admin_${school.key}_${classIndex}`,
passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: school.id, classId,
displayName: `${school.name}高三${classIndex}班测试班管`, active: true, createdAt: nowIso()
});
}
}
for (const school of sourceSchools) {
let center = database.testCenters.find(item => item.schoolId === school.id);
if (!center) {
center = {
id: `center_${school.key}`, schoolId: school.id, code: `${school.code}-C01`, name: `${school.name}考点`,
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市',
districtCode: school.districtCode, districtName: school.districtName, address: school.address,
contact: '0518-8602 0000', managerName: '测试负责人', managerPhone: '13800000000', emergencyPhone: '0518-8602 0120',
gateOpenTime: '07:00', transport: '测试数据:请以正式考点通知为准', status: 'active', notes: '仅供手动导入测试数据使用', rooms: '', updatedAt: nowIso()
};
database.testCenters.push(center);
}
for (let roomIndex = 1; roomIndex <= 4; roomIndex += 1) {
const roomId = `room_${school.key}_test_${roomIndex}`;
if (!database.testRooms.some(item => item.id === roomId)) {
database.testRooms.push({
id: roomId, centerId: center.id, code: `T0${roomIndex}`, name: `测试第 ${roomIndex} 考场`, building: '测试教学楼',
floor: `${Math.ceil(roomIndex / 2)}`, capacity: 30, seatPlan: '等待正式编排', roomType: 'standard', status: 'active', notes: '未编排'
});
}
}
}
// 测试库停留在考场编排前:不预置编排计划或准考证;主考试成绩与志愿已完成。
const familyNames = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫'];
const givenNames = ['子涵', '梓萱', '宇航', '雨欣', '浩然', '思远', '佳宁', '晨曦', '明轩', '若彤', '嘉诚', '欣怡'];
const specialtyDefinitions = [
{ category: 'sports', type: 'track_field', label: '田径' },
{ category: 'sports', type: 'basketball', label: '篮球' },
{ category: 'arts', type: 'fine_arts', label: '美术' },
{ category: 'arts', type: 'vocal_music', label: '声乐' },
{ category: 'arts', type: 'dance', label: '舞蹈' }
];
for (let index = 0; index < mainCandidateCount - 1; index += 1) {
const serial = index + 1001;
const school = sourceSchools[index % sourceSchools.length];
const classIndex = Math.floor(index / sourceSchools.length) % 3 + 1;
const classId = `class_${school.key}_30${classIndex}`;
const gender = index % 2 === 0 ? '男' : '女';
const genderCode = gender === '男' ? 'M' : 'F';
const userId = `usr_bulk_${String(index + 1).padStart(4, '0')}`;
const profileId = `profile_bulk_${String(index + 1).padStart(4, '0')}`;
const registrationIdBulk = `reg_bulk_${String(index + 1).padStart(4, '0')}`;
const candidateNumber = `2026-${school.code}-${genderCode}-${String(serial).padStart(4, '0')}`;
const createdAt = new Date(Date.UTC(2026, 3, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
const name = `${familyNames[index % familyNames.length]}${givenNames[Math.floor(index / familyNames.length) % givenNames.length]}${Math.floor(index / 144) + 1}`;
const idNumber = `3207002008${String(index % 12 + 1).padStart(2, '0')}${String(index % 28 + 1).padStart(2, '0')}${String(index + 1).padStart(4, '0')}`;
const phone = `138${String(10000000 + index).padStart(8, '0')}`;
database.users.push({
id: userId, username: candidateNumber, candidateNumber, passwordHash: testPasswordHash, role: 'candidate',
displayName: name, active: true, mustChangePassword: false, createdAt
});
const isSpecialtyCandidate = index < specialtyCandidateCount - 1;
const specialty = isSpecialtyCandidate ? specialtyDefinitions[index % specialtyDefinitions.length] : null;
database.candidateProfiles.push({
id: profileId, userId, name, gender, idNumber, phone, email: `candidate${String(index + 1).padStart(4, '0')}@example.test`,
school: school.name, grade: `高三(${classIndex})班`, schoolId: school.id, classId,
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市',
districtCode: school.districtCode, districtName: school.districtName, address: `${school.address}测试宿舍 ${index % 20 + 1}`,
emergencyContact: `${familyNames[index % familyNames.length]}家长`, emergencyPhone: `139${String(10000000 + index).padStart(8, '0')}`,
nativePlace: `江苏${school.districtName}`, birthDate: `2008-${String(index % 12 + 1).padStart(2, '0')}-${String(index % 28 + 1).padStart(2, '0')}`,
ethnicity: index % 19 === 0 ? '回族' : '汉族', postalCode: '222000', guardianName: `${familyNames[index % familyNames.length]}家长`,
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`,
specialtyCategory: specialty?.category || '', specialtyType: specialty?.type || '', specialtyTypes: specialty ? [specialty.type] : [],
specialtyCertificate: specialty ? `SPECIAL-2026-${String(index + 2).padStart(4, '0')}` : '', policyEligibility: specialty ? `${specialty.label}特长生资格已核验` : '',
profileCompleted: true, status: 'approved', reviewNote: '批量演示数据:学籍核验通过',
reviewedAt: '2026-04-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
});
const paymentStatus = index % 2 === 0 ? 'paid' : 'unpaid';
const classAdminId = classId === 'class_hz1_302' ? 'usr_class_admin' : `usr_test_class_admin_${school.key}_${classIndex}`;
database.registrations.push({
id: registrationIdBulk, userId, examId, subjectIds: mainSubjectIds, status: 'approved', paymentStatus,
paidAt: paymentStatus === 'paid' ? '2026-05-18T08:30:00.000Z' : null,
paidBy: paymentStatus === 'paid' ? classAdminId : null,
createdAt, reviewedAt: '2026-04-30T08:00:00.000Z', reviewNote: '批量演示数据:报名审核通过',
registrationNumber: candidateNumber, numberRuleId: 'rule_default',
featureScore: specialty ? Number((80 + seededRandom() * 20).toFixed(1)) : 0
});
}
const admissionCreatedAt = '2026-07-01T00:00:00.000Z';
database.admissionRecords.push({
id: 'admission_setting_main_2026', kind: 'setting', examId, userId: adminId, schoolId: null, status: 'closed',
payload: {
enabled: true, preferenceStart: '2026-07-01T00:00:00.000Z', preferenceEnd: '2026-07-15T15:59:59.000Z',
maxChoices: 3, maxSubmissions: 1, round: 1, autoPublish: true, progress: '第一轮志愿已全部填报完毕,等待投档'
},
createdAt: admissionCreatedAt, updatedAt: '2026-07-16T00:00:00.000Z'
});
for (const school of admissionSchools) {
database.admissionRecords.push({
id: `admission_plan_${school.key}`, kind: 'plan', examId, userId: `usr_${school.key}`, schoolId: school.id, status: 'approved',
payload: {
categories: [
{ code: 'general', name: '普通生', quota: 350, specialtyCategory: '', specialtyType: '', indicatorAllocations: [] },
{ code: 'sports', name: '体育特长生', quota: 1, specialtyCategory: 'sports', specialtyType: '', indicatorAllocations: [] },
{ code: 'arts', name: '艺术特长生', quota: 1, specialtyCategory: 'arts', specialtyType: '', indicatorAllocations: [] }
],
note: '演示数据招生计划:普通类 350 人,特长生合计 2 人', submittedBy: `${school.name}招生办`, reviewedBy: '林老师', reviewedAt: admissionCreatedAt, publicVisible: true
},
createdAt: admissionCreatedAt, updatedAt: admissionCreatedAt
});
}
const mainRegistrations = database.registrations.filter(registration => registration.examId === examId);
for (const [candidateIndex, registration] of mainRegistrations.entries()) {
const profile = database.candidateProfiles.find(item => item.userId === registration.userId);
const isSpecialtyCandidate = Boolean(profile?.specialtyCategory);
for (const subject of mainSubjectDefinitions) {
const score = normalScore(subject.fullScore);
database.results.push({
id: `result_main_${String(candidateIndex + 1).padStart(4, '0')}_${subject.id.slice(4)}`,
registrationId: registration.id, subjectId: subject.id, score, grade: scoreGrade(score, subject.fullScore),
published: true, updatedAt: '2026-06-30T08:00:00.000Z', publishedAt: '2026-06-30T08:00:00.000Z'
});
}
const rotatedAdmissionSchools = admissionSchools.map((_, offset) => admissionSchools[(candidateIndex + offset) % admissionSchools.length]);
const choices = rotatedAdmissionSchools.map((school, choiceIndex) => ({
schoolId: school.id,
categoryCode: isSpecialtyCandidate && choiceIndex === 0 ? profile.specialtyCategory : 'general',
preferenceType: 'general'
}));
database.admissionRecords.push({
id: `preference_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'preference', examId,
userId: registration.userId, schoolId: null, status: 'submitted',
payload: {
round: 1, submissionCount: 1, submittedAt: new Date(Date.UTC(2026, 6, 5 + (candidateIndex % 10), 1 + (candidateIndex % 8), candidateIndex % 60)).toISOString(),
choices
},
createdAt: admissionCreatedAt, updatedAt: '2026-07-15T08:00:00.000Z'
});
database.admissionRecords.push({
id: `qualification_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'indicator_qualification', examId,
userId: registration.userId, schoolId: profile.schoolId, status: 'confirmed',
payload: { eligible: isSpecialtyCandidate, confirmedAt: '2026-06-28T08:00:00.000Z', note: isSpecialtyCandidate ? '特长资格核验通过' : '普通生' },
createdAt: '2026-06-28T08:00:00.000Z', updatedAt: '2026-06-28T08:00:00.000Z'
});
}
return database;
}
-455
View File
@@ -1,455 +0,0 @@
import { synchronizeMysqlPartitions } from './partition-storage.mjs';
import { createStateCache } from './state-cache.mjs';
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
});
const mysqlTableNames = mysqlSchema.map(statement =>
statement.match(/^CREATE TABLE IF NOT EXISTS\s+([a-z0-9_]+)/i)?.[1]
).filter(Boolean);
const [databaseTables] = await pool.execute(`
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
`);
const existingTableNames = new Set(databaseTables.map(row => row.TABLE_NAME));
const existingAppTables = mysqlTableNames.filter(table => existingTableNames.has(table));
let hasSchemaMetadata = false;
let existingSchemaVersion = null;
if (existingTableNames.has('schema_metadata')) {
const [metadataRows] = await pool.execute('SELECT id, schema_version FROM schema_metadata WHERE id = 1');
hasSchemaMetadata = metadataRows.length > 0;
existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null;
}
if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17, 18, 19, 20].includes(existingSchemaVersion))) {
for (const table of [...mysqlTableNames].reverse()) {
await pool.query(`DROP TABLE IF EXISTS \`${table}\``);
}
}
for (const statement of mysqlSchema) await pool.query(statement);
// Development schemas are created from the current DDL as a whole. MySQL 8.4 lacks
// MariaDB-style conditional column addition; outdated schemas are rejected by the
// version check below and should be rebuilt instead of migrated column by column.
const [resultLockTriggers] = await pool.execute(`
SELECT TRIGGER_NAME FROM information_schema.TRIGGERS
WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME LIKE 'trg_results_lock_archived_%'
`);
const existingResultLockTriggers = new Set(resultLockTriggers.map(item => item.TRIGGER_NAME));
const mysqlResultLockTriggers = {
trg_results_lock_archived_insert: `CREATE TRIGGER trg_results_lock_archived_insert BEFORE INSERT ON results FOR EACH ROW
BEGIN
IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id = NEW.registration_id AND exam.archived_at IS NOT NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定';
END IF;
END`,
trg_results_lock_archived_update: `CREATE TRIGGER trg_results_lock_archived_update BEFORE UPDATE ON results FOR EACH ROW
BEGIN
IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id IN (OLD.registration_id, NEW.registration_id) AND exam.archived_at IS NOT NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定';
END IF;
END`,
trg_results_lock_archived_delete: `CREATE TRIGGER trg_results_lock_archived_delete BEFORE DELETE ON results FOR EACH ROW
BEGIN
IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id
WHERE registration.id = OLD.registration_id AND exam.archived_at IS NOT NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定';
END IF;
END`
};
for (const [name, statement] of Object.entries(mysqlResultLockTriggers)) {
if (!existingResultLockTriggers.has(name)) await pool.query(statement);
}
const [legacyRegistrationNumberIndexes] = await pool.query("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'");
if (legacyRegistrationNumberIndexes.length) await pool.query('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, schema_version FROM schema_metadata WHERE id = 1');
if (Number(metadataRows[0]?.schema_version || 1) < 9) {
await pool.query('DROP TABLE IF EXISTS admit_card_subjects');
await pool.query('DROP TABLE IF EXISTS admit_cards');
await pool.query('DROP TABLE IF EXISTS exam_arrangement_plans');
await pool.query('DROP TABLE IF EXISTS admission_number_rules');
const admissionTables = ['admission_number_rules', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects'];
for (const table of admissionTables) {
const statement = mysqlSchema.find(item => item.includes(`CREATE TABLE IF NOT EXISTS ${table} (`));
if (!statement) throw new Error(`缺少 ${table} 的 MySQL 表定义`);
await pool.query(statement);
}
const extension = seed();
for (const rule of extension.admissionNumberRules) await pool.execute(
`INSERT INTO admission_number_rules (
id, code, name, description, \`separator\`, segments_json, example, active, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt]
);
await pool.execute('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1');
metadataRows[0].schema_version = 9;
metadataRows[0].app_version = 9;
}
if (Number(metadataRows[0]?.schema_version || 1) < 10) {
await pool.execute(`UPDATE admit_cards card LEFT JOIN test_centers center ON center.id = card.center_id SET
card.center_code = COALESCE(center.code, card.center_code),
card.center_address = COALESCE(CONCAT_WS(' ', center.province_name, center.city_name, center.district_name, center.address), card.center_address)`);
await pool.execute(`UPDATE admit_card_subjects assignment LEFT JOIN test_rooms room ON room.id = assignment.room_id SET
assignment.building = COALESCE(room.building, assignment.building),
assignment.floor = COALESCE(room.floor, assignment.floor)`);
await pool.execute('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1');
metadataRows[0].schema_version = 10;
metadataRows[0].app_version = 10;
}
if (Number(metadataRows[0]?.schema_version || 1) < 11) {
await pool.execute("UPDATE exam_subjects SET pass_rule = 'fixed_score', pass_value = pass_score");
await pool.execute('UPDATE schema_metadata SET schema_version = 11, app_version = 11 WHERE id = 1');
metadataRows[0].schema_version = 11;
metadataRows[0].app_version = 11;
}
if (Number(metadataRows[0]?.schema_version || 1) < 12) {
await pool.execute("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'");
await pool.execute('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1');
metadataRows[0].schema_version = 12;
metadataRows[0].app_version = 12;
}
if (Number(metadataRows[0]?.schema_version || 1) < 13) {
await pool.execute('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1');
metadataRows[0].schema_version = 13;
metadataRows[0].app_version = 13;
}
if (Number(metadataRows[0]?.schema_version || 1) < 15) {
throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动');
}
if (Number(metadataRows[0]?.schema_version || 1) < 16) {
await pool.execute('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1');
metadataRows[0].schema_version = 16;
}
if (Number(metadataRows[0]?.schema_version || 1) < 17) {
await pool.query(`ALTER TABLE users
ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER must_change_password,
ADD COLUMN totp_secret_encrypted VARCHAR(512) NULL AFTER totp_enabled,
ADD COLUMN totp_recovery_codes VARCHAR(2048) NOT NULL DEFAULT '[]' AFTER totp_secret_encrypted,
ADD COLUMN totp_last_used_step BIGINT NULL AFTER totp_recovery_codes`);
await pool.execute('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1');
metadataRows[0].schema_version = 17;
}
if (Number(metadataRows[0]?.schema_version || 1) < 18) {
await pool.query("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'candidate', 'admission_school') NOT NULL");
const [profileColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_types', 'specialty_certificate', 'policy_eligibility')");
const existingProfileColumns = new Set(profileColumns.map(item => item.Field));
if (!existingProfileColumns.has('specialty_types')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()) AFTER guardian_phone');
if (!existingProfileColumns.has('specialty_certificate')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_certificate VARCHAR(255) NULL AFTER specialty_types');
if (!existingProfileColumns.has('policy_eligibility')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN policy_eligibility VARCHAR(255) NULL AFTER specialty_certificate');
await pool.execute('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1');
metadataRows[0].schema_version = 18;
}
if (Number(metadataRows[0]?.schema_version || 1) < 19) {
const [schoolColumns] = await pool.query("SHOW COLUMNS FROM schools WHERE Field IN ('is_source_school', 'is_admission_school')");
const existingSchoolColumns = new Set(schoolColumns.map(item => item.Field));
if (!existingSchoolColumns.has('is_source_school')) await pool.query('ALTER TABLE schools ADD COLUMN is_source_school BOOLEAN NOT NULL DEFAULT TRUE AFTER address');
if (!existingSchoolColumns.has('is_admission_school')) await pool.query('ALTER TABLE schools ADD COLUMN is_admission_school BOOLEAN NOT NULL DEFAULT TRUE AFTER is_source_school');
const [specialtyColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_category', 'specialty_type')");
const existingSpecialtyColumns = new Set(specialtyColumns.map(item => item.Field));
if (!existingSpecialtyColumns.has('specialty_category')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_category VARCHAR(30) NULL AFTER guardian_phone');
if (!existingSpecialtyColumns.has('specialty_type')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_type VARCHAR(40) NULL AFTER specialty_category');
const [registrationColumns] = await pool.query("SHOW COLUMNS FROM registrations WHERE Field = 'feature_score'");
if (!registrationColumns.length) await pool.query('ALTER TABLE registrations ADD COLUMN feature_score DECIMAL(8,2) NOT NULL DEFAULT 0 AFTER number_rule_id');
await pool.execute('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1');
metadataRows[0].schema_version = 19;
}
if (Number(metadataRows[0]?.schema_version || 1) < 20) {
await pool.query("ALTER TABLE admission_records MODIFY COLUMN kind ENUM('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication') NOT NULL");
await pool.execute('UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1');
metadataRows[0].schema_version = 20;
}
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 (Number(metadataRows[0]?.app_version || 1) < 7) {
await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1');
}
if (Number(metadataRows[0]?.schema_version || 1) < 15) {
throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动');
}
}
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, 18, ?, ?, ?)
`, [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.query("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'");
if (!centerCodeIndexes.length) {
await pool.query('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)');
}
const [candidateNumberIndexes] = await pool.query("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'");
if (!candidateNumberIndexes.length) {
await pool.query('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)');
}
const partitionConnection = await pool.getConnection();
try {
await synchronizeMysqlPartitions(partitionConnection);
} finally {
partitionConnection.release();
}
let stateCache;
const transaction = async operations => {
stateCache?.invalidate();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
for (const item of operations) await connection.execute(item.sql, item.params);
await connection.commit();
await synchronizeMysqlPartitions(connection);
} catch (error) {
await connection.rollback();
throw error;
} finally {
stateCache?.invalidate();
connection.release();
}
};
const loadState = 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();
}
};
stateCache = createStateCache({ load: loadState });
return createRepository({
client: 'mysql',
location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`,
read: stateCache.read,
transaction,
close: async () => pool.end()
});
}
return createMysqlStore;
}
-324
View File
@@ -1,324 +0,0 @@
import { createHash } from 'node:crypto';
const identifierPattern = /^[a-z][a-z0-9_]{0,63}$/;
function partitionKey(ownerId) {
return createHash('sha256').update(String(ownerId)).digest('hex').slice(0, 16);
}
function namesForExam(examId) {
const key = partitionKey(examId);
return {
key,
candidates: `exam_${key}_candidates`,
admissions: `exam_${key}_admissions`,
results: `exam_${key}_results`,
centers: `exam_${key}_centers`
};
}
function namesForSchool(schoolId) {
const key = partitionKey(schoolId);
return { key, students: `school_${key}_students` };
}
function quoteSqlite(identifier) {
if (!identifierPattern.test(identifier)) throw new Error(`非法 SQLite 分表名称:${identifier}`);
return `"${identifier}"`;
}
function quoteMysql(identifier) {
if (!identifierPattern.test(identifier)) throw new Error(`非法 MySQL 分表名称:${identifier}`);
return `\`${identifier}\``;
}
function sqliteExamTables(connection, names) {
const candidates = quoteSqlite(names.candidates);
const admissions = quoteSqlite(names.admissions);
const results = quoteSqlite(names.results);
const centers = quoteSqlite(names.centers);
connection.exec(`
CREATE TABLE IF NOT EXISTS ${candidates} (
registration_id TEXT PRIMARY KEY, exam_id TEXT NOT NULL, user_id TEXT NOT NULL,
candidate_number TEXT, candidate_name TEXT NOT NULL, school_id TEXT, class_id TEXT,
registration_status TEXT NOT NULL, payment_status TEXT NOT NULL, registered_at TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS ${admissions} (
registration_id TEXT NOT NULL, subject_id TEXT NOT NULL, exam_id TEXT NOT NULL,
candidate_number TEXT, admission_number TEXT, test_center TEXT, center_code TEXT,
center_address TEXT, room_id TEXT, room_name TEXT, room_code TEXT, exam_room_code TEXT,
building TEXT, floor TEXT, seat TEXT, generated_at TEXT,
PRIMARY KEY (registration_id, subject_id)
) STRICT;
CREATE TABLE IF NOT EXISTS ${results} (
result_id TEXT PRIMARY KEY, exam_id TEXT NOT NULL, registration_id TEXT NOT NULL,
subject_id TEXT NOT NULL, candidate_number TEXT, score REAL NOT NULL, grade TEXT NOT NULL,
published INTEGER NOT NULL CHECK (published IN (0, 1)), updated_at TEXT, published_at TEXT,
UNIQUE (registration_id, subject_id)
) STRICT;
CREATE TABLE IF NOT EXISTS ${centers} (
center_key TEXT PRIMARY KEY, exam_id TEXT NOT NULL, center_id TEXT, center_code TEXT,
center_name TEXT NOT NULL, center_address TEXT NOT NULL, candidate_count INTEGER NOT NULL,
room_count INTEGER NOT NULL
) STRICT;
`);
}
function sqliteSchoolTable(connection, names) {
const students = quoteSqlite(names.students);
connection.exec(`
CREATE TABLE IF NOT EXISTS ${students} (
user_id TEXT PRIMARY KEY, school_id TEXT NOT NULL, candidate_number TEXT,
candidate_name TEXT NOT NULL, id_number TEXT, gender TEXT, class_id TEXT, grade TEXT,
phone TEXT, email TEXT, profile_status TEXT, active INTEGER NOT NULL CHECK (active IN (0, 1)),
updated_at TEXT
) STRICT;
`);
}
function syncSqliteExam(connection, examId, names) {
const candidates = quoteSqlite(names.candidates);
const admissions = quoteSqlite(names.admissions);
const results = quoteSqlite(names.results);
const centers = quoteSqlite(names.centers);
connection.exec(`DELETE FROM ${candidates}; DELETE FROM ${admissions}; DELETE FROM ${results}; DELETE FROM ${centers};`);
connection.prepare(`
INSERT INTO ${candidates} (
registration_id, exam_id, user_id, candidate_number, candidate_name, school_id, class_id,
registration_status, payment_status, registered_at
)
SELECT registration.id, registration.exam_id, registration.user_id, user.candidate_number,
COALESCE(profile.name, user.display_name), COALESCE(profile.school_id, user.school_id),
COALESCE(profile.class_id, user.class_id), registration.status, registration.payment_status,
registration.created_at
FROM registrations registration
JOIN users user ON user.id = registration.user_id
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${admissions} (
registration_id, subject_id, exam_id, candidate_number, admission_number, test_center,
center_code, center_address, room_id, room_name, room_code, exam_room_code,
building, floor, seat, generated_at
)
SELECT registration.id, selected.subject_id, registration.exam_id, user.candidate_number,
card.card_number, card.test_center, card.center_code, card.center_address,
assignment.room_id, assignment.room, assignment.room_code, assignment.exam_room_code,
assignment.building, assignment.floor, assignment.seat, card.generated_at
FROM registrations registration
JOIN users user ON user.id = registration.user_id
JOIN registration_subjects selected ON selected.registration_id = registration.id
LEFT JOIN admit_cards card ON card.registration_id = registration.id
LEFT JOIN admit_card_subjects assignment
ON assignment.registration_id = registration.id AND assignment.subject_id = selected.subject_id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${results} (
result_id, exam_id, registration_id, subject_id, candidate_number, score, grade,
published, updated_at, published_at
)
SELECT result.id, registration.exam_id, result.registration_id, result.subject_id,
user.candidate_number, result.score, result.grade, result.published,
result.updated_at, result.published_at
FROM results result
JOIN registrations registration ON registration.id = result.registration_id
JOIN users user ON user.id = registration.user_id
WHERE registration.exam_id = ?
`).run(examId);
connection.prepare(`
INSERT INTO ${centers} (
center_key, exam_id, center_id, center_code, center_name, center_address,
candidate_count, room_count
)
SELECT COALESCE(card.center_id, 'snapshot:' || card.center_code), registration.exam_id,
card.center_id, card.center_code, MAX(card.test_center), MAX(card.center_address),
COUNT(DISTINCT card.registration_id), COUNT(DISTINCT assignment.room_id)
FROM admit_cards card
JOIN registrations registration ON registration.id = card.registration_id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = card.registration_id
WHERE registration.exam_id = ?
GROUP BY COALESCE(card.center_id, 'snapshot:' || card.center_code), registration.exam_id,
card.center_id, card.center_code
`).run(examId);
}
function syncSqliteSchool(connection, schoolId, names) {
const students = quoteSqlite(names.students);
connection.exec(`DELETE FROM ${students};`);
connection.prepare(`
INSERT INTO ${students} (
user_id, school_id, candidate_number, candidate_name, id_number, gender, class_id,
grade, phone, email, profile_status, active, updated_at
)
SELECT user.id, ?, user.candidate_number, COALESCE(profile.name, user.display_name),
profile.id_number, profile.gender, COALESCE(profile.class_id, user.class_id), profile.grade,
profile.phone, profile.email, profile.status, user.active, profile.updated_at
FROM users user
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?
`).run(schoolId, schoolId);
}
export function synchronizeSqlitePartitions(connection) {
const now = new Date().toISOString();
const exams = connection.prepare('SELECT id FROM exams ORDER BY id').all();
const schools = connection.prepare('SELECT id FROM schools ORDER BY id').all();
for (const { id } of exams) {
const names = namesForExam(id);
sqliteExamTables(connection, names);
connection.prepare(`
INSERT INTO exam_data_partitions (
exam_id, partition_key, candidates_table, admissions_table, results_table, centers_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_id) DO UPDATE SET partition_key = excluded.partition_key,
candidates_table = excluded.candidates_table, admissions_table = excluded.admissions_table,
results_table = excluded.results_table, centers_table = excluded.centers_table,
updated_at = excluded.updated_at
`).run(id, names.key, names.candidates, names.admissions, names.results, names.centers, now, now);
syncSqliteExam(connection, id, names);
}
for (const { id } of schools) {
const names = namesForSchool(id);
sqliteSchoolTable(connection, names);
connection.prepare(`
INSERT INTO school_student_partitions (school_id, partition_key, students_table, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(school_id) DO UPDATE SET partition_key = excluded.partition_key,
students_table = excluded.students_table, updated_at = excluded.updated_at
`).run(id, names.key, names.students, now, now);
syncSqliteSchool(connection, id, names);
}
}
async function mysqlExamTables(connection, names) {
const candidates = quoteMysql(names.candidates);
const admissions = quoteMysql(names.admissions);
const results = quoteMysql(names.results);
const centers = quoteMysql(names.centers);
await connection.query(`CREATE TABLE IF NOT EXISTS ${candidates} (
registration_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL,
candidate_number VARCHAR(120) NULL, candidate_name VARCHAR(120) NOT NULL, school_id VARCHAR(64) NULL,
class_id VARCHAR(64) NULL, registration_status VARCHAR(20) NOT NULL, payment_status VARCHAR(20) NOT NULL,
registered_at VARCHAR(35) NOT NULL, PRIMARY KEY (registration_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${admissions} (
registration_id VARCHAR(64) NOT NULL, subject_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL,
candidate_number VARCHAR(120) NULL, admission_number VARCHAR(120) NULL, test_center VARCHAR(200) NULL,
center_code VARCHAR(60) NULL, center_address VARCHAR(500) NULL, room_id VARCHAR(64) NULL,
room_name VARCHAR(120) NULL, room_code VARCHAR(60) NULL, exam_room_code VARCHAR(120) NULL,
building VARCHAR(120) NULL, floor VARCHAR(60) NULL, seat VARCHAR(60) NULL, generated_at VARCHAR(35) NULL,
PRIMARY KEY (registration_id, subject_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${results} (
result_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, registration_id VARCHAR(64) NOT NULL,
subject_id VARCHAR(64) NOT NULL, candidate_number VARCHAR(120) NULL, score DOUBLE NOT NULL,
grade VARCHAR(30) NOT NULL, published BOOLEAN NOT NULL, updated_at VARCHAR(35) NULL,
published_at VARCHAR(35) NULL, PRIMARY KEY (result_id), UNIQUE KEY uq_registration_subject (registration_id, subject_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
await connection.query(`CREATE TABLE IF NOT EXISTS ${centers} (
center_key VARCHAR(160) NOT NULL, exam_id VARCHAR(64) NOT NULL, center_id VARCHAR(64) NULL,
center_code VARCHAR(60) NULL, center_name VARCHAR(200) NOT NULL, center_address VARCHAR(500) NOT NULL,
candidate_count INT UNSIGNED NOT NULL, room_count INT UNSIGNED NOT NULL, PRIMARY KEY (center_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
}
async function mysqlSchoolTable(connection, names) {
const students = quoteMysql(names.students);
await connection.query(`CREATE TABLE IF NOT EXISTS ${students} (
user_id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL, candidate_number VARCHAR(120) NULL,
candidate_name VARCHAR(120) NOT NULL, id_number VARCHAR(30) NULL, gender VARCHAR(20) NULL,
class_id VARCHAR(64) NULL, grade VARCHAR(60) NULL, phone VARCHAR(60) NULL, email VARCHAR(160) NULL,
profile_status VARCHAR(20) NULL, active BOOLEAN NOT NULL, updated_at VARCHAR(35) NULL,
PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`);
}
async function syncMysqlExam(connection, examId, names) {
const candidates = quoteMysql(names.candidates);
const admissions = quoteMysql(names.admissions);
const results = quoteMysql(names.results);
const centers = quoteMysql(names.centers);
await connection.query(`DELETE FROM ${candidates}`);
await connection.execute(`INSERT INTO ${candidates} (
registration_id, exam_id, user_id, candidate_number, candidate_name, school_id, class_id,
registration_status, payment_status, registered_at
) SELECT registration.id, registration.exam_id, registration.user_id, user.candidate_number,
COALESCE(profile.name, user.display_name), COALESCE(profile.school_id, user.school_id),
COALESCE(profile.class_id, user.class_id), registration.status, registration.payment_status,
registration.created_at FROM registrations registration JOIN users user ON user.id = registration.user_id
LEFT JOIN candidate_profiles profile ON profile.user_id = user.id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${admissions}`);
await connection.execute(`INSERT INTO ${admissions} (
registration_id, subject_id, exam_id, candidate_number, admission_number, test_center,
center_code, center_address, room_id, room_name, room_code, exam_room_code,
building, floor, seat, generated_at
) SELECT registration.id, selected.subject_id, registration.exam_id, user.candidate_number,
card.card_number, card.test_center, card.center_code, card.center_address,
assignment.room_id, assignment.room, assignment.room_code, assignment.exam_room_code,
assignment.building, assignment.floor, assignment.seat, card.generated_at
FROM registrations registration JOIN users user ON user.id = registration.user_id
JOIN registration_subjects selected ON selected.registration_id = registration.id
LEFT JOIN admit_cards card ON card.registration_id = registration.id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = registration.id
AND assignment.subject_id = selected.subject_id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${results}`);
await connection.execute(`INSERT INTO ${results} (
result_id, exam_id, registration_id, subject_id, candidate_number, score, grade,
published, updated_at, published_at
) SELECT result.id, registration.exam_id, result.registration_id, result.subject_id,
user.candidate_number, result.score, result.grade, result.published, result.updated_at, result.published_at
FROM results result JOIN registrations registration ON registration.id = result.registration_id
JOIN users user ON user.id = registration.user_id WHERE registration.exam_id = ?`, [examId]);
await connection.query(`DELETE FROM ${centers}`);
await connection.execute(`INSERT INTO ${centers} (
center_key, exam_id, center_id, center_code, center_name, center_address, candidate_count, room_count
) SELECT COALESCE(card.center_id, CONCAT('snapshot:', card.center_code)), registration.exam_id,
card.center_id, card.center_code, MAX(card.test_center), MAX(card.center_address),
COUNT(DISTINCT card.registration_id), COUNT(DISTINCT assignment.room_id)
FROM admit_cards card JOIN registrations registration ON registration.id = card.registration_id
LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = card.registration_id
WHERE registration.exam_id = ? GROUP BY COALESCE(card.center_id, CONCAT('snapshot:', card.center_code)),
registration.exam_id, card.center_id, card.center_code`, [examId]);
}
async function syncMysqlSchool(connection, schoolId, names) {
const students = quoteMysql(names.students);
await connection.query(`DELETE FROM ${students}`);
await connection.execute(`INSERT INTO ${students} (
user_id, school_id, candidate_number, candidate_name, id_number, gender, class_id,
grade, phone, email, profile_status, active, updated_at
) SELECT user.id, ?, user.candidate_number, COALESCE(profile.name, user.display_name),
profile.id_number, profile.gender, COALESCE(profile.class_id, user.class_id), profile.grade,
profile.phone, profile.email, profile.status, user.active, profile.updated_at
FROM users user LEFT JOIN candidate_profiles profile ON profile.user_id = user.id
WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?`, [schoolId, schoolId]);
}
export async function synchronizeMysqlPartitions(connection) {
const now = new Date().toISOString();
const [exams] = await connection.query('SELECT id FROM exams ORDER BY id');
const [schools] = await connection.query('SELECT id FROM schools ORDER BY id');
for (const { id } of exams) {
const names = namesForExam(id);
await mysqlExamTables(connection, names);
await connection.execute(`INSERT INTO exam_data_partitions (
exam_id, partition_key, candidates_table, admissions_table, results_table, centers_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE partition_key = VALUES(partition_key),
candidates_table = VALUES(candidates_table), admissions_table = VALUES(admissions_table),
results_table = VALUES(results_table), centers_table = VALUES(centers_table), updated_at = VALUES(updated_at)`,
[id, names.key, names.candidates, names.admissions, names.results, names.centers, now, now]);
await syncMysqlExam(connection, id, names);
}
for (const { id } of schools) {
const names = namesForSchool(id);
await mysqlSchoolTable(connection, names);
await connection.execute(`INSERT INTO school_student_partitions (
school_id, partition_key, students_table, created_at, updated_at
) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE partition_key = VALUES(partition_key),
students_table = VALUES(students_table), updated_at = VALUES(updated_at)`,
[id, names.key, names.students, now, now]);
await syncMysqlSchool(connection, id, names);
}
}
-526
View File
@@ -1,526 +0,0 @@
import { synchronizeSqlitePartitions } from './partition-storage.mjs';
import { createStateCache } from './state-cache.mjs';
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;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -65536;
PRAGMA mmap_size = 268435456;
PRAGMA wal_autocheckpoint = 1000;
`);
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'],
['totp_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['totp_secret_encrypted', 'TEXT'],
['totp_recovery_codes', "TEXT NOT NULL DEFAULT '[]'"], ['totp_last_used_step', 'INTEGER'],
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
]);
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
ensureColumns('schools', [
['is_source_school', 'INTEGER NOT NULL DEFAULT 1'], ['is_admission_school', 'INTEGER NOT NULL DEFAULT 1']
]);
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'],
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
['district_code', 'TEXT'], ['district_name', 'TEXT'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"],
['specialty_category', 'TEXT'], ['specialty_type', 'TEXT'],
['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT']
]);
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT'], ['feature_score', 'REAL NOT NULL DEFAULT 0']]);
ensureColumns('exams', [
['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'],
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
]);
ensureColumns('exam_subjects', [
['full_score', 'REAL NOT NULL DEFAULT 150'], ['pass_score', 'REAL NOT NULL DEFAULT 90'],
['pass_rule', "TEXT NOT NULL DEFAULT 'fixed_score'"], ['pass_value', 'REAL NOT NULL DEFAULT 90']
]);
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'],
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
['district_code', 'TEXT'], ['district_name', 'TEXT']
]);
ensureColumns('center_change_requests', [
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
['district_code', 'TEXT'], ['district_name', 'TEXT']
]);
ensureColumns('test_rooms', [['seat_plan', 'TEXT']]);
ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]);
ensureColumns('admit_cards', [
['center_code', "TEXT NOT NULL DEFAULT ''"], ['center_address', "TEXT NOT NULL DEFAULT ''"]
]);
ensureColumns('admit_card_subjects', [
['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"]
]);
if (tableExists('users')) {
const usersSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'").get()?.sql || '';
if (!usersSql.includes('admission_school')) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE users_v18 (
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', 'admission_school')),
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)),
totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)), totp_secret_encrypted TEXT,
totp_recovery_codes TEXT NOT NULL DEFAULT '[]', totp_last_used_step INTEGER, archived_at TEXT,
archived_by TEXT REFERENCES users_v18(id) ON DELETE RESTRICT, display_name TEXT NOT NULL, created_at TEXT NOT NULL
) STRICT;
INSERT INTO users_v18 SELECT id, username, candidate_number, password_hash, role, admin_level, school_id, class_id,
active, must_change_password, COALESCE(totp_enabled, 0), totp_secret_encrypted, COALESCE(totp_recovery_codes, '[]'),
totp_last_used_step, archived_at, archived_by, display_name, created_at FROM users;
DROP TABLE users;
ALTER TABLE users_v18 RENAME TO users;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
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.schema_version || 1) < 9) {
const extension = seed();
connection.exec('PRAGMA foreign_keys = OFF;');
connection.exec('BEGIN IMMEDIATE');
try {
connection.exec(`
DROP TABLE IF EXISTS admit_card_subjects;
DROP TABLE IF EXISTS admit_cards;
DROP TABLE IF EXISTS exam_arrangement_plans;
DROP TABLE IF EXISTS admission_number_rules;
CREATE TABLE admission_number_rules (
id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT NOT NULL,
separator TEXT NOT NULL DEFAULT '', segments_json TEXT NOT NULL, example TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), created_at TEXT NOT NULL
) STRICT;
CREATE TABLE exam_arrangement_plans (
id TEXT PRIMARY KEY, exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE,
number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id),
mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')),
random_seed TEXT NOT NULL, candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0),
center_count INTEGER NOT NULL CHECK (center_count >= 0),
subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0),
subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0),
same_school_center_rate REAL NOT NULL, warnings_json TEXT NOT NULL,
generated_by TEXT REFERENCES users(id) ON DELETE SET NULL, generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE admit_cards (
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
card_number TEXT NOT NULL UNIQUE, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
test_center TEXT NOT NULL, center_code TEXT NOT NULL, center_address TEXT NOT NULL, generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE admit_card_subjects (
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL, room TEXT NOT NULL, room_code TEXT NOT NULL,
exam_room_code TEXT NOT NULL, building TEXT NOT NULL, floor TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL,
PRIMARY KEY (registration_id, subject_id), UNIQUE (subject_id, room_id, seat)
) STRICT;
CREATE INDEX idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
CREATE INDEX idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
`);
for (const rule of extension.admissionNumberRules) connection.prepare(
`INSERT INTO admission_number_rules (
id, code, name, description, separator, segments_json, example, active, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt);
connection.prepare('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
} finally {
try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {}
}
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 10) {
connection.prepare(`
UPDATE admit_cards SET
center_code = COALESCE((SELECT code FROM test_centers WHERE id = admit_cards.center_id), center_code),
center_address = COALESCE((SELECT trim(
COALESCE(province_name, '') || ' ' || COALESCE(city_name, '') || ' ' ||
COALESCE(district_name, '') || ' ' || COALESCE(address, '')
) FROM test_centers WHERE id = admit_cards.center_id), center_address)
`).run();
connection.prepare(`
UPDATE admit_card_subjects SET
building = COALESCE((SELECT building FROM test_rooms WHERE id = admit_card_subjects.room_id), building),
floor = COALESCE((SELECT floor FROM test_rooms WHERE id = admit_card_subjects.room_id), floor)
`).run();
connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 11) {
connection.exec('PRAGMA foreign_keys = OFF;');
connection.exec('BEGIN IMMEDIATE');
try {
connection.prepare("UPDATE exam_subjects SET pass_rule = 'fixed_score', pass_value = pass_score").run();
connection.exec(`
CREATE TABLE results_v11 (
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),
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;
INSERT INTO results_v11 (id, registration_id, subject_id, score, grade, published, updated_at, published_at)
SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results;
DROP TABLE results;
ALTER TABLE results_v11 RENAME TO results;
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
`);
connection.prepare('UPDATE schema_metadata SET schema_version = 11, app_version = 11 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
} finally {
try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {}
}
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 12) {
connection.prepare("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'").run();
connection.prepare('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 13) {
connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 15) {
throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库');
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 16) {
connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 17) {
connection.prepare('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 18) {
connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 19) {
connection.prepare('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 20) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE admission_records_v20 (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')),
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
status TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
INSERT INTO admission_records_v20 SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records;
DROP TABLE admission_records;
ALTER TABLE admission_records_v20 RENAME TO admission_records;
CREATE INDEX idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
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 (existingSystem && Number(existingSystem.app_version || 1) < 7) {
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 15) {
throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库');
}
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, 18, ?, ?, ?)
`).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;
}
}
synchronizeSqlitePartitions(connection);
const dataVersion = connection.prepare('PRAGMA data_version');
const stateCache = createStateCache({
load: () => stateFromRows(readSqliteRows(connection)),
version: () => Number(dataVersion.get().data_version)
});
const transaction = async operations => {
// Call this before the first possible await so request-local mutations of
// the previous snapshot can never be observed by another request.
stateCache.invalidate();
connection.exec('BEGIN IMMEDIATE');
try {
for (const item of operations) connection.prepare(item.sql).run(...item.params);
synchronizeSqlitePartitions(connection);
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
throw error;
} finally {
stateCache.invalidate();
}
};
return createRepository({
client: 'sqlite',
location: path,
read: stateCache.read,
transaction,
close: async () => connection.close()
});
}
return createSqliteStore;
}
-55
View File
@@ -1,55 +0,0 @@
function cacheDuration(value, fallback = 30000) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.min(Math.trunc(parsed), 3600000) : fallback;
}
/**
* Keeps the materialized application state in process instead of rebuilding it
* from every relational table for every HTTP request. Writes explicitly
* invalidate the snapshot; an optional version reader also detects changes made
* by another database connection.
*/
export function createStateCache({ load, version, maxAgeMs = process.env.DATABASE_STATE_CACHE_TTL_MS }) {
// A database-native version token is stronger than a timer, so SQLite can
// keep the snapshot indefinitely while still observing external commits.
const ttlMs = version ? 0 : cacheDuration(maxAgeMs);
let snapshot = null;
let snapshotVersion;
let loadedAt = 0;
let generation = 0;
let pending = null;
function invalidate() {
generation += 1;
snapshot = null;
snapshotVersion = undefined;
loadedAt = 0;
pending = null;
}
async function read() {
const currentVersion = version ? await version() : undefined;
const freshByAge = !ttlMs || Date.now() - loadedAt < ttlMs;
if (snapshot && freshByAge && (!version || currentVersion === snapshotVersion)) return snapshot;
if (pending && (!version || pending.version === currentVersion)) return pending.promise;
const startedGeneration = generation;
const loading = Promise.resolve().then(load).then(state => {
if (generation === startedGeneration) {
snapshot = state;
snapshotVersion = currentVersion;
loadedAt = Date.now();
}
return state;
});
pending = { version: currentVersion, promise: loading };
try {
return await loading;
} finally {
if (pending?.promise === loading) pending = null;
}
}
return { read, invalidate };
}
-1
View File
@@ -1 +0,0 @@
export const CURRENT_SCHEMA_VERSION = 20;
-46
View File
@@ -1,46 +0,0 @@
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);
}
File diff suppressed because it is too large Load Diff
-356
View File
@@ -1,356 +0,0 @@
import { admissionPlanProgress, admissionRecords, admissionReportingRecord, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
import { systemNotificationItems } from '../services/system-notifications.mjs';
function normalizeCategories(input, cleanText) {
const source = Array.isArray(input) ? input : [];
return source.map((item, index) => ({
code: cleanText(item.code || `category_${index + 1}`, 40),
name: cleanText(item.name, 80),
quota: Math.max(0, Math.trunc(Number(item.quota || 0))),
specialtyCategory: cleanText(item.specialtyCategory, 30),
specialtyType: cleanText(item.specialtyType, 80),
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
})).filter(item => item.sourceSchoolId && item.quota > 0)
})).filter(item => item.code && item.name && item.quota > 0);
}
export function createAdmissionRoutes(context) {
const { database, readDb, sendJson, sendError, readJson, readBodyBuffer, sendWorkbook, buildWorkbook, parseWorkbook, requireUser, cleanText, maskId, uid, nowIso, logAction, documentVerificationSecret, admissionNoticeCode, safeCodeEqual } = context;
const reportingStatusByCode = { Y: 'reported', N: 'not_reported', P: 'pending' };
const reportingCodeByStatus = { reported: 'Y', not_reported: 'N', pending: 'P' };
function reportingRows(db, plan, record) {
const exam = db.exams.find(item => item.id === plan.examId) || {};
const school = db.schools.find(item => item.id === plan.schoolId) || {};
const rowByPlacement = new Map((record?.payload?.rows || []).map(item => [item.placementId, item]));
const placementIds = new Set((record?.payload?.rows || []).map(item => item.placementId));
const round = Number(record?.payload?.round || 1);
const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.status === 'final' && (placementIds.has(item.id) || (!record && Number(item.payload?.finalizedRound || 1) === round)));
return placements.map(placement => {
const account = db.users.find(item => item.id === placement.userId) || {};
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
const row = rowByPlacement.get(placement.id) || {};
return {
placementId: placement.id,
noticeNumber: placement.payload?.noticeNumber || '',
candidateNumber: account.candidateNumber || '',
name: profile.name || account.displayName || '',
idNumberMasked: maskId(profile.idNumber),
examCode: exam.code || '',
schoolCode: school.code || '',
categoryName: placement.payload?.categoryName || '',
status: row.status || 'pending',
statusCode: reportingCodeByStatus[row.status] || 'P',
note: row.note || '',
updatedAt: row.updatedAt || null
};
}).sort((left, right) => left.candidateNumber.localeCompare(right.candidateNumber));
}
function reportingBatch(db, plan, record) {
const exam = db.exams.find(item => item.id === plan.examId) || {};
return { id: record?.id || '', exam: { id: exam.id, code: exam.code, name: exam.name }, round: Number(record?.payload?.round || 1), status: record?.status || 'not_started', rows: reportingRows(db, plan, record), progress: admissionPlanProgress(db, plan), supplementDecision: record?.payload?.supplementDecision || '', decisionNote: record?.payload?.decisionNote || '', approvalNote: record?.payload?.approvalNote || '', updatedAt: record?.updatedAt || null };
}
function editableReportingRecord(db, examId, schoolId) {
const setting = admissionRecords(db, 'setting', examId)[0];
const record = admissionReportingRecord(db, examId, schoolId, Number(setting?.payload?.round || 1)) || admissionReportingRecord(db, examId, schoolId);
return { setting, record };
}
function reportingScanTarget(db, school, rawCode) {
const match = String(rawCode || '').toUpperCase().match(/AN-[A-F0-9]{24}/);
if (!match) return { error: [400, '未识别到有效的录取通知书防伪码'] };
const code = match[0];
const placement = admissionRecords(db, 'placement').find(item => item.schoolId === school.id && item.status === 'final' && safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, item, db.exams.find(exam => exam.id === item.examId) || {})));
if (!placement) return { error: [404, '该二维码不属于本校有效录取通知书'] };
const plan = approvedPlans(db, placement.examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, placement.examId, school.id);
if (!plan || !record || !['draft', 'rejected'].includes(record.status) || !(record.payload?.rows || []).some(item => item.placementId === placement.id)) return { error: [409, '该考生不在当前可维护的报到批次'] };
return { code, placement, plan, record };
}
async function handleAdmission(request, response, pathname) {
if (!pathname.startsWith('/api/admission/')) return false;
const user = await requireUser(request, response, 'admission_school');
if (!user) return true;
const db = request.authDb || await readDb();
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool);
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
if (request.method === 'GET' && pathname === '/api/admission/context') {
const plans = approvedPlans(db).filter(item => item.schoolId === school.id).map(item => ({ ...item, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', progress: admissionPlanProgress(db, item) }));
const notifications = systemNotificationItems(db).filter(item => item.visible && (!item.schoolId || item.schoolId === school.id)).slice(0, 6).map(item => ({ ...item, id: item.noticeId }));
return sendJson(response, 200, { ok: true, school, plans, notifications, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
}
if (request.method === 'GET' && pathname === '/api/admission/plans') {
const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool) });
}
if (request.method === 'POST' && pathname === '/api/admission/plans') {
const body = await readJson(request);
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
const categories = normalizeCategories(body.categories, cleanText);
if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数');
if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复');
if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应');
if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.length)) return sendError(response, 400, '同一招生类别不能重复分配同一生源校指标');
if (categories.some(item => item.indicatorAllocations.reduce((sum, entry) => sum + entry.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过该类别计划人数');
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校');
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整');
const now = nowIso();
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, userId: user.id, schoolId: school.id, createdAt: now };
Object.assign(plan, { status: 'pending', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewNote: '' } });
await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
}
if (request.method === 'GET' && pathname === '/api/admission/notice-template') {
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template');
const template = record?.payload?.template || {
eyebrow: 'ADMISSION NOTICE', title: '录 取 通 知 书',
body: '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。',
footer: '请妥善保管本通知书,报到时出示。', primaryColor: '#8d2028', accentColor: '#c9a45b'
};
return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt), template, updatedAt: record?.updatedAt || null });
}
if (request.method === 'PUT' && pathname === '/api/admission/notice-template') {
const body = await readJson(request);
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64)) || db.exams.find(item => !item.archivedAt) || db.exams[0];
if (!exam) return sendError(response, 409, '系统中还没有可关联的考试,暂时无法保存模板');
const template = {
eyebrow: cleanText(body.eyebrow || 'ADMISSION NOTICE', 60),
title: cleanText(body.title || '录 取 通 知 书', 80),
body: cleanText(body.body, 1600), footer: cleanText(body.footer, 300),
primaryColor: /^#[0-9a-f]{6}$/i.test(body.primaryColor) ? body.primaryColor : '#8d2028',
accentColor: /^#[0-9a-f]{6}$/i.test(body.accentColor) ? body.accentColor : '#c9a45b'
};
if (!template.body) return sendError(response, 400, '请填写录取通知书正文');
const now = nowIso();
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template')
|| { id: uid('notice_template'), kind: 'notification', examId: exam.id, userId: null, schoolId: school.id, status: 'template', createdAt: now };
Object.assign(record, { examId: exam.id, updatedAt: now, payload: { template, updatedBy: user.displayName } });
await database.saveAdmissionRecord(record, logAction(db, user, '保存录取通知书模板', school.name));
return sendJson(response, 200, { ok: true, template, updatedAt: now });
}
if (request.method === 'GET' && pathname === '/api/admission/reporting') {
const plans = approvedPlans(db).filter(item => item.schoolId === school.id);
const batches = plans.map(plan => reportingBatch(db, plan, admissionReportingRecord(db, plan.examId, school.id)));
return sendJson(response, 200, { ok: true, school, batches });
}
if (request.method === 'GET' && pathname === '/api/admission/reporting/export') {
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record) return sendError(response, 404, '当前考试还没有可维护的报到批次');
const rows = reportingRows(db, plan, record).map(item => ({
noticeNumber: item.noticeNumber, candidateNumber: item.candidateNumber, name: item.name,
examCode: item.examCode, schoolCode: item.schoolCode, categoryName: item.categoryName,
reportingStatusCode: item.statusCode, reportingNote: item.note
}));
const buffer = Buffer.from(await buildWorkbook('admission_reporting', rows, { subtitle: `${record.payload?.round || 1} 轮|${school.name}` }));
return sendWorkbook(response, buffer, `${record.payload?.round || 1}轮-${school.name}-考生报到状态.xlsx`);
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/import') {
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能导入暂存数据');
const imported = await parseWorkbook('admission_reporting', await readBodyBuffer(request));
const available = reportingRows(db, plan, record);
const byNotice = new Map(available.map(item => [item.noticeNumber, item]));
const byCandidate = new Map(available.map(item => [item.candidateNumber, item]));
const seen = new Set();
const updates = [];
const changes = [];
let unchangedCount = 0;
const importedAt = nowIso();
for (const item of imported) {
const noticeNumber = cleanText(item.noticeNumber, 100);
const candidateNumber = cleanText(item.candidateNumber, 100);
const target = byNotice.get(noticeNumber);
if (!target || byCandidate.get(candidateNumber)?.placementId !== target.placementId) return sendError(response, 400, `Excel 第 ${item.__row} 行的通知书编号与报名号不属于本校当前报到批次`);
if (seen.has(target.placementId)) return sendError(response, 400, `Excel 第 ${item.__row} 行重复填写同一考生`);
const code = String(item.reportingStatusCode || '').trim().toUpperCase();
if (!reportingStatusByCode[code]) return sendError(response, 400, `Excel 第 ${item.__row} 行报到状态码只能填写 Y、N 或 P`);
seen.add(target.placementId);
const status = reportingStatusByCode[code];
const note = cleanText(item.reportingNote, 300);
if (target.status === status && target.note === note) { unchangedCount += 1; continue; }
updates.push({ placementId: target.placementId, status, note, updatedAt: importedAt, source: 'excel' });
changes.push({ placementId: target.placementId, name: target.name, candidateNumber: target.candidateNumber, noticeNumber: target.noticeNumber, from: target.status, to: status, fromCode: target.statusCode, toCode: code, noteChanged: target.note !== note });
}
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
updates.forEach(item => merged.set(item.placementId, item));
if (updates.length) {
record.status = 'draft'; record.updatedAt = importedAt; record.payload = { ...record.payload, rows: [...merged.values()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · 实际更新 ${updates.length}`));
}
const nextDb = updates.length ? { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) } : db;
return sendJson(response, 200, { ok: true, count: imported.length, changedCount: updates.length, unchangedCount, changes, batch: reportingBatch(nextDb, plan, record) });
}
if (request.method === 'PUT' && pathname === '/api/admission/reporting/draft') {
const body = await readJson(request);
const examId = cleanText(body.examId, 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能修改暂存状态');
const available = new Set(reportingRows(db, plan, record).map(item => item.placementId));
const updates = (Array.isArray(body.rows) ? body.rows : []).map(item => ({ placementId: cleanText(item.placementId, 64), status: cleanText(item.status, 30), note: cleanText(item.note, 300), updatedAt: nowIso(), source: 'manual' }));
if (!updates.length || updates.some(item => !available.has(item.placementId) || !['pending', 'reported', 'not_reported'].includes(item.status))) return sendError(response, 400, '报到暂存数据无效');
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
updates.forEach(item => merged.set(item.placementId, item));
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, '暂存考生报到状态', `${school.name} · ${updates.length}`));
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan-preview') {
const body = await readJson(request);
const target = reportingScanTarget(db, school, body.code);
if (target.error) return sendError(response, ...target.error);
const { code, placement, plan, record } = target;
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
const row = reportingRows(db, plan, record).find(item => item.placementId === placement.id);
return sendJson(response, 200, { ok: true, code, examId: placement.examId, row });
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan') {
const body = await readJson(request);
const target = reportingScanTarget(db, school, body.code);
if (target.error) return sendError(response, ...target.error);
const { placement, plan, record } = target;
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
const status = cleanText(body.status, 30);
if (!['reported', 'not_reported', 'pending'].includes(status)) return sendError(response, 400, '请选择有效的报到确认状态');
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
const fallbackNote = status === 'reported' ? '扫描录取通知书二维码确认报到' : status === 'not_reported' ? '扫描录取通知书二维码确认未报到' : '扫描录取通知书二维码后暂待确认';
merged.set(placement.id, { placementId: placement.id, status, note: cleanText(body.note, 300) || fallbackNote, updatedAt: nowIso(), source: 'qr_scan' });
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, '扫码确认并暂存考生报到', `${school.name} · ${placement.payload?.noticeNumber || placement.id} · ${reportingCodeByStatus[status]}`));
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
return sendJson(response, 200, { ok: true, row: reportingRows(nextDb, plan, record).find(item => item.placementId === placement.id), batch: reportingBatch(nextDb, plan, record) });
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/submit') {
const body = await readJson(request);
const examId = cleanText(body.examId, 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能提交');
const rows = reportingRows(db, plan, record);
if (rows.some(item => item.status === 'pending')) return sendError(response, 409, `仍有 ${rows.filter(item => item.status === 'pending').length} 名考生待确认,请全部标记后提交`);
record.status = 'submitted'; record.updatedAt = nowIso(); record.payload = { ...record.payload, submittedAt: record.updatedAt, submittedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, '提交考生报到情况', `${school.name} · ${rows.length}`));
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
return sendJson(response, 200, { ok: true, batch: reportingBatch(nextDb, plan, record) });
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/decision') {
const body = await readJson(request);
const examId = cleanText(body.examId, 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record || record.status !== 'submitted') return sendError(response, 409, '请先提交本轮考生报到情况');
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
const progress = admissionPlanProgress(nextDb, plan);
const supplement = body.supplement === true && progress.reportingGap > 0;
const decisionNote = cleanText(body.decisionNote, 500);
if (supplement && decisionNote.length < 4) return sendError(response, 400, '申请补录时请填写至少 4 个字的补录说明');
record.status = 'pending_approval'; record.updatedAt = nowIso(); record.payload = { ...record.payload, supplementDecision: supplement ? 'supplement' : 'no_supplement', decisionNote: decisionNote || (progress.reportingGap ? '经学校研究决定,本轮不进行补录。' : '本校招生计划已完成。'), decisionSubmittedAt: record.updatedAt, decisionSubmittedBy: user.displayName, statistics: progress };
await database.saveAdmissionRecord(record, logAction(db, user, supplement ? '提交补录申请' : '提交不补录决定', `${school.name} · 缺额 ${progress.reportingGap}`));
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
}
if (request.method === 'GET' && pathname === '/api/admission/placements') {
const accountById = new Map(db.users.map(item => [item.id, item]));
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
const examById = new Map(db.exams.map(item => [item.id, item]));
const registrationByExamUser = new Map(db.registrations.map(item => [`${item.examId}\u0000${item.userId}`, item]));
const publishedResultsByRegistration = new Map();
for (const result of db.results) {
if (!result.published) continue;
const rows = publishedResultsByRegistration.get(result.registrationId) || [];
rows.push(result);
publishedResultsByRegistration.set(result.registrationId, rows);
}
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
const account = accountById.get(item.userId) || {};
const profile = profileByUserId.get(item.userId) || {};
const exam = examById.get(item.examId);
const registration = registrationByExamUser.get(`${item.examId}\u0000${item.userId}`);
const results = (publishedResultsByRegistration.get(registration?.id) || []).map(result => ({ subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
const qualification = resolveProfileSpecialty(profile);
return { ...item, examName: exam?.name || item.examId, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
});
const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final'));
return sendJson(response, 200, { ok: true, school, placements, completedExams });
}
if (request.method === 'GET' && pathname === '/api/admission/placements/export') {
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
const exam = db.exams.find(item => item.id === examId);
const setting = admissionRecords(db, 'setting', examId)[0];
if (!exam || setting?.status !== 'completed') return sendError(response, 409, '录取工作结束后才能下载正式录取名单');
const rows = admissionRecords(db, 'placement', examId).filter(item => item.schoolId === school.id && item.status === 'final').map(item => {
const account = db.users.find(entry => entry.id === item.userId) || {};
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
const registration = db.registrations.find(entry => entry.examId === examId && entry.userId === item.userId) || {};
const sourceSchool = db.schools.find(entry => entry.id === profile.schoolId) || {};
const schoolClass = db.classes.find(entry => entry.id === profile.classId) || {};
const qualification = resolveProfileSpecialty(profile);
const scoreRows = db.results.filter(entry => entry.registrationId === registration.id && entry.published).map(result => ({ name: exam.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
return {
candidateNumber: account.candidateNumber || registration.registrationNumber || '', name: profile.name || account.displayName || '', gender: profile.gender || '',
idNumber: profile.idNumber || '', phone: profile.phone || '', email: profile.email || '', birthDate: profile.birthDate || '', ethnicity: profile.ethnicity || '', nativePlace: profile.nativePlace || '',
sourceSchool: sourceSchool.name || profile.school || '', sourceSchoolCode: sourceSchool.code || '', className: schoolClass.name || profile.grade || '',
address: [profile.provinceName, profile.cityName, profile.districtName, profile.address].filter(Boolean).join(' '), guardianName: profile.guardianName || profile.emergencyContact || '', guardianPhone: profile.guardianPhone || profile.emergencyPhone || '',
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生', specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '',
featureScore: Number(registration.featureScore || 0), subjectScores: scoreRows.map(score => `${score.name} ${score.score}`).join(''), totalScore: Number(item.payload?.totalScore || 0),
admittedSchool: school.name, categoryName: item.payload?.categoryName || '', preferenceOrder: Number(item.payload?.preferenceOrder || 0)
};
});
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}${school.name}` }));
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
}
if (request.method === 'POST' && pathname === '/api/admission/placements/bulk') {
const body = await readJson(request);
const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).map(id => cleanText(id, 64)).filter(Boolean))];
const decision = cleanText(body.decision, 30);
const note = cleanText(body.note, 500);
if (!ids.length) return sendError(response, 400, '请至少选择一名待审核考生');
if (!['accept', 'withdraw'].includes(decision)) return sendError(response, 400, '请选择接收或申请退档');
if (decision === 'withdraw' && note.length < 8) return sendError(response, 400, '批量申请退档必须填写至少 8 个字的特殊理由');
const placements = admissionRecords(db, 'placement').filter(item => ids.includes(item.id) && item.schoolId === school.id && item.status === 'school_review');
if (placements.length !== ids.length) return sendError(response, 409, '所选记录中包含已处理或不属于本校的投档记录,请刷新后重试');
const now = nowIso();
for (const placement of placements) {
placement.status = decision === 'accept' ? 'admitted' : 'withdrawal_pending';
placement.payload.schoolDecisionNote = note;
if (decision === 'withdraw') placement.payload.withdrawalReason = note;
placement.updatedAt = now;
}
await database.saveAdmissionRecords(placements, logAction(db, user, decision === 'accept' ? '批量接收投档考生' : '批量申请退档', `${school.name} · ${placements.length}`));
return sendJson(response, 200, { ok: true, count: placements.length, decision });
}
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
if (request.method === 'PATCH' && placementMatch) {
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
if (!placement || placement.status !== 'school_review') return sendError(response, 404, '待审核投档记录不存在');
const body = await readJson(request);
const decision = cleanText(body.decision, 30);
const note = cleanText(body.note, 500);
if (decision === 'accept') placement.status = 'admitted';
else if (decision === 'withdraw') {
if (note.length < 8) return sendError(response, 400, '申请退档必须填写至少 8 个字的特殊理由');
placement.status = 'withdrawal_pending';
placement.payload.withdrawalReason = note;
} else return sendError(response, 400, '请选择接收或申请退档');
placement.payload.schoolDecisionNote = note;
placement.updatedAt = nowIso();
await database.saveAdmissionRecord(placement, logAction(db, user, decision === 'accept' ? '接收投档考生' : '申请退档', `${school.name} · ${placement.id}`));
return sendJson(response, 200, { ok: true, placement });
}
return sendError(response, 404, '招生学校功能接口不存在');
}
return handleAdmission;
}
-270
View File
@@ -1,270 +0,0 @@
import QRCode from 'qrcode';
import {
assertTotpConfiguration,
buildOtpAuthUri,
consumeRecoveryCode,
createRecoveryCodes,
createTotpSecret,
decryptTotpSecret,
encryptTotpSecret,
hashRecoveryCode,
verifyTotp
} from '../security/totp.mjs';
export function createAuthRoutes(context) {
assertTotpConfiguration();
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,
authState,
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames,
permissionsByLevel
} = context;
async function issueSession(user) {
const token = randomBytes(32).toString('hex');
await authState.createSession(token, user.id);
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=${authState.sessionTtlSeconds}` };
}
function sessionToken(request) {
return parseCookies(request).hz_session || '';
}
function verifySecondFactor(user, code) {
if (!user.totpEnabled || !user.totpSecretEncrypted) return null;
const normalized = String(code || '').trim();
if (/^\d{6}$/.test(normalized)) {
const step = verifyTotp(normalized, decryptTotpSecret(user.totpSecretEncrypted), { lastUsedStep: user.totpLastUsedStep });
return step == null ? null : { type: 'totp', step };
}
const recoveryCodes = consumeRecoveryCode(normalized, user.totpRecoveryCodes || []);
return recoveryCodes ? { type: 'recovery', recoveryCodes } : null;
}
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 && item.isSourceSchool);
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 || user.archivedAt || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
if (user.totpEnabled) {
const challenge = randomBytes(32).toString('base64url');
await authState.createLoginChallenge(challenge, user.id);
return sendJson(response, 200, { ok: true, requiresTotp: true, challenge });
}
const session = await issueSession(user);
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': session.cookie });
}
if (request.method === 'POST' && pathname === '/api/auth/login/totp') {
const body = await readJson(request);
const challengeKey = String(body.challenge || '');
const challenge = await authState.getLoginChallenge(challengeKey);
if (!challenge || challenge.attempts >= 5) {
await authState.deleteLoginChallenge(challengeKey);
return sendError(response, 401, '验证请求已过期,请重新输入账号和密码');
}
const db = await readDb();
const user = db.users.find(item => item.id === challenge.userId);
if (!user || !user.totpEnabled || user.active === false || user.archivedAt) {
await authState.deleteLoginChallenge(challengeKey);
return sendError(response, 401, '验证请求已失效,请重新登录');
}
let verified = null;
try { verified = verifySecondFactor(user, body.code); } catch {}
if (!verified) {
const failure = await authState.recordLoginChallengeFailure(challengeKey, 5);
return sendError(response, 401, failure?.exhausted ? '验证失败次数过多,请重新登录' : failure ? '验证码或恢复码不正确' : '验证请求已过期,请重新输入账号和密码');
}
if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
else user.totpRecoveryCodes = verified.recoveryCodes;
const log = verified.type === 'recovery' ? logAction(db, user, '使用 TOTP 恢复码登录', user.username) : null;
await database.updateTotpSecurity(user, log);
await authState.deleteLoginChallenge(challengeKey);
const session = await issueSession(user);
return sendJson(response, 200, { ok: true, user: safeUser(user), usedRecoveryCode: verified.type === 'recovery' }, { 'Set-Cookie': session.cookie });
}
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 === 'GET' && pathname === '/api/auth/totp') {
const user = await requireUser(request, response);
if (!user) return true;
return sendJson(response, 200, {
ok: true,
enabled: Boolean(user.totpEnabled),
recoveryCodesRemaining: user.totpEnabled ? (user.totpRecoveryCodes || []).length : 0
});
}
if (request.method === 'POST' && pathname === '/api/auth/totp/setup') {
const user = await requireUser(request, response);
if (!user) return true;
if (user.mustChangePassword) return sendError(response, 400, '请先修改初始密码,再启用二次验证');
if (user.totpEnabled) return sendError(response, 409, '当前账号已经启用 TOTP 二次验证');
const body = await readJson(request);
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
const db = await readDb();
const issuer = cleanText(db.organization?.name || '考试服务平台', 80);
const secret = createTotpSecret();
const uri = buildOtpAuthUri({ secret, account: user.candidateNumber || user.username, issuer });
const token = sessionToken(request);
await authState.createTotpSetup(token, user.id, secret);
const qrCode = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
return sendJson(response, 200, { ok: true, secret, uri, qrCode, expiresIn: 600 });
}
if (request.method === 'POST' && pathname === '/api/auth/totp/enable') {
const user = await requireUser(request, response);
if (!user) return true;
const token = sessionToken(request);
const setup = await authState.getTotpSetup(token);
if (!setup || setup.userId !== user.id) {
await authState.deleteTotpSetup(token);
return sendError(response, 400, '绑定信息已过期,请重新开始');
}
const body = await readJson(request);
const step = verifyTotp(body.code, setup.secret);
if (step == null) return sendError(response, 400, '动态验证码不正确,请确认设备时间准确后重试');
const recoveryCodes = createRecoveryCodes();
user.totpEnabled = true;
user.totpSecretEncrypted = encryptTotpSecret(setup.secret);
user.totpRecoveryCodes = recoveryCodes.map(hashRecoveryCode);
user.totpLastUsedStep = step;
const db = await readDb();
const log = logAction(db, user, '启用 TOTP 二次验证', user.username);
await database.updateTotpSecurity(user, log);
await authState.deleteTotpSetup(token);
return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) });
}
if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') {
const user = await requireUser(request, response);
if (!user) return true;
if (!user.totpEnabled) return sendError(response, 400, '当前账号尚未启用 TOTP 二次验证');
const body = await readJson(request);
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
let verified = null;
try { verified = verifySecondFactor(user, body.code); } catch {}
if (!verified) return sendError(response, 400, '动态验证码或恢复码不正确');
const recoveryCodes = createRecoveryCodes();
user.totpRecoveryCodes = recoveryCodes.map(hashRecoveryCode);
if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
const db = await readDb();
const log = logAction(db, user, '重新生成 TOTP 恢复码', user.username);
await database.updateTotpSecurity(user, log);
return sendJson(response, 200, { ok: true, recoveryCodes });
}
if (request.method === 'POST' && pathname === '/api/auth/totp/disable') {
const user = await requireUser(request, response);
if (!user) return true;
if (!user.totpEnabled) return sendError(response, 400, '当前账号尚未启用 TOTP 二次验证');
const body = await readJson(request);
if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确');
let verified = null;
try { verified = verifySecondFactor(user, body.code); } catch {}
if (!verified) return sendError(response, 400, '动态验证码或恢复码不正确');
user.totpEnabled = false;
user.totpSecretEncrypted = null;
user.totpRecoveryCodes = [];
user.totpLastUsedStep = null;
const db = await readDb();
const log = logAction(db, user, '关闭 TOTP 二次验证', user.username);
await database.updateTotpSecurity(user, log);
await authState.deleteTotpSetup(sessionToken(request));
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) await authState.deleteSession(token);
return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' });
}
return false;
}
return handleAuth;
}
-346
View File
@@ -1,346 +0,0 @@
import { noticeForClient } from '../security/notice-content.mjs';
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota, supplementarySchoolIds } from '../services/volunteer-admission.mjs';
import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs';
import QRCode from 'qrcode';
import { systemNotificationItems } from '../services/system-notifications.mjs';
export function createCandidateRoutes(context) {
const {
database,
cache,
resultsCacheTtlSeconds,
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,
examResultSummary,
subjectPassText,
resultRankInfo,
documentVerificationSecret,
scoreReportCode,
admissionNoticeCode,
subjectPassEvaluation,
logAction,
excelResourceNames,
excelRowsForResource,
importExcelResource,
admitCardHtml,
hashPassword,
verifyPassword,
uid,
nowIso,
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames,
resolveRegion
} = context;
const verificationUrl = (request, code) => {
const protocol = String(request.headers['x-forwarded-proto'] || '').split(',')[0].trim() || (process.env.NODE_ENV === 'production' ? 'https' : 'http');
const host = request.headers.host || `${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 4173}`;
return `${protocol}://${host}/#verify/${encodeURIComponent(code)}`;
};
const verificationQr = (request, code) => QRCode.toDataURL(verificationUrl(request, code), { errorCorrectionLevel: 'M', margin: 1, width: 320 });
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').map(noticeForClient),
...systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }))
].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/notices') {
const notices = [
...db.notices.filter(item => item.status === 'published').map(noticeForClient),
...systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }))
].sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
return sendJson(response, 200, { ok: true, 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 && item.isSourceSchool), 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', 'specialtyCertificate', 'policyEligibility'];
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
profile.specialtyCategory = cleanText(body.specialtyCategory, 30);
profile.specialtyType = cleanText(body.specialtyType, 40);
if (!isValidSpecialty(profile.specialtyCategory, profile.specialtyType)) return sendError(response, 400, '请选择对应的特长生大类和小类');
profile.specialtyTypes = profile.specialtyType ? [profile.specialtyType] : [];
const region = resolveRegion(body);
if (!region) return sendError(response, 400, '请选择有效的省、市和区县');
Object.assign(profile, region);
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isSourceSchool);
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' && !item.archivedAt).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' && !item.archivedAt);
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', paidAt: null, paidBy: null, 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 payload = await cache.remember('results', `candidate:${encodeURIComponent(user.id)}`, async () => {
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);
const appealInstance = db.workflowInstances.find(item => item.businessType === 'score_appeal' && item.businessId === result.id);
const appeal = appealInstance ? workflowView(db, appealInstance) : null;
const rank = resultRankInfo(db, result);
const pass = subjectPassEvaluation(db, result, subject);
return {
...result, ...rank, grade: rank.grade, examId: exam.id, examName: exam.name, examCode: exam.code, examStart: exam.examStart, archivedAt: exam.archivedAt || null,
subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150,
passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore,
passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject), qualified: pass.qualified,
appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null
};
});
const summaries = await Promise.all(registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects).map(async summary => {
const registration = registrations.find(item => item.examId === summary.examId);
const exam = db.exams.find(item => item.id === summary.examId);
const reportResults = db.results.filter(item => item.registrationId === registration?.id && item.published);
const verificationCode = registration && exam ? scoreReportCode(documentVerificationSecret, registration, exam, reportResults) : '';
return { ...summary, verificationCode, verificationQr: verificationCode ? await verificationQr(request, verificationCode) : '' };
}));
return { ok: true, results, summaries, candidate: { name: profile.name || user.displayName, candidateNumber: user.candidateNumber || '' } };
}, { ttlSeconds: resultsCacheTtlSeconds });
return sendJson(response, 200, payload);
}
if (request.method === 'GET' && pathname === '/api/candidate/admissions') {
const settings = (await Promise.all(admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(async setting => {
const exam = db.exams.find(item => item.id === setting.examId);
const round = Number(setting.payload?.round || 1);
const preference = activePreference(db, setting.examId, user.id, round);
const preferenceView = preference ? {
...preference,
payload: {
...preference.payload,
choices: (preference.payload?.choices || []).map(choice => {
const school = db.schools.find(item => item.id === choice.schoolId);
const categories = admissionRecords(db, 'plan', setting.examId)
.filter(item => item.schoolId === choice.schoolId)
.flatMap(item => item.payload?.categories || []);
const category = categories.find(item => item.code === choice.categoryCode)
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' };
})
}
} : null;
const qualification = indicatorQualification(db, setting.examId, user.id);
const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
const blockingPlacement = setting.status === 'supplementary'
? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status))
: null;
const supplementEligible = !blockingPlacement;
const supplementIneligibilityReason = blockingPlacement?.status === 'forfeited'
? '因本轮未按规定完成报到,不能再次参加补录。'
: blockingPlacement
? '你已被录取,本轮补录无需且不能再次填报。'
: '';
const supplementarySchools = supplementarySchoolIds(db, setting);
const plans = (supplementEligible ? approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId)) : []).map(plan => {
const school = db.schools.find(item => item.id === plan.schoolId);
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status));
return {
id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '',
categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => {
const indicatorAllocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
const indicatorUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
const generalUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === 'general').length;
const indicatorRemaining = Math.max(0, Number(indicatorAllocation?.quota || 0) - indicatorUsed);
const generalRemaining = Math.max(0, generalQuota - generalUsed);
const preferenceTypes = [generalRemaining > 0 ? 'general' : '', qualification?.payload?.eligible && indicatorRemaining > 0 ? 'indicator' : ''].filter(Boolean);
return { ...category, generalRemaining, indicatorRemaining, preferenceTypes };
}).filter(category => category.preferenceTypes.length)
};
}).filter(plan => plan.categories.length);
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
const submissionCount = Number(preference?.payload?.submissionCount || 0);
const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3));
const school = placement ? db.schools.find(item => item.id === placement.schoolId) : null;
const templateRecord = placement ? admissionRecords(db, 'notification').find(item => item.schoolId === placement.schoolId && item.status === 'template') : null;
const noticeTemplate = templateRecord?.payload?.template || null;
const noticeVerificationCode = placement?.status === 'final' && exam ? admissionNoticeCode(documentVerificationSecret, placement, exam) : '';
const noticeVerificationQr = noticeVerificationCode ? await verificationQr(request, noticeVerificationCode) : '';
return { ...setting, exam: exam ? publicExam(exam) : null, preference: preferenceView, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, noticeVerificationQr, noticeNumber: placement?.payload?.noticeNumber || '', plans, supplementEligible, supplementIneligibilityReason, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
}))).filter(item => item.exam);
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id).map(item => {
const placement = admissionRecords(db, 'placement', item.examId).find(entry => entry.id === item.payload?.placementId);
const school = db.schools.find(entry => entry.id === (placement?.schoolId || item.schoolId));
const exam = db.exams.find(entry => entry.id === item.examId);
return { ...item, examName: exam?.name || '', schoolName: school?.name || '', schoolCode: school?.code || '', categoryName: placement?.payload?.categoryName || '', noticeNumber: placement?.payload?.noticeNumber || '', placementStatus: placement?.status || '' };
}).sort((left, right) => new Date(right.createdAt) - new Date(left.createdAt));
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
}
const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/);
if (request.method === 'PUT' && preferenceMatch) {
const setting = admissionSetting(db, preferenceMatch[1]);
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报');
if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段');
if (setting.status === 'supplementary') {
const blockingPlacement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status));
if (blockingPlacement?.status === 'forfeited') return sendError(response, 403, '因未按规定完成报到,本轮不能再次参加补录');
if (blockingPlacement) return sendError(response, 403, '你已被录取,本轮补录不能再次填报');
}
const now = Date.now();
if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始');
if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止');
if (candidateTotalScore(db, setting.examId, user.id) == null) return sendError(response, 403, '本场考试成绩全部发布后才能填报志愿');
const body = await readJson(request);
const maxChoices = Math.max(1, Number(setting.payload.maxChoices || 5));
const round = Number(setting.payload.round || 1);
const currentPreference = activePreference(db, setting.examId, user.id, round);
const maxSubmissions = Math.max(1, Number(setting.payload.maxSubmissions || 3));
const submissionCount = Number(currentPreference?.payload?.submissionCount || 0);
if (submissionCount >= maxSubmissions) return sendError(response, 409, `志愿已达到 ${maxSubmissions} 次提交上限,现已自动锁定`);
const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices + 1).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40), preferenceType: item.preferenceType === 'indicator' ? 'indicator' : 'general' }));
if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
const indicatorChoices = choices.filter(item => item.preferenceType === 'indicator');
const generalChoices = choices.filter(item => item.preferenceType === 'general');
if (indicatorChoices.length > 1 || generalChoices.length > maxChoices) return sendError(response, 400, `本轮最多填报 1 个指标分配志愿和 ${maxChoices} 个普通志愿`);
if (indicatorChoices.length && choices[0].preferenceType !== 'indicator') return sendError(response, 400, '指标分配志愿必须位于专用第一栏');
if (new Set(choices.map(item => `${item.preferenceType}|${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同类志愿中同一学校和招生类别不能重复填报');
const supplementarySchools = supplementarySchoolIds(db, setting);
const plans = approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId));
const indicator = indicatorQualification(db, setting.examId, user.id);
const invalidChoice = choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => {
if (category.code !== choice.categoryCode || !candidateEligibleForCategory(profile, category)) return false;
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status));
if (choice.preferenceType === 'indicator') {
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
const used = placements.filter(item => item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
return indicator?.payload?.eligible === true && Number(allocation?.quota || 0) > used;
}
const quota = Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0);
return quota > placements.filter(item => item.payload?.quotaBucket === 'general').length;
})));
if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别');
const nowValue = nowIso();
const preference = currentPreference || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
const storedChoices = choices.map(choice => {
const school = db.schools.find(item => item.id === choice.schoolId);
const category = plans.find(plan => plan.schoolId === choice.schoolId)?.payload?.categories?.find(item => item.code === choice.categoryCode);
return { ...choice, schoolCode: school?.code || '', schoolName: school?.name || '', categoryName: category?.name || '' };
});
Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices: storedChoices, submittedAt: nowValue, submissionCount: submissionCount + 1 } });
await database.saveAdmissionRecord(preference);
return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' });
}
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
if (request.method === 'POST' && scoreAppealMatch) {
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
const registration = db.registrations.find(item => item.id === result?.registrationId && item.userId === user.id);
if (!result || !registration) return sendError(response, 404, '已发布成绩不存在或不属于当前考生');
const exam = db.exams.find(item => item.id === registration.examId);
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩及复议入口已永久锁定');
if (pendingWorkflow(db, 'score_appeal', result.id)) return sendError(response, 409, '该科成绩已有待处理复议,请勿重复提交');
const body = await readJson(request);
const reason = cleanText(body.reason, 500);
if (reason.length < 5) return sendError(response, 400, '请至少填写 5 个字的复议理由');
const { instance, action } = createWorkflowSubmission(db, 'score_appeal', result.id, profile, user.id);
action.note = reason;
const subject = exam?.subjects.find(item => item.id === result.subjectId);
const log = logAction(db, user, '提交成绩复议', `${exam?.name || ''} · ${subject?.name || ''}`);
await database.createWorkflow(instance, action, log);
return sendJson(response, 201, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
}
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;
}
-146
View File
@@ -1,146 +0,0 @@
import { noticeForClient } from '../security/notice-content.mjs';
import { admissionRecords, admissionRoundPublications, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
import { systemNotificationItems } from '../services/system-notifications.mjs';
export function createPublicRoutes(context) {
const {
database,
cache,
readDb,
publicSiteConfig,
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,
buildWorkbook,
hasExcelResource,
parseWorkbook,
adminLevelNames
, documentVerificationSecret, scoreReportCode, admissionNoticeCode, safeCodeEqual
} = context;
async function handlePublic(pathname, response) {
const verificationMatch = pathname.match(/^\/api\/public\/verifications\/([^/]+)$/);
if (verificationMatch) {
const db = await readDb();
const code = decodeURIComponent(verificationMatch[1]).toUpperCase();
const hideName = value => value ? `${value.slice(0, 1)}${'*'.repeat(Math.max(1, value.length - 1))}` : '';
if (code.startsWith('SR-')) {
for (const registration of db.registrations) {
const exam = db.exams.find(item => item.id === registration.examId);
const results = db.results.filter(item => item.registrationId === registration.id && item.published);
if (!exam || !results.length || !safeCodeEqual(code, scoreReportCode(documentVerificationSecret, registration, exam, results))) continue;
const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {};
const user = db.users.find(item => item.id === registration.userId) || {};
return sendJson(response, 200, { ok: true, verified: true, document: { type: 'score-report', typeName: '考生成绩单', candidateName: hideName(profile.name || user.displayName), candidateNumber: String(user.candidateNumber || registration.registrationNumber || '').replace(/^(.{3}).+(.{3})$/, '$1****$2'), examName: exam.name, subjectCount: results.length, totalScore: Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2)), issuedAt: [...results].sort((a, b) => new Date(b.publishedAt || b.updatedAt) - new Date(a.publishedAt || a.updatedAt))[0]?.publishedAt } });
}
}
if (code.startsWith('AN-')) {
for (const placement of admissionRecords(db, 'placement').filter(item => item.status === 'final')) {
const exam = db.exams.find(item => item.id === placement.examId);
if (!exam || !safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, placement, exam))) continue;
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
const school = db.schools.find(item => item.id === placement.schoolId) || {};
return sendJson(response, 200, { ok: true, verified: true, document: { type: 'admission-notice', typeName: '录取通知书', noticeNumber: placement.payload?.noticeNumber || '', candidateName: hideName(profile.name), examName: exam.name, schoolName: school.name, categoryName: placement.payload?.categoryName || '', issuedAt: placement.updatedAt } });
}
}
return sendError(response, 404, '未查询到有效文书,请核对防伪码');
}
if (pathname === '/api/public/home') {
const payload = await cache.remember('public', 'home', async () => {
const db = await readDb();
const manualNotices = db.notices.filter(item => item.status === 'published').map(noticeForClient);
const automaticNotices = systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }));
const publishedNotices = [...manualNotices, ...automaticNotices].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' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
});
return sendJson(response, 200, payload);
}
if (pathname === '/api/public/announcements') {
const payload = await cache.remember('public', 'admission-announcements', async () => {
const db = await readDb();
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved' && item.payload?.publicVisible !== false).map(item => ({
id: item.id,
examId: item.examId,
examName: db.exams.find(exam => exam.id === item.examId)?.name || '',
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
publishedAt: item.payload?.reviewedAt || item.updatedAt,
note: item.payload?.note || '',
rows: (item.payload?.categories || []).map(category => ({
code: category.code,
name: category.name,
quota: Number(category.quota || 0),
specialtyCategory: category.specialtyCategory || '',
specialtyType: category.specialtyType || '',
specialtyLabel: specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类',
indicatorQuota: (category.indicatorAllocations || []).reduce((sum, allocation) => sum + Number(allocation.quota || 0), 0),
indicatorAllocations: (category.indicatorAllocations || []).map(allocation => ({
sourceSchoolName: db.schools.find(school => school.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId,
quota: Number(allocation.quota || 0)
}))
}))
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', round: item.round, title: `${db.exams.find(exam => exam.id === item.examId)?.name || ''}${item.round} 轮录取名单公示`, publishedAt: item.publishedAt, rows: item.rows }));
const admissions = [...roundAdmissions, ...admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', title: `${db.exams.find(item => item.id === setting.examId)?.name || ''}最终录取名单`, publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) }))].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting' && item.visible).map(item => ({ id: item.id, examId: item.examId, schoolId: item.schoolId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', title: item.title, summary: item.summary, publishedAt: item.publishAt, statistics: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.statistics || {}, supplementDecision: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.supplementDecision || '', decisionNote: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.decisionNote || '' }));
return { ok: true, plans, qualifications, admissions, cutoffs, reports };
});
return sendJson(response, 200, payload);
}
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
if (noticeMatch) {
const notice = await cache.remember('public', `notice:${encodeURIComponent(noticeMatch[1])}`, async () => {
const db = await readDb();
const found = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
if (found) return noticeForClient(found);
const systemNotice = systemNotificationItems(db).find(item => item.noticeId === noticeMatch[1] && item.visible);
return systemNotice ? { ...systemNotice, id: systemNotice.noticeId } : null;
});
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
}
return false;
}
return handlePublic;
}
-259
View File
@@ -1,259 +0,0 @@
import { createClient } from 'redis';
function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
}
function nonNegativeInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 ? Math.min(parsed, maximum) : fallback;
}
function redisDatabase(url) {
try {
const pathname = new URL(url).pathname.replace(/^\//, '');
const parsed = Number(pathname || 0);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
} catch {
return 0;
}
}
function redisEndpoint(url) {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.hostname}:${parsed.port || '6379'}`;
} catch {
return '';
}
}
function memoryAuthState({ sessionTtlSeconds, loginChallengeTtlSeconds, totpSetupTtlSeconds }) {
const sessions = new Map();
const loginChallenges = new Map();
const totpSetups = new Map();
function liveEntry(map, key) {
const entry = map.get(key);
if (!entry || entry.expiresAt <= Date.now()) {
map.delete(key);
return null;
}
return entry;
}
return {
status: 'disabled',
backend: 'memory',
database: null,
sessionTtlSeconds,
async createSession(token, userId) {
sessions.set(token, { userId, expiresAt: Date.now() + sessionTtlSeconds * 1000 });
},
async getSession(token) {
const entry = liveEntry(sessions, token);
return entry ? { userId: entry.userId } : null;
},
async deleteSession(token) {
return sessions.delete(token);
},
async deleteUserSessions(userId) {
let deleted = 0;
for (const [token, session] of sessions) {
if (session.userId === userId) {
sessions.delete(token);
deleted += 1;
}
}
return deleted;
},
async deleteUsersSessions(userIds) {
const targets = userIds instanceof Set ? userIds : new Set(userIds);
let deleted = 0;
for (const [token, session] of sessions) {
if (targets.has(session.userId)) {
sessions.delete(token);
deleted += 1;
}
}
return deleted;
},
async createLoginChallenge(key, userId) {
loginChallenges.set(key, { userId, attempts: 0, expiresAt: Date.now() + loginChallengeTtlSeconds * 1000 });
},
async getLoginChallenge(key) {
const entry = liveEntry(loginChallenges, key);
return entry ? { userId: entry.userId, attempts: entry.attempts } : null;
},
async recordLoginChallengeFailure(key, maximumAttempts) {
const entry = liveEntry(loginChallenges, key);
if (!entry) return null;
entry.attempts += 1;
const exhausted = entry.attempts >= maximumAttempts;
if (exhausted) loginChallenges.delete(key);
return { attempts: entry.attempts, exhausted };
},
async deleteLoginChallenge(key) {
return loginChallenges.delete(key);
},
async createTotpSetup(token, userId, secret) {
totpSetups.set(token, { userId, secret, expiresAt: Date.now() + totpSetupTtlSeconds * 1000 });
},
async getTotpSetup(token) {
const entry = liveEntry(totpSetups, token);
return entry ? { userId: entry.userId, secret: entry.secret } : null;
},
async deleteTotpSetup(token) {
return totpSetups.delete(token);
},
async close() {
sessions.clear();
loginChallenges.clear();
totpSetups.clear();
}
};
}
const recordFailureScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then
return -1
end
local attempts = redis.call('HINCRBY', KEYS[1], 'attempts', 1)
if attempts >= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
end
return attempts
`;
export async function createAuthStateStore({ env = process.env, logger = console, clientFactory = createClient } = {}) {
const cacheUrl = String(env.REDIS_URL || '').trim();
const explicitSessionUrl = String(env.REDIS_SESSION_URL || '').trim();
const sessionUrl = explicitSessionUrl || cacheUrl;
const sessionTtlSeconds = positiveInteger(env.AUTH_SESSION_TTL_SECONDS, 8 * 60 * 60, 30 * 24 * 60 * 60);
const loginChallengeTtlSeconds = positiveInteger(env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS, 5 * 60, 60 * 60);
const totpSetupTtlSeconds = positiveInteger(env.AUTH_TOTP_SETUP_TTL_SECONDS, 10 * 60, 60 * 60);
const lifetimes = { sessionTtlSeconds, loginChallengeTtlSeconds, totpSetupTtlSeconds };
if (!sessionUrl) return memoryAuthState(lifetimes);
const cacheDatabase = redisDatabase(cacheUrl);
const configuredDatabase = String(env.REDIS_SESSION_DB || '').trim();
const sessionDatabase = configuredDatabase
? nonNegativeInteger(configuredDatabase, cacheDatabase === 0 ? 1 : 0, 1024)
: explicitSessionUrl
? redisDatabase(explicitSessionUrl)
: cacheDatabase === 0 ? 1 : 0;
if (cacheUrl && redisEndpoint(cacheUrl) === redisEndpoint(sessionUrl) && cacheDatabase === sessionDatabase) {
throw new Error('Redis 认证状态必须使用与普通缓存不同的逻辑数据库;请配置 REDIS_SESSION_DB 或 REDIS_SESSION_URL');
}
const prefix = String(env.REDIS_SESSION_PREFIX || 'exam-information:auth')
.trim()
.replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information:auth';
const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000);
const client = clientFactory({
url: sessionUrl,
database: sessionDatabase,
socket: { connectTimeout }
});
client.on('error', error => logger.error(`Redis 认证状态存储错误:${error?.message || error}`));
try {
await client.connect();
} catch (error) {
if (client.isOpen) client.destroy();
throw new Error(`Redis 认证状态存储连接失败:${error?.message || error}`, { cause: error });
}
const sessionKey = token => `${prefix}:session:${token}`;
const userSessionsKey = userId => `${prefix}:user-sessions:${userId}`;
const loginChallengeKey = key => `${prefix}:login-challenge:${key}`;
const totpSetupKey = token => `${prefix}:totp-setup:${token}`;
async function deleteUserSessions(userId) {
const indexKey = userSessionsKey(userId);
const tokens = await client.sMembers(indexKey);
if (!tokens.length) {
await client.del(indexKey);
return 0;
}
const transaction = client.multi();
for (const token of tokens) transaction.del(sessionKey(token));
transaction.del(indexKey);
await transaction.exec();
return tokens.length;
}
return {
status: 'ready',
backend: 'redis',
database: sessionDatabase,
sessionTtlSeconds,
async createSession(token, userId) {
const indexKey = userSessionsKey(userId);
const transaction = client.multi();
transaction.set(sessionKey(token), userId, { EX: sessionTtlSeconds });
transaction.sAdd(indexKey, token);
transaction.expire(indexKey, sessionTtlSeconds);
await transaction.exec();
},
async getSession(token) {
const userId = await client.get(sessionKey(token));
return userId ? { userId } : null;
},
async deleteSession(token) {
const key = sessionKey(token);
const userId = await client.get(key);
const transaction = client.multi();
transaction.del(key);
if (userId) transaction.sRem(userSessionsKey(userId), token);
await transaction.exec();
return Boolean(userId);
},
deleteUserSessions,
async deleteUsersSessions(userIds) {
const counts = await Promise.all([...userIds].map(deleteUserSessions));
return counts.reduce((sum, count) => sum + count, 0);
},
async createLoginChallenge(key, userId) {
const redisKey = loginChallengeKey(key);
const transaction = client.multi();
transaction.hSet(redisKey, { userId, attempts: '0' });
transaction.expire(redisKey, loginChallengeTtlSeconds);
await transaction.exec();
},
async getLoginChallenge(key) {
const entry = await client.hGetAll(loginChallengeKey(key));
return entry.userId ? { userId: entry.userId, attempts: Number(entry.attempts || 0) } : null;
},
async recordLoginChallengeFailure(key, maximumAttempts) {
const attempts = Number(await client.eval(recordFailureScript, {
keys: [loginChallengeKey(key)],
arguments: [String(maximumAttempts)]
}));
return attempts < 0 ? null : { attempts, exhausted: attempts >= maximumAttempts };
},
async deleteLoginChallenge(key) {
return Boolean(await client.del(loginChallengeKey(key)));
},
async createTotpSetup(token, userId, secret) {
const key = totpSetupKey(token);
const transaction = client.multi();
transaction.hSet(key, { userId, secret });
transaction.expire(key, totpSetupTtlSeconds);
await transaction.exec();
},
async getTotpSetup(token) {
const entry = await client.hGetAll(totpSetupKey(token));
return entry.userId && entry.secret ? { userId: entry.userId, secret: entry.secret } : null;
},
async deleteTotpSetup(token) {
return Boolean(await client.del(totpSetupKey(token)));
},
async close() {
if (client.isOpen) await client.quit();
}
};
}
-40
View File
@@ -1,40 +0,0 @@
export const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' };
export const permissionsByLevel = {
super: ['*'],
school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'payments.write', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'],
class: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'payments.write', 'results.read', 'workflows.inbox']
};
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}`;
}
-28
View File
@@ -1,28 +0,0 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
function signature(secret, type, parts) {
return createHmac('sha256', secret).update([type, ...parts].join('\u001f')).digest('hex').slice(0, 24).toUpperCase();
}
export function resolveDocumentVerificationSecret(env = process.env) {
const configured = String(env.DOCUMENT_VERIFICATION_SECRET || '');
if (env.NODE_ENV === 'production' && configured.length < 32) {
throw new Error('生产环境必须设置至少 32 个字符的 DOCUMENT_VERIFICATION_SECRET');
}
return configured || String(env.SESSION_SECRET || '') || 'development-document-verification-secret';
}
export function scoreReportCode(secret, registration, exam, results = []) {
const scores = [...results].sort((a, b) => String(a.subjectId).localeCompare(String(b.subjectId))).map(item => `${item.subjectId}:${Number(item.score)}:${item.publishedAt || item.updatedAt || ''}`);
return `SR-${signature(secret, 'score-report', [registration.id, registration.userId, exam.id, ...scores])}`;
}
export function admissionNoticeCode(secret, placement, exam) {
return `AN-${signature(secret, 'admission-notice', [placement.id, placement.userId, placement.schoolId, exam.id, placement.payload?.categoryCode || '', placement.payload?.noticeNumber || '', placement.updatedAt || ''])}`;
}
export function safeCodeEqual(left, right) {
const a = Buffer.from(String(left || '').toUpperCase());
const b = Buffer.from(String(right || '').toUpperCase());
return a.length === b.length && timingSafeEqual(a, b);
}
-94
View File
@@ -1,94 +0,0 @@
import sanitizeHtml from 'sanitize-html';
const allowedTags = [
'p', 'br', 'h2', 'h3', 'h4',
'strong', 'em', 'u', 's',
'ul', 'ol', 'li', 'blockquote', 'a',
'figure', 'figcaption', 'img',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'
];
const blockTags = /<\/?(?:p|h[2-4]|ul|ol|li|blockquote|br|figure|figcaption|table|thead|tbody|tfoot|tr|th|td)\b[^>]*>/gi;
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function decodeTextEntities(value) {
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", nbsp: ' ' };
const codePoint = (code, radix) => {
const parsed = Number.parseInt(code, radix);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0x10ffff && !(parsed >= 0xd800 && parsed <= 0xdfff)
? String.fromCodePoint(parsed)
: '';
};
return String(value)
.replace(/&#x([0-9a-f]+);/gi, (_, code) => codePoint(code, 16))
.replace(/&#(\d+);/g, (_, code) => codePoint(code, 10))
.replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/gi, (_, name) => named[name.toLowerCase()]);
}
export function sanitizeNoticeContent(value) {
const source = String(value ?? '').trim().slice(0, 20000);
return sanitizeHtml(source, {
allowedTags,
allowedAttributes: {
a: ['href', 'target', 'rel'],
figure: ['class'],
img: ['src', 'alt'],
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan']
},
allowedClasses: {
figure: [
'image', 'table', 'image-style-inline', 'image-style-block', 'image-style-side',
'image-style-align-left', 'image-style-align-right',
'image-style-block-align-left', 'image-style-block-align-right'
]
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowProtocolRelative: false,
transformTags: {
a(tagName, attributes) {
const safeAttributes = {};
if (attributes.href) safeAttributes.href = attributes.href;
if (attributes.target === '_blank') safeAttributes.target = '_blank';
safeAttributes.rel = 'noopener noreferrer';
return { tagName, attribs: safeAttributes };
},
img(tagName, attributes) {
const safeAttributes = {};
if (/^https?:\/\//i.test(attributes.src || '')) safeAttributes.src = attributes.src;
if (attributes.alt) safeAttributes.alt = attributes.alt;
return { tagName, attribs: safeAttributes };
}
}
});
}
export function noticePlainText(value) {
const sanitized = sanitizeNoticeContent(value).replace(blockTags, ' ');
const withoutTags = sanitizeHtml(sanitized, { allowedTags: [], allowedAttributes: {} });
return decodeTextEntities(withoutTags).replace(/\s+/g, ' ').trim();
}
export function noticeContentHtml(value) {
const source = String(value ?? '').trim();
if (!source) return '';
if (!/<\/?(?:p|h[2-4]|strong|em|u|s|ul|ol|li|blockquote|a|br|figure|figcaption|img|table|thead|tbody|tfoot|tr|th|td)\b/i.test(source)) {
return source
.split(/\r?\n{2,}/)
.map(paragraph => `<p>${escapeHtml(paragraph).replace(/\r?\n/g, '<br>')}</p>`)
.join('');
}
return sanitizeNoticeContent(source);
}
export function noticeForClient(notice) {
return { ...notice, content: sanitizeNoticeContent(notice.content), contentHtml: noticeContentHtml(notice.content) };
}
-54
View File
@@ -1,54 +0,0 @@
export function createSessionManager({ authState, readDb, sendError }) {
function normalizeUser(user) {
if (user?.role === 'admin' && !user.adminLevel) return { ...user, adminLevel: 'super' };
return user;
}
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 ? await authState.getSession(token) : null;
if (!session) return null;
const db = await readDb();
request.authDb = db;
const user = db.users.find(item => item.id === session.userId) || null;
return user?.active === false || user?.archivedAt ? null : normalizeUser(user);
}
function safeUser(user) {
return {
id: user.id,
username: user.username,
role: user.role,
adminLevel: user.role === 'admin' ? user.adminLevel || 'super' : null,
schoolId: user.schoolId || null,
classId: user.classId || null,
displayName: user.displayName,
candidateNumber: user.candidateNumber || null,
mustChangePassword: Boolean(user.mustChangePassword),
totpEnabled: Boolean(user.totpEnabled),
archived: Boolean(user.archivedAt)
};
}
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 };
}
-121
View File
@@ -1,121 +0,0 @@
import {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomBytes,
timingSafeEqual
} from 'node:crypto';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const RECOVERY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
const TOTP_PERIOD_SECONDS = 30;
function encryptionKey() {
const configured = String(process.env.TOTP_ENCRYPTION_KEY || '');
if (process.env.NODE_ENV === 'production' && configured.length < 32) {
throw new Error('生产环境启用 TOTP 前必须设置至少 32 个字符的 TOTP_ENCRYPTION_KEY');
}
const material = configured || `development-only:${process.env.INITIAL_ADMIN_PASSWORD || 'local-exam-system'}`;
return createHash('sha256').update(material).digest();
}
export function assertTotpConfiguration() {
encryptionKey();
}
export function createTotpSecret() {
const bytes = randomBytes(20);
let bits = '';
for (const byte of bytes) bits += byte.toString(2).padStart(8, '0');
let encoded = '';
for (let index = 0; index < bits.length; index += 5) {
encoded += BASE32_ALPHABET[Number.parseInt(bits.slice(index, index + 5).padEnd(5, '0'), 2)];
}
return encoded;
}
function decodeBase32(value) {
const normalized = String(value || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = '';
for (const character of normalized) {
const index = BASE32_ALPHABET.indexOf(character);
if (index < 0) throw new Error('TOTP 密钥格式无效');
bits += index.toString(2).padStart(5, '0');
}
const bytes = [];
for (let index = 0; index + 8 <= bits.length; index += 8) bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
return Buffer.from(bytes);
}
export function totpAtStep(secret, step) {
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(step));
const digest = createHmac('sha1', decodeBase32(secret)).update(counter).digest();
const offset = digest[digest.length - 1] & 0x0f;
const binary = (digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
return String(binary).padStart(6, '0');
}
export function verifyTotp(code, secret, { now = Date.now(), window = 1, lastUsedStep = null } = {}) {
const normalized = String(code || '').replace(/\s/g, '');
if (!/^\d{6}$/.test(normalized)) return null;
const currentStep = Math.floor(now / 1000 / TOTP_PERIOD_SECONDS);
for (let offset = -window; offset <= window; offset += 1) {
const step = currentStep + offset;
if (lastUsedStep != null && step <= Number(lastUsedStep)) continue;
const expected = Buffer.from(totpAtStep(secret, step));
const supplied = Buffer.from(normalized);
if (expected.length === supplied.length && timingSafeEqual(expected, supplied)) return step;
}
return null;
}
export function buildOtpAuthUri({ secret, account, issuer }) {
const label = `${issuer}:${account}`;
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: String(TOTP_PERIOD_SECONDS) });
return `otpauth://totp/${encodeURIComponent(label)}?${params}`;
}
export function encryptTotpSecret(secret) {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(String(secret), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`;
}
export function decryptTotpSecret(value) {
const [version, ivValue, tagValue, encryptedValue] = String(value || '').split('.');
if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) throw new Error('TOTP 密钥数据无效');
const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url'));
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'));
return Buffer.concat([decipher.update(Buffer.from(encryptedValue, 'base64url')), decipher.final()]).toString('utf8');
}
function normalizeRecoveryCode(code) {
return String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
export function hashRecoveryCode(code) {
return createHmac('sha256', encryptionKey()).update(normalizeRecoveryCode(code)).digest('hex');
}
export function createRecoveryCodes(count = 8) {
return Array.from({ length: count }, () => {
let value = '';
const bytes = randomBytes(10);
for (let index = 0; index < 10; index += 1) value += RECOVERY_ALPHABET[bytes[index] % RECOVERY_ALPHABET.length];
return `${value.slice(0, 5)}-${value.slice(5)}`;
});
}
export function consumeRecoveryCode(code, hashes = []) {
const candidate = Buffer.from(hashRecoveryCode(code));
const index = hashes.findIndex(hash => {
const stored = Buffer.from(String(hash || ''));
return stored.length === candidate.length && timingSafeEqual(stored, candidate);
});
if (index < 0) return null;
return hashes.filter((_, itemIndex) => itemIndex !== index);
}
-276
View File
@@ -1,276 +0,0 @@
export const admissionMixingScopes = [
{ code: 'class', name: '班内混编', description: '以班级为边界,同班考生按科目组合穿插编排。' },
{ code: 'school', name: '校内混编', description: '同校跨班混编,优先安排在本校考点。' },
{ code: 'district', name: '县区内混编', description: '同县区跨学校混编,优先使用本县区考点。' },
{ code: 'city', name: '市内混编', description: '同市跨县区混编,优先使用本市考点。' },
{ code: 'province', name: '省内混编', description: '全省范围混编,按容量和科目组合选择考点。' }
];
const mixingScopeCodes = new Set(admissionMixingScopes.map(item => item.code));
function arrangementError(message, status = 409) {
return Object.assign(new Error(message), { status });
}
function normalizedCode(value, fallback = '') {
return String(value || fallback).replace(/[^0-9A-Z]/gi, '').toUpperCase();
}
function hashText(value) {
let hash = 2166136261;
for (const char of String(value)) {
hash ^= char.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function stableCompare(seed, left, right) {
return hashText(`${seed}:${left.id}`) - hashText(`${seed}:${right.id}`) || left.id.localeCompare(right.id);
}
function scopeValue(profile, scope) {
if (scope === 'class') return profile.classId;
if (scope === 'school') return profile.schoolId;
if (scope === 'district') return profile.districtCode;
if (scope === 'city') return profile.cityCode;
return profile.provinceCode;
}
function centerMatchesProfile(center, profile, scope) {
if (scope === 'class' || scope === 'school') return center.schoolId === profile.schoolId;
if (scope === 'district') return Boolean(profile.districtCode && center.districtCode === profile.districtCode);
if (scope === 'city') return Boolean(profile.cityCode && center.cityCode === profile.cityCode);
return Boolean(profile.provinceCode && center.provinceCode === profile.provinceCode);
}
function localityScore(center, profile) {
if (center.schoolId === profile.schoolId) return 5000;
if (profile.districtCode && center.districtCode === profile.districtCode) return 1000;
if (profile.cityCode && center.cityCode === profile.cityCode) return 300;
if (profile.provinceCode && center.provinceCode === profile.provinceCode) return 100;
return 0;
}
function minutes(value) {
const [hour, minute] = String(value || '').split(':').map(Number);
return Number.isFinite(hour) && Number.isFinite(minute) ? hour * 60 + minute : NaN;
}
function overlappingSubjects(subjects) {
for (let left = 0; left < subjects.length; left += 1) {
for (let right = left + 1; right < subjects.length; right += 1) {
const first = subjects[left];
const second = subjects[right];
if (first.date !== second.date) continue;
const firstStart = minutes(first.start);
const firstEnd = minutes(first.end);
const secondStart = minutes(second.start);
const secondEnd = minutes(second.end);
if ([firstStart, firstEnd, secondStart, secondEnd].every(Number.isFinite)
&& firstStart < secondEnd && secondStart < firstEnd) return [first, second];
}
}
return null;
}
function parseRuleSegments(rule) {
if (Array.isArray(rule.segments)) return rule.segments;
try { return JSON.parse(rule.segmentsJson || '[]'); } catch { return []; }
}
function segmentValue(segment, sources, sequence) {
const source = segment.source === 'sequence' ? String(sequence) : String(sources[segment.source] || '');
if (!source) throw arrangementError(`准考证号规则需要“${segment.label || segment.source}”,但考生或考点档案中缺少该值`);
const width = Math.max(0, Number(segment.width || 0));
return width ? source.padStart(width, '0') : source;
}
function formatAdmissionNumber(rule, sources, sequence) {
const segments = parseRuleSegments(rule);
if (!segments.length) throw arrangementError('所选准考证号规则没有可用的组成段');
return segments.map(segment => segmentValue(segment, sources, sequence)).join(rule.separator || '');
}
function sequenceGroup(rule, sources) {
if (rule.code.startsWith('district_')) return sources.district_code;
if (rule.code.startsWith('center_school_')) return sources.center_school_code;
if (rule.code.startsWith('candidate_school_')) return sources.candidate_school_code;
return 'all';
}
export function buildAdmissionArrangement(db, options) {
const exam = db.exams.find(item => item.id === options.examId);
if (!exam) throw arrangementError('考试不存在', 404);
const mixingScope = String(options.mixingScope || 'school');
if (!mixingScopeCodes.has(mixingScope)) throw arrangementError('请选择有效的混编范围', 400);
const rule = db.admissionNumberRules.find(item => item.id === options.numberRuleId && item.active !== false);
if (!rule) throw arrangementError('请选择有效的准考证号规则', 400);
const seed = String(options.seed || exam.code || exam.id).slice(0, 80);
const warnings = [];
const warn = message => { if (!warnings.includes(message)) warnings.push(message); };
const subjectOrder = new Map(exam.subjects.map((subject, index) => [subject.id, index]));
const registrations = db.registrations.filter(item => item.examId === exam.id && item.status === 'approved');
if (!registrations.length) throw arrangementError('该考试没有已审核通过的报名,无法编排');
const candidates = registrations.map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
if (!profile) throw arrangementError(`报名 ${registration.id} 缺少考生档案`);
const boundary = scopeValue(profile, mixingScope);
if (!boundary) throw arrangementError(`${profile.name} 缺少${admissionMixingScopes.find(item => item.code === mixingScope)?.name.replace('内混编', '') || '范围'}信息`);
const subjects = registration.subjectIds.map(id => exam.subjects.find(subject => subject.id === id)).filter(Boolean);
if (subjects.length !== registration.subjectIds.length || !subjects.length) throw arrangementError(`${profile.name} 的报考科目无效`);
const overlap = overlappingSubjects(subjects);
if (overlap) throw arrangementError(`${profile.name} 报考的“${overlap[0].name}”与“${overlap[1].name}”时间冲突`);
const orderedSubjectIds = subjects.sort((a, b) => subjectOrder.get(a.id) - subjectOrder.get(b.id)).map(subject => subject.id);
return {
id: registration.id,
registration,
profile,
subjectIds: orderedSubjectIds,
signature: orderedSubjectIds.join('|'),
scopeKey: `${mixingScope}:${boundary}`,
centerId: null
};
});
const activeCenters = db.testCenters.filter(center => center.status === 'active').map(center => {
const allRooms = db.testRooms.filter(room => room.centerId === center.id && room.status === 'active');
const regularRooms = allRooms.filter(room => room.roomType !== 'spare');
const rooms = regularRooms.length ? regularRooms : allRooms;
if (!regularRooms.length && allRooms.length) warn(`${center.name} 没有普通启用考场,本次将使用备用考场`);
return { ...center, rooms, allRooms };
}).filter(center => center.rooms.length);
if (!activeCenters.length) throw arrangementError('没有可用的启用考点和考场');
const remaining = new Map(activeCenters.map(center => [center.id, new Map(
exam.subjects.map(subject => [subject.id, center.rooms.reduce((sum, room) => sum + Number(room.capacity), 0)])
)]));
const assignedCounts = new Map(activeCenters.map(center => [center.id, 0]));
const affinity = new Map();
const groups = new Map();
for (const candidate of candidates) {
const key = `${candidate.scopeKey}:${candidate.signature}`;
const group = groups.get(key) || [];
group.push(candidate);
groups.set(key, group);
}
const orderedGroups = [...groups.entries()].sort((left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0]));
for (const [, group] of orderedGroups) {
group.sort((left, right) => stableCompare(seed, left, right));
for (const candidate of group) {
const eligible = activeCenters.filter(center => candidate.subjectIds.every(subjectId => (remaining.get(center.id).get(subjectId) || 0) > 0));
if (!eligible.length) throw arrangementError(`${candidate.profile.name} 的全部报考科目无法在同一考点容纳;请增加考场容量或缩小本次报名范围`);
const local = eligible.filter(center => centerMatchesProfile(center, candidate.profile, mixingScope));
if (!local.length) warn(`${candidate.profile.name} 所属范围内没有足够考点,已跨范围使用可用考点`);
const pool = local.length ? local : eligible;
const selected = [...pool].sort((left, right) => {
const leftAffinity = affinity.get(`${left.id}:${candidate.signature}`) || 0;
const rightAffinity = affinity.get(`${right.id}:${candidate.signature}`) || 0;
const leftCapacity = Math.min(...candidate.subjectIds.map(id => remaining.get(left.id).get(id)));
const rightCapacity = Math.min(...candidate.subjectIds.map(id => remaining.get(right.id).get(id)));
const leftScore = leftAffinity * 100000 + localityScore(left, candidate.profile) + leftCapacity - (assignedCounts.get(left.id) || 0);
const rightScore = rightAffinity * 100000 + localityScore(right, candidate.profile) + rightCapacity - (assignedCounts.get(right.id) || 0);
return rightScore - leftScore || left.code.localeCompare(right.code);
})[0];
candidate.centerId = selected.id;
candidate.subjectIds.forEach(subjectId => remaining.get(selected.id).set(subjectId, remaining.get(selected.id).get(subjectId) - 1));
assignedCounts.set(selected.id, (assignedCounts.get(selected.id) || 0) + 1);
affinity.set(`${selected.id}:${candidate.signature}`, (affinity.get(`${selected.id}:${candidate.signature}`) || 0) + 1);
}
}
const assignments = new Map(candidates.map(candidate => [candidate.id, []]));
const roomUses = [];
for (const subject of exam.subjects) {
for (const center of activeCenters) {
const subjectCandidates = candidates.filter(candidate => candidate.centerId === center.id && candidate.subjectIds.includes(subject.id));
if (!subjectCandidates.length) continue;
subjectCandidates.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey)
|| left.signature.localeCompare(right.signature) || stableCompare(seed, left, right));
let roomIndex = 0;
let seatIndex = 0;
let currentScopeKey = '';
const rooms = [...center.rooms].sort((left, right) => left.code.localeCompare(right.code) || left.id.localeCompare(right.id));
for (const candidate of subjectCandidates) {
if (currentScopeKey && candidate.scopeKey !== currentScopeKey && seatIndex > 0) { roomIndex += 1; seatIndex = 0; }
currentScopeKey = candidate.scopeKey;
while (roomIndex < rooms.length && seatIndex >= Number(rooms[roomIndex].capacity)) { roomIndex += 1; seatIndex = 0; }
const room = rooms[roomIndex];
if (!room) throw arrangementError(`${center.name} 在“${subject.name}”科目下按${admissionMixingScopes.find(item => item.code === mixingScope)?.name || '当前范围'}隔离后考场不足;请增加考场或扩大混编范围`);
const seatNumber = Number(room.seatStart || 1) + seatIndex;
const assignment = {
registrationId: candidate.id,
subjectId: subject.id,
centerId: center.id,
centerName: center.name,
roomId: room.id,
roomName: room.name,
roomCode: room.code,
building: room.building || '',
floor: room.floor || '',
examRoomCode: '',
seat: String(seatNumber).padStart(2, '0'),
subjectSignature: candidate.signature
};
assignments.get(candidate.id).push(assignment);
roomUses.push({ centerCode: center.code, roomCode: room.code, roomId: room.id });
seatIndex += 1;
}
}
}
const examRoomCodes = new Map([...new Map(roomUses.map(item => [item.roomId, item])).values()]
.sort((left, right) => left.centerCode.localeCompare(right.centerCode) || left.roomCode.localeCompare(right.roomCode) || left.roomId.localeCompare(right.roomId))
.map((item, index) => [item.roomId, String(index + 1).padStart(3, '0')]));
for (const subjectAssignments of assignments.values()) {
subjectAssignments.sort((left, right) => subjectOrder.get(left.subjectId) - subjectOrder.get(right.subjectId));
subjectAssignments.forEach(item => { item.examRoomCode = examRoomCodes.get(item.roomId); });
}
const sequenceCounters = new Map();
const numbers = new Set();
const cards = candidates.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey) || stableCompare(seed, left, right)).map(candidate => {
const center = activeCenters.find(item => item.id === candidate.centerId);
const primary = assignments.get(candidate.id)[0];
const candidateSchool = db.schools.find(item => item.id === candidate.profile.schoolId);
const centerSchool = db.schools.find(item => item.id === center.schoolId);
const sources = {
district_code: normalizedCode(candidate.profile.districtCode || center.districtCode),
center_school_code: normalizedCode(centerSchool?.code),
candidate_school_code: normalizedCode(candidateSchool?.code),
exam_room_code: primary.examRoomCode,
seat: primary.seat
};
const counterKey = sequenceGroup(rule, sources);
const sequence = (sequenceCounters.get(counterKey) || 0) + 1;
sequenceCounters.set(counterKey, sequence);
const number = formatAdmissionNumber(rule, sources, sequence);
if (numbers.has(number)) throw arrangementError(`规则“${rule.name}”生成了重复准考证号 ${number},请检查规则组成`);
numbers.add(number);
return {
registrationId: candidate.id,
number,
centerId: center.id,
testCenter: center.name,
centerCode: center.code || '',
centerAddress: [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '),
generatedAt: options.generatedAt,
assignments: assignments.get(candidate.id)
};
});
const usedCenters = new Set(cards.map(card => card.centerId));
const sameSchoolCenterCount = candidates.filter(candidate => activeCenters.find(center => center.id === candidate.centerId)?.schoolId === candidate.profile.schoolId).length;
const summary = {
candidateCount: candidates.length,
centerCount: usedCenters.size,
subjectAssignmentCount: cards.reduce((sum, card) => sum + card.assignments.length, 0),
subjectCombinationCount: new Set(candidates.map(candidate => candidate.signature)).size,
sameSchoolCenterCount,
sameSchoolCenterRate: Number((sameSchoolCenterCount * 100 / candidates.length).toFixed(1)),
reservedSpareRooms: activeCenters.reduce((sum, center) => sum + center.allRooms.filter(room => room.roomType === 'spare' && !center.rooms.includes(room)).length, 0)
};
return { exam, rule, mixingScope, seed, warnings, summary, cards };
}
-63
View File
@@ -1,63 +0,0 @@
import { admissionRecords, admissionRoundPublications, admissionSetting } from './volunteer-admission.mjs';
const h = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
export function systemNotificationItems(db) {
const examName = examId => db.exams.find(item => item.id === examId)?.name || '未知考试';
const schoolName = schoolId => db.schools.find(item => item.id === schoolId)?.name || '未知学校';
const item = (record, sourceType, category, title, summary, publishAt, content) => ({
id: record.id,
noticeId: `system-${sourceType}-${record.id}`,
sourceType,
schoolId: record.schoolId || null,
examId: record.examId,
category,
title,
summary,
content,
author: '系统自动发布',
publishAt,
publishedAt: publishAt,
pinned: false,
visible: record.payload?.publicVisible !== false,
status: record.payload?.publicVisible === false ? 'hidden' : 'visible'
});
const plans = admissionRecords(db, 'plan').filter(record => record.status === 'approved').map(record => item(
record, 'plan', '招生计划', `${examName(record.examId)} · ${schoolName(record.schoolId)}招生计划公示`,
'招生计划审核通过,类别人数与指标分配已经公开。', record.payload?.reviewedAt || record.updatedAt,
`<p>${h(schoolName(record.schoolId))}招生计划已经审核通过。</p><ul>${(record.payload?.categories || []).map(category => `<li>${h(category.name)}${Number(category.quota || 0)} 人</li>`).join('')}</ul>`
));
const qualifications = admissionRecords(db, 'qualification_publication').filter(record => record.status === 'published').map(record => item(
record, 'qualification', '指标资格', `${examName(record.examId)} · ${schoolName(record.schoolId)}指标分配资格公示`,
'生源学校资格确认完成,系统已生成指标分配资格公示。', record.payload?.publishedAt || record.updatedAt,
`<p>${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。</p>`
));
const roundAdmissions = admissionRoundPublications(db).filter(record => admissionSetting(db, record.examId)?.payload?.autoPublish !== false).map(record => item(
record, 'admission', '录取名单', `${examName(record.examId)}${record.round} 轮录取名单公示`,
`${record.round} 轮录取通知书已签发,共 ${record.rows.length} 名考生进入本轮录取公示。`, record.publishedAt,
`<p>${h(examName(record.examId))}${record.round} 轮录取工作已经完成,共 ${record.rows.length} 名考生正式录取。</p>`
));
const admissions = admissionRecords(db, 'setting').filter(record => record.status === 'completed' && record.payload?.autoPublish !== false).map(record => item(
record, 'admission', '录取名单', `${examName(record.examId)}最终录取名单`,
'录取与报到决策已经办结,最终录取结果已自动公开。', record.payload?.completedAt || record.updatedAt,
`<p>${h(examName(record.examId))}录取工作已经完成,请在招生录取公示中查询脱敏结果。</p>`
));
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(record => record.status === 'published' && admissionSetting(db, record.examId)?.payload?.autoPublish !== false).map(record => item(
record, 'cutoff', '录取分数线', `${examName(record.examId)}录取分数线`,
'各招生学校和类别录取分数线已经由系统汇总发布。', record.payload?.publishedAt || record.updatedAt,
`<p>系统已汇总 ${Number(record.payload?.rows?.length || 0)} 个学校招生类别的录取分数线。</p>`
));
const reports = admissionRecords(db, 'notification').filter(record => record.userId == null && record.status === 'approved' && record.payload?.type === 'admission_reporting').map(record => {
const stats = record.payload?.statistics || {};
const supplement = record.payload?.supplementDecision === 'supplement';
const title = supplement
? `${examName(record.examId)} · ${schoolName(record.schoolId)}考生报到情况及补录说明`
: `${examName(record.examId)} · ${schoolName(record.schoolId)}考生报到情况公示`;
const summary = `计划 ${Number(stats.totalQuota || 0)} 人,已报到 ${Number(stats.reportedCount || 0)} 人,完成率 ${Number(stats.reportingRate || 0)}%。`;
const decision = supplement ? '学校申请补录并已获批准。' : (record.payload?.decisionNote || '本轮不进行补录。');
return item(record, 'reporting', '考生报到', title, summary, record.payload?.approvedAt || record.updatedAt,
`<p>${h(summary)}</p><p>${h(decision)}</p><ul><li>正式录取:${Number(stats.finalCount || 0)} 人</li><li>已报到:${Number(stats.reportedCount || 0)} 人</li><li>未报到:${Number(stats.notReportedCount || 0)} 人</li><li>计划缺额:${Number(stats.reportingGap || 0)} 人</li></ul><p>${h(record.payload?.approvalNote || '')}</p>`);
});
return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports]
.sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt));
}
-350
View File
@@ -1,350 +0,0 @@
export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'reporting', 'supplementary', 'completed']);
export function admissionRecords(db, kind, examId = null) {
return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId));
}
export function admissionSetting(db, examId) {
return admissionRecords(db, 'setting', examId)[0] || null;
}
export function candidateTotalScore(db, examId, userId) {
const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved');
if (!registration) return null;
const results = db.results.filter(item => item.registrationId === registration.id && item.published);
if (!registration.subjectIds.length || registration.subjectIds.some(id => !results.some(result => result.subjectId === id))) return null;
return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2));
}
export function candidateAdmissionScore(db, examId, userId, category = {}) {
const culturalScore = candidateTotalScore(db, examId, userId);
if (culturalScore == null) return null;
const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved');
const featureScore = Number(registration?.featureScore || 0);
const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
return Number((culturalScore + (usesFeatureScore ? featureScore : 0)).toFixed(2));
}
export function activePreference(db, examId, userId, round) {
return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null;
}
export function indicatorQualification(db, examId, userId) {
return admissionRecords(db, 'indicator_qualification', examId).find(item => item.userId === userId) || null;
}
export function sourceSchoolQualificationStatus(db, examId, schoolId) {
const profiles = db.candidateProfiles.filter(profile => profile.schoolId === schoolId && profile.profileCompleted && db.users.some(user => user.id === profile.userId && user.role === 'candidate' && user.active));
const qualifications = admissionRecords(db, 'indicator_qualification', examId).filter(item => item.schoolId === schoolId && item.status === 'confirmed');
const byUser = new Map(qualifications.map(item => [item.userId, item]));
const rows = profiles.map(profile => {
const account = db.users.find(item => item.id === profile.userId) || {};
const specialty = resolveProfileSpecialty(profile);
const qualification = byUser.get(profile.userId) || null;
return {
userId: profile.userId,
registrationNumber: account.candidateNumber || '',
name: profile.name || account.displayName || '',
eligible: qualification?.payload?.eligible === true,
confirmed: Boolean(qualification),
confirmedAt: qualification?.payload?.confirmedAt || qualification?.updatedAt || '',
specialtyCategory: specialty.category,
specialtyType: specialty.type,
specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生'
};
}).sort((left, right) => left.registrationNumber.localeCompare(right.registrationNumber));
return { total: rows.length, confirmed: rows.filter(item => item.confirmed).length, complete: rows.length > 0 && rows.every(item => item.confirmed), rows };
}
export function approvedPlans(db, examId) {
return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved');
}
export function supplementarySchoolIds(db, setting) {
if (setting?.status !== 'supplementary') return null;
const sourceRound = Math.max(1, Number(setting.payload?.round || 1) - 1);
const schoolIds = admissionRecords(db, 'notification', setting.examId)
.filter(item => item.userId == null
&& item.status === 'approved'
&& item.payload?.type === 'admission_reporting'
&& Number(item.payload?.round || 1) === sourceRound
&& item.payload?.supplementDecision === 'supplement')
.map(item => item.schoolId)
.filter(Boolean);
// Older data could enter the supplementary phase without reporting decisions.
// Preserve that legacy behavior, while new rounds are restricted to approved schools.
return schoolIds.length ? new Set(schoolIds) : null;
}
export function planSummary(plan) {
const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : [];
return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) };
}
export function admissionReportingRecords(db, examId, schoolId) {
return admissionRecords(db, 'notification', examId)
.filter(item => item.schoolId === schoolId && item.userId == null && item.payload?.type === 'admission_reporting')
.sort((left, right) => Number(right.payload?.round || 1) - Number(left.payload?.round || 1) || new Date(right.updatedAt) - new Date(left.updatedAt));
}
export function admissionReportingRecord(db, examId, schoolId, round = null) {
return admissionReportingRecords(db, examId, schoolId).find(item => round == null || Number(item.payload?.round || 1) === Number(round)) || null;
}
export function admissionPlanProgress(db, plan) {
const totalQuota = (plan.payload?.categories || []).reduce((sum, item) => sum + Number(item.quota || 0), 0);
const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status));
const finalPlacements = placements.filter(item => item.status === 'final');
const reportingRecords = admissionReportingRecords(db, plan.examId, plan.schoolId);
const reporting = reportingRecords[0] || null;
const reportingRows = new Map();
for (const record of [...reportingRecords].reverse()) for (const row of record.payload?.rows || []) reportingRows.set(row.placementId, row);
const reportedCount = finalPlacements.filter(item => reportingRows.get(item.id)?.status === 'reported').length;
const notReportedCount = finalPlacements.filter(item => reportingRows.get(item.id)?.status === 'not_reported').length;
const pendingReportingCount = Math.max(0, finalPlacements.length - reportedCount - notReportedCount);
const percent = value => totalQuota ? Number((value / totalQuota * 100).toFixed(1)) : 0;
return {
examId: plan.examId,
schoolId: plan.schoolId,
totalQuota,
placedCount: placements.length,
finalCount: finalPlacements.length,
reportedCount,
notReportedCount,
pendingReportingCount,
admissionRate: percent(finalPlacements.length),
reportingRate: percent(reportedCount),
remainingQuota: Math.max(0, totalQuota - finalPlacements.length),
reportingGap: Math.max(0, totalQuota - reportedCount),
reportingStatus: reporting?.status || 'not_started',
supplementDecision: reporting?.payload?.supplementDecision || '',
reportingUpdatedAt: reporting?.updatedAt || null
};
}
function documentCodePart(value, fallback) {
const normalized = String(value || '').trim().toUpperCase().replace(/[^A-Z0-9-]+/g, '');
return normalized || fallback;
}
export function assignAdmissionNoticeNumbers(db, placements) {
const counters = new Map();
for (const item of admissionRecords(db, 'placement')) {
const serial = Number(item.payload?.noticeSerial || String(item.payload?.noticeNumber || '').match(/(\d{6})$/)?.[1] || 0);
if (!serial) continue;
const key = `${item.schoolId}\u0000${item.examId}`;
counters.set(key, Math.max(counters.get(key) || 0, serial));
}
const accountNumber = userId => db.users.find(item => item.id === userId)?.candidateNumber || userId;
const output = [];
const grouped = new Map();
for (const placement of placements) {
const key = `${placement.schoolId}\u0000${placement.examId}`;
const rows = grouped.get(key) || [];
rows.push(placement);
grouped.set(key, rows);
}
for (const [key, rows] of grouped) {
let serial = counters.get(key) || 0;
rows.sort((left, right) => String(accountNumber(left.userId)).localeCompare(String(accountNumber(right.userId))));
for (const placement of rows) {
if (placement.payload?.noticeNumber) {
output.push(placement);
continue;
}
serial += 1;
const school = db.schools.find(item => item.id === placement.schoolId) || {};
const exam = db.exams.find(item => item.id === placement.examId) || {};
const noticeSerial = serial;
const noticeNumber = `${documentCodePart(school.code, 'SCHOOL')}-${documentCodePart(exam.code, 'EXAM')}-${String(noticeSerial).padStart(6, '0')}`;
output.push({ ...placement, payload: { ...placement.payload, noticeSerial, noticeNumber } });
}
counters.set(key, serial);
}
return output;
}
export function publicAdmissionRows(db, examId, options = {}) {
const round = Math.max(0, Number(options.round || 0));
return admissionRecords(db, 'placement', examId).filter(item => round
? Number(item.payload?.finalizedRound || 1) === round && ['final', 'forfeited'].includes(item.status)
: item.status === 'final').map(item => {
const user = db.users.find(entry => entry.id === item.userId) || {};
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
const school = db.schools.find(entry => entry.id === item.schoolId) || {};
return {
registrationNumber: user.candidateNumber || '',
name: profile.name || user.displayName || '',
totalScore: Number(item.payload?.totalScore || 0),
admittedSchool: school.name || '',
categoryName: item.payload?.categoryName || '',
idNumberMasked: profile.idNumber ? `${profile.idNumber.slice(0, 3)}***********${profile.idNumber.slice(-2)}` : '',
phoneMasked: profile.phone ? `${profile.phone.slice(0, 3)}****${profile.phone.slice(-4)}` : ''
};
}).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber));
}
export function admissionRoundPublications(db) {
const stored = admissionRecords(db, 'notification')
.filter(item => item.userId == null && item.status === 'published' && item.payload?.type === 'admission_round_publication')
.map(item => ({ ...item, round: Number(item.payload?.round || 1), publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [], virtual: false }));
const keys = new Set(stored.map(item => `${item.examId}:${item.round}`));
const fallback = admissionRecords(db, 'setting').filter(item => ['reporting', 'supplementary', 'completed'].includes(item.status)).flatMap(setting => {
const rounds = admissionRecords(db, 'placement', setting.examId)
.filter(item => ['final', 'forfeited'].includes(item.status))
.map(item => Number(item.payload?.finalizedRound || 1));
const round = rounds.length ? Math.max(...rounds) : 0;
if (!round || keys.has(`${setting.examId}:${round}`)) return [];
return [{
id: `${setting.id}-round-${round}`,
sourceRecordId: setting.id,
kind: 'notification',
examId: setting.examId,
schoolId: null,
userId: null,
status: 'published',
createdAt: setting.updatedAt,
updatedAt: setting.updatedAt,
round,
publishedAt: setting.payload?.roundPublishedAt || setting.updatedAt,
rows: publicAdmissionRows(db, setting.examId, { round }),
virtual: true,
payload: { type: 'admission_round_publication', round, publicVisible: setting.payload?.publicVisible, publishedAt: setting.payload?.roundPublishedAt || setting.updatedAt }
}];
});
return [...stored, ...fallback].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
}
export function admissionCutoffRows(db, examId) {
const groups = new Map();
for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) {
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
const row = groups.get(key) || {
schoolId: placement.schoolId,
schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '',
categoryCode: placement.payload?.categoryCode || '',
categoryName: placement.payload?.categoryName || '',
admittedCount: 0,
planQuota: 0,
highestScore: null,
cutoffScore: null
};
const score = Number(placement.payload?.totalScore || 0);
row.admittedCount += 1;
row.highestScore = row.highestScore == null ? score : Math.max(row.highestScore, score);
row.cutoffScore = row.cutoffScore == null ? score : Math.min(row.cutoffScore, score);
groups.set(key, row);
}
for (const plan of approvedPlans(db, examId)) for (const category of plan.payload?.categories || []) {
const row = groups.get(categoryKey(plan.schoolId, category.code));
if (row) row.planQuota = Number(category.quota || 0);
}
return [...groups.values()].sort((left, right) => left.schoolName.localeCompare(right.schoolName) || left.categoryName.localeCompare(right.categoryName));
}
function categoryKey(schoolId, code) {
return `${schoolId}|${code}`;
}
export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
const examId = setting.examId;
const round = Number(setting.payload?.round || 1);
const supplementarySchools = supplementarySchoolIds(db, setting);
const plans = approvedPlans(db, examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId));
const categories = new Map();
for (const plan of plans) for (const category of plan.payload?.categories || []) {
categories.set(categoryKey(plan.schoolId, category.code), { plan, category });
}
const allExisting = admissionRecords(db, 'placement', examId);
const existing = allExisting.filter(item => !['withdrawn', 'forfeited'].includes(item.status));
const occupiedIndicators = new Map();
const occupiedGeneral = new Map();
for (const placement of existing) {
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
if (placement.payload?.quotaBucket?.startsWith('indicator:')) {
const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`;
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
} else occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1);
}
const preferences = admissionRecords(db, 'preference', examId).filter(item => Number(item.payload?.round || 1) === round && item.status === 'submitted');
const candidates = preferences.map(preference => {
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
const account = db.users.find(item => item.id === preference.userId) || {};
const registration = db.registrations.find(item => item.examId === examId && item.userId === preference.userId && item.status === 'approved');
return { preference, profile, account, registration, culturalScore: candidateTotalScore(db, examId, preference.userId), featureScore: Number(registration?.featureScore || 0), nextChoiceIndex: 0 };
}).filter(item => item.culturalScore != null && !allExisting.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(entry.status)));
const compareProposals = (left, right) => right.totalScore - left.totalScore || String(left.candidate.account.candidateNumber || '').localeCompare(String(right.candidate.account.candidateNumber || ''));
const acceptedByBucket = new Map();
const queue = [...candidates].sort((left, right) => right.culturalScore - left.culturalScore || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
while (queue.length) {
const candidate = queue.shift();
const choices = candidate.preference.payload?.choices || [];
while (candidate.nextChoiceIndex < choices.length) {
const index = candidate.nextChoiceIndex;
const choice = choices[candidate.nextChoiceIndex++];
const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
if (!target) continue;
const { category } = target;
if (!candidateEligibleForCategory(candidate.profile, category)) continue;
const key = categoryKey(choice.schoolId, choice.categoryCode);
let quotaBucket = null;
let bucketKey = '';
let capacity = 0;
let occupiedCount = 0;
if (choice.preferenceType === 'indicator') {
const qualification = indicatorQualification(db, examId, candidate.preference.userId);
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
if (!qualification?.payload?.eligible || !allocation) continue;
const indicatorKey = `${key}|${candidate.profile.schoolId}`;
quotaBucket = `indicator:${candidate.profile.schoolId}`;
bucketKey = quotaBucket + '|' + key;
capacity = Number(allocation.quota || 0);
occupiedCount = occupiedIndicators.get(indicatorKey) || 0;
} else {
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
quotaBucket = 'general';
bucketKey = `general|${key}`;
capacity = generalQuota;
occupiedCount = occupiedGeneral.get(key) || 0;
}
const available = Math.max(0, capacity - occupiedCount);
if (!available) continue;
const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
const proposal = {
candidate, choice, category, index, quotaBucket,
culturalScore: candidate.culturalScore,
featureScore: candidate.featureScore,
totalScore: Number((candidate.culturalScore + (usesFeatureScore ? candidate.featureScore : 0)).toFixed(2))
};
const accepted = acceptedByBucket.get(bucketKey) || [];
accepted.push(proposal);
accepted.sort(compareProposals);
const rejected = accepted.length > available ? accepted.pop() : null;
acceptedByBucket.set(bucketKey, accepted);
if (rejected && rejected !== proposal) queue.push(rejected.candidate);
if (rejected === proposal) continue;
break;
}
}
const accepted = [...acceptedByBucket.values()].flat().sort(compareProposals);
return accepted.map(({ candidate, choice, category, index, quotaBucket, culturalScore, featureScore, totalScore }) => ({
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
culturalScore, totalScore, featureScore,
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
}
}));
}
export function remainingPlanQuota(db, plan) {
return (plan.payload?.categories || []).map(category => {
const used = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status)).length;
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
});
}
import { candidateEligibleForCategory, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
@@ -6,6 +6,29 @@ namespace Eis.Infrastructure.Tests.Data;
public sealed class DatabaseInitializerTests
{
[Fact]
public async Task EmbeddedSchemas_AreNativeSqlResources()
{
var assembly = typeof(DatabaseInitializer).Assembly;
var resourceNames = assembly.GetManifestResourceNames();
var sqliteName = Assert.Single(resourceNames, name => name.EndsWith("Schema.sqlite.sql", StringComparison.Ordinal));
var mysqlName = Assert.Single(resourceNames, name => name.EndsWith("Schema.mysql.sql", StringComparison.Ordinal));
var sqlite = await ReadResourceAsync(assembly, sqliteName);
var mysql = (await ReadResourceAsync(assembly, mysqlName)).ReplaceLineEndings("\n");
var mysqlStatements = mysql.Split(
"\n-- EIS-STATEMENT\n",
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Assert.Contains("CREATE TABLE IF NOT EXISTS schema_metadata", sqlite, StringComparison.Ordinal);
Assert.DoesNotContain("export const", sqlite, StringComparison.Ordinal);
Assert.True(mysqlStatements.Length >= 30);
Assert.All(mysqlStatements, statement =>
Assert.StartsWith("CREATE TABLE IF NOT EXISTS", statement, StringComparison.Ordinal));
Assert.Contains("`separator` VARCHAR(10)", mysql, StringComparison.Ordinal);
Assert.DoesNotContain("export const", mysql, StringComparison.Ordinal);
}
[Fact]
public async Task InitializeAsync_CreatesFreshDatabaseAndIsIdempotent()
{
@@ -49,4 +72,12 @@ public sealed class DatabaseInitializerTests
command.CommandText = sql;
return Convert.ToInt64(await command.ExecuteScalarAsync());
}
private static async Task<string> ReadResourceAsync(System.Reflection.Assembly assembly, string name)
{
await using var stream = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException($"Missing resource: {name}");
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
}
-136
View File
@@ -1,136 +0,0 @@
import assert from 'node:assert/strict';
import { admissionCutoffRows, admissionPlanProgress, admissionRoundPublications, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus, supplementarySchoolIds } from '../src/services/volunteer-admission.mjs';
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
import { systemNotificationItems } from '../src/services/system-notifications.mjs';
const now = '2026-07-21T08:00:00.000Z';
let sequence = 0;
const db = {
users: [
{ id: 'u-high', role: 'candidate', active: true, candidateNumber: '20260001', displayName: '高分考生' },
{ id: 'u-low', role: 'candidate', active: true, candidateNumber: '20260002', displayName: '次高考生' },
{ id: 'u-sport', role: 'candidate', active: true, candidateNumber: '20260003', displayName: '特长考生' }
],
candidateProfiles: [
{ userId: 'u-high', name: '高分考生', schoolId: 'source-a', profileCompleted: true, idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] },
{ userId: 'u-low', name: '次高考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] },
{ userId: 'u-sport', name: '特长考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200903031234', phone: '13712345678', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] }
],
schools: [
{ id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' },
{ id: 'target-a', name: '第一中学' }, { id: 'target-b', name: '第二中学' }
],
registrations: [
{ id: 'r-high', examId: 'exam', userId: 'u-high', status: 'approved', subjectIds: ['cn', 'math'] },
{ id: 'r-low', examId: 'exam', userId: 'u-low', status: 'approved', subjectIds: ['cn', 'math'] },
{ id: 'r-sport', examId: 'exam', userId: 'u-sport', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 88.5 }
],
results: [
{ registrationId: 'r-high', subjectId: 'cn', score: 120, published: true }, { registrationId: 'r-high', subjectId: 'math', score: 130, published: true },
{ registrationId: 'r-low', subjectId: 'cn', score: 118, published: true }, { registrationId: 'r-low', subjectId: 'math', score: 126, published: true },
{ registrationId: 'r-sport', subjectId: 'cn', score: 105, published: true }, { registrationId: 'r-sport', subjectId: 'math', score: 110, published: true }
],
admissionRecords: [
{ id: 'plan-a', kind: 'plan', examId: 'exam', schoolId: 'target-a', status: 'approved', payload: { categories: [{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }] } },
{ id: 'plan-b', kind: 'plan', examId: 'exam', schoolId: 'target-b', status: 'approved', payload: { categories: [
{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] },
{ code: 'sport', name: '田径特长生', quota: 1, specialtyCategory: 'sports', specialtyType: 'track_field', indicatorAllocations: [{ sourceSchoolId: 'source-b', quota: 1 }] }
] } },
{ id: 'qual-low', kind: 'indicator_qualification', examId: 'exam', userId: 'u-low', schoolId: 'source-b', status: 'confirmed', payload: { eligible: false } },
{ id: 'qual-sport', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
{ id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } },
{ id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } },
{ id: 'pref-sport', kind: 'preference', examId: 'exam', userId: 'u-sport', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
]
};
const setting = { examId: 'exam', payload: { round: 1 } };
const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now });
assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和');
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { code: 'general' }), 215, '普通招生类别不得加入特征分');
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { specialtyCategory: 'sports', specialtyType: 'track_field' }), 303.5, '特长生招生类别应使用文化课总分加特征分');
assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档');
assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送');
assert.equal(placements.find(item => item.userId === 'u-sport').payload.culturalScore, 215, '特长生投档材料应保留文化课原始总分');
assert.equal(placements.find(item => item.userId === 'u-sport').payload.totalScore, 303.5, '特长生类别投档总分应加入特征分');
assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类');
assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划');
const specialtyPriorityDb = structuredClone(db);
specialtyPriorityDb.users.push({ id: 'u-sport-rival', role: 'candidate', active: true, candidateNumber: '20260004', displayName: '特长竞争考生' });
specialtyPriorityDb.candidateProfiles.push({ userId: 'u-sport-rival', name: '特长竞争考生', schoolId: 'source-b', profileCompleted: true, specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] });
specialtyPriorityDb.registrations.push({ id: 'r-sport-rival', examId: 'exam', userId: 'u-sport-rival', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 0 });
specialtyPriorityDb.results.push({ registrationId: 'r-sport-rival', subjectId: 'cn', score: 125, published: true }, { registrationId: 'r-sport-rival', subjectId: 'math', score: 125, published: true });
specialtyPriorityDb.admissionRecords.push(
{ id: 'qual-sport-rival', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport-rival', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
{ id: 'pref-sport-rival', kind: 'preference', examId: 'exam', userId: 'u-sport-rival', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
);
const specialtyPriorityPlacements = buildVolunteerPlacements(specialtyPriorityDb, setting, { uid: prefix => `${prefix}-priority-${++sequence}`, nowIso: () => now });
assert.equal(specialtyPriorityPlacements.find(item => item.payload.categoryCode === 'sport').userId, 'u-sport', '特长类别应按文化课加特征分排序,而不是只按文化课排序');
db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计');
assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计');
const supplementDb = structuredClone(db);
const supplementSetting = { id: 'setting-supplement', kind: 'setting', examId: 'exam', status: 'supplementary', payload: { round: 2 } };
supplementDb.admissionRecords.push(
supplementSetting,
{ id: 'report-a', kind: 'notification', examId: 'exam', schoolId: 'target-a', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } },
{ id: 'report-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } }
);
for (const placement of supplementDb.admissionRecords.filter(item => item.kind === 'placement' && item.payload?.categoryCode === 'general')) placement.status = 'forfeited';
for (const [suffix, schoolId, score] of [['a', 'target-a', 230], ['b', 'target-b', 225]]) {
supplementDb.users.push({ id: `u-supp-${suffix}`, role: 'candidate', active: true, candidateNumber: `2026010${suffix === 'a' ? 1 : 2}`, displayName: `补录考生${suffix.toUpperCase()}` });
supplementDb.candidateProfiles.push({ userId: `u-supp-${suffix}`, name: `补录考生${suffix.toUpperCase()}`, schoolId: 'source-a', profileCompleted: true, specialtyTypes: [] });
supplementDb.registrations.push({ id: `r-supp-${suffix}`, examId: 'exam', userId: `u-supp-${suffix}`, status: 'approved', subjectIds: ['cn'] });
supplementDb.results.push({ registrationId: `r-supp-${suffix}`, subjectId: 'cn', score, published: true });
supplementDb.admissionRecords.push({ id: `pref-supp-${suffix}`, kind: 'preference', examId: 'exam', userId: `u-supp-${suffix}`, status: 'submitted', payload: { round: 2, choices: [{ schoolId, categoryCode: 'general', preferenceType: 'general' }] } });
}
assert.deepEqual([...supplementarySchoolIds(supplementDb, supplementSetting)].sort(), ['target-a', 'target-b'], '同轮多所学校获批补录时应完整保留学校集合');
const supplementPlacements = buildVolunteerPlacements(supplementDb, supplementSetting, { uid: prefix => `${prefix}-supp-${++sequence}`, nowIso: () => now });
assert.deepEqual(supplementPlacements.map(item => item.schoolId).sort(), ['target-a', 'target-b'], '放弃考生不得继续占用缺额,两所获批学校都应进入补录投档');
const publicRows = publicAdmissionRows(db, 'exam');
const highPublicRow = publicRows.find(item => item.registrationNumber === '20260001');
assert.equal(highPublicRow.registrationNumber, '20260001', '公示必须公开报名号');
assert.equal(highPublicRow.name, '高分考生', '公示必须公开姓名');
assert.equal(highPublicRow.totalScore, 250, '普通类别公示总成绩不得加入特征分');
assert.equal(highPublicRow.admittedSchool, '第二中学', '公示必须公开录取学校');
assert.ok(highPublicRow.idNumberMasked.includes('*') && !highPublicRow.idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
assert.equal(publicRows.find(item => item.registrationNumber === '20260003').totalScore, 303.5, '特长生类别公示总成绩应包含特征分');
const cutoffs = admissionCutoffRows(db, 'exam');
assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分');
const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b');
assert.equal(qualificationStatus.complete, true, '生源校全部考生确认后应达到自动公示条件');
assert.equal(qualificationStatus.rows.find(item => item.userId === 'u-sport').specialtyLabel, '体育·田径', '资格公示应包含对应特长类型');
const documentDb = structuredClone(db);
documentDb.exams = [{ id: 'exam', code: 'EX-2026-ZK', name: '中考' }];
documentDb.schools.find(item => item.id === 'target-b').code = 'AD02';
const targetPlacements = documentDb.admissionRecords.filter(item => item.kind === 'placement' && item.schoolId === 'target-b');
const numbered = assignAdmissionNoticeNumbers(documentDb, targetPlacements);
assert.deepEqual(numbered.map(item => item.payload.noticeNumber), ['AD02-EX-2026-ZK-000001', 'AD02-EX-2026-ZK-000002'], '通知书编号应按学校与考试独立生成连续流水号');
const reportingPlan = documentDb.admissionRecords.find(item => item.id === 'plan-b');
documentDb.admissionRecords = documentDb.admissionRecords.map(item => numbered.find(numberedItem => numberedItem.id === item.id) || item);
const legacyRoundDb = structuredClone(documentDb);
legacyRoundDb.admissionRecords.push({ id: 'setting-exam', kind: 'setting', examId: 'exam', status: 'reporting', updatedAt: now, payload: { enabled: true, autoPublish: true, round: 1, roundPublishedAt: now } });
const legacyRoundPublication = admissionRoundPublications(legacyRoundDb).find(item => item.examId === 'exam' && item.round === 1);
assert.ok(legacyRoundPublication?.virtual, '历史报到中数据缺少轮次公示记录时应自动兼容回显');
assert.equal(legacyRoundPublication.rows.length, publicAdmissionRows(legacyRoundDb, 'exam', { round: 1 }).length, '历史轮次公示应恢复该轮全部正式录取名单');
documentDb.admissionRecords.push({ id: 'reporting-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'draft', payload: { type: 'admission_reporting', round: 1, rows: [{ placementId: numbered[0].id, status: 'reported' }, { placementId: numbered[1].id, status: 'not_reported' }] } });
const progress = admissionPlanProgress(documentDb, reportingPlan);
assert.equal(progress.totalQuota, 2, '计划完成率分母应来自学校审核通过的计划人数');
assert.equal(progress.reportedCount, 1, '实际报到人数应来自学校报到暂存台账');
assert.equal(progress.reportingRate, 50, '实际报到完成率应实时按计划人数计算');
documentDb.admissionRecords.at(-1).status = 'approved';
documentDb.admissionRecords.at(-1).payload.supplementDecision = 'no_supplement';
documentDb.admissionRecords.at(-1).payload.decisionNote = '学校研究决定不进行补录。';
documentDb.admissionRecords.at(-1).payload.statistics = progress;
const reportingNotice = systemNotificationItems(documentDb).find(item => item.sourceType === 'reporting');
assert.ok(reportingNotice.title.includes('报到情况公示') && !reportingNotice.title.includes('补录'), '计划完成或决定不补录时公告标题不应出现“补录”');
console.log('志愿投档、指标名额与脱敏公示测试通过');
-25
View File
@@ -1,25 +0,0 @@
import assert from 'node:assert/strict';
import { api } from '../src/client/api.mjs';
const originalFetch = globalThis.fetch;
let calls = 0;
let release;
globalThis.fetch = async () => {
calls += 1;
await new Promise(resolve => { release = resolve; });
return new Response(JSON.stringify({ ok: true, calls }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
try {
const first = api('/api/large-ledger');
const second = api('/api/large-ledger');
await Promise.resolve();
assert.equal(calls, 1, '并发的相同 GET 请求应只发送一次');
release();
assert.deepEqual(await first, { ok: true, calls: 1 });
assert.deepEqual(await second, { ok: true, calls: 1 });
} finally {
globalThis.fetch = originalFetch;
}
console.log('API read deduplication tests passed');
-209
View File
@@ -1,209 +0,0 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { createAuthStateStore } from '../src/security/auth-state.mjs';
class FakeRedisClient extends EventEmitter {
constructor({ connectError = null } = {}) {
super();
this.connectError = connectError;
this.isOpen = false;
this.values = new Map();
this.sets = new Map();
this.hashes = new Map();
}
async connect() {
if (this.connectError) throw this.connectError;
this.isOpen = true;
}
async get(key) {
return this.values.has(key) ? this.values.get(key) : null;
}
async set(key, value) {
this.values.set(key, String(value));
return 'OK';
}
async del(key) {
const deleted = Number(this.values.delete(key)) + Number(this.sets.delete(key)) + Number(this.hashes.delete(key));
return deleted ? 1 : 0;
}
async sAdd(key, value) {
if (!this.sets.has(key)) this.sets.set(key, new Set());
const before = this.sets.get(key).size;
this.sets.get(key).add(value);
return this.sets.get(key).size - before;
}
async sRem(key, value) {
return Number(this.sets.get(key)?.delete(value) || false);
}
async sMembers(key) {
return [...(this.sets.get(key) || [])];
}
async expire() {
return 1;
}
async hSet(key, entries) {
if (!this.hashes.has(key)) this.hashes.set(key, new Map());
for (const [field, value] of Object.entries(entries)) this.hashes.get(key).set(field, String(value));
return Object.keys(entries).length;
}
async hGetAll(key) {
return Object.fromEntries(this.hashes.get(key) || []);
}
async eval(_script, { keys, arguments: scriptArguments }) {
const hash = this.hashes.get(keys[0]);
if (!hash) return -1;
const attempts = Number(hash.get('attempts') || 0) + 1;
hash.set('attempts', String(attempts));
if (attempts >= Number(scriptArguments[0])) this.hashes.delete(keys[0]);
return attempts;
}
multi() {
const operations = [];
const transaction = {};
for (const method of ['set', 'del', 'sAdd', 'sRem', 'expire', 'hSet']) {
transaction[method] = (...args) => {
operations.push(() => this[method](...args));
return transaction;
};
}
transaction.exec = async () => Promise.all(operations.map(operation => operation()));
return transaction;
}
async quit() {
this.isOpen = false;
}
destroy() {
this.isOpen = false;
}
}
const silentLogger = { error() {} };
{
const state = await createAuthStateStore({ env: {} });
assert.equal(state.status, 'disabled');
assert.equal(state.backend, 'memory');
await state.createSession('session-1', 'user-1');
assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' });
await state.createLoginChallenge('challenge-1', 'user-1');
assert.deepEqual(await state.getLoginChallenge('challenge-1'), { userId: 'user-1', attempts: 0 });
await state.createTotpSetup('session-1', 'user-1', 'SECRET');
assert.deepEqual(await state.getTotpSetup('session-1'), { userId: 'user-1', secret: 'SECRET' });
assert.equal(await state.deleteUserSessions('user-1'), 1);
assert.equal(await state.getSession('session-1'), null);
await state.close();
}
{
const client = new FakeRedisClient();
let clientOptions;
const state = await createAuthStateStore({
env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_PREFIX: 'test:auth' },
logger: silentLogger,
clientFactory(options) {
clientOptions = options;
return client;
}
});
assert.equal(state.status, 'ready');
assert.equal(state.backend, 'redis');
assert.equal(state.database, 1, '普通缓存使用 DB 0 时,认证状态应自动使用 DB 1');
assert.equal(clientOptions.database, 1);
assert.equal(clientOptions.url, 'redis://cache.example:6379/0');
await state.createSession('session-1', 'user-1');
await state.createSession('session-2', 'user-1');
assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' });
assert.equal(await state.deleteUserSessions('user-1'), 2);
assert.equal(await state.getSession('session-1'), null);
assert.equal(await state.getSession('session-2'), null);
await state.createLoginChallenge('challenge-1', 'user-1');
for (let attempts = 1; attempts <= 4; attempts += 1) {
assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts, exhausted: false });
}
assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts: 5, exhausted: true });
assert.equal(await state.getLoginChallenge('challenge-1'), null);
await state.createTotpSetup('session-3', 'user-1', 'SECRET');
assert.deepEqual(await state.getTotpSetup('session-3'), { userId: 'user-1', secret: 'SECRET' });
await state.deleteTotpSetup('session-3');
assert.equal(await state.getTotpSetup('session-3'), null);
await state.close();
assert.equal(client.isOpen, false);
}
{
const client = new FakeRedisClient();
let options;
const state = await createAuthStateStore({
env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_URL: 'rediss://sessions.example:6380/4' },
logger: silentLogger,
clientFactory(clientOptions) {
options = clientOptions;
return client;
}
});
assert.equal(options.url, 'rediss://sessions.example:6380/4');
assert.equal(options.database, 4);
await state.close();
}
{
const client = new FakeRedisClient();
let options;
const state = await createAuthStateStore({
env: { REDIS_URL: 'redis://cache.example:6379/1', REDIS_SESSION_DB: '0' },
logger: silentLogger,
clientFactory(clientOptions) {
options = clientOptions;
return client;
}
});
assert.equal(options.database, 0, '应允许显式选择 DB 0,只要普通缓存使用不同的 DB');
await state.close();
}
await assert.rejects(
createAuthStateStore({
env: { REDIS_URL: 'redis://same.example:6379/0', REDIS_SESSION_URL: 'redis://same.example:6379/0' },
logger: silentLogger,
clientFactory: () => new FakeRedisClient()
}),
/必须使用与普通缓存不同的逻辑数据库/
);
await assert.rejects(
createAuthStateStore({
env: { REDIS_URL: 'redis://cache-user@same.example:6379/0', REDIS_SESSION_URL: 'redis://session-user@same.example:6379/0' },
logger: silentLogger,
clientFactory: () => new FakeRedisClient()
}),
/必须使用与普通缓存不同的逻辑数据库/
);
await assert.rejects(
createAuthStateStore({
env: { REDIS_URL: 'redis://unavailable.example:6379/0' },
logger: silentLogger,
clientFactory: () => new FakeRedisClient({ connectError: new Error('connection refused') })
}),
/认证状态存储连接失败/
);
console.log('✓ 认证状态本机回退、独立 Redis DB、会话失效与 TOTP 临时状态');
-129
View File
@@ -1,129 +0,0 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { createRedisCache, withCacheInvalidation } from '../src/cache/redis-cache.mjs';
class FakeRedisClient extends EventEmitter {
constructor({ connectError = null } = {}) {
super();
this.connectError = connectError;
this.isOpen = false;
this.isReady = false;
this.values = new Map();
}
async connect() {
if (this.connectError) throw this.connectError;
this.isOpen = true;
this.isReady = true;
this.emit('ready');
}
async get(key) {
return this.values.has(key) ? this.values.get(key) : null;
}
async set(key, value, options = {}) {
if (options.NX && this.values.has(key)) return null;
this.values.set(key, value);
return 'OK';
}
async incr(key) {
const next = Number(this.values.get(key) || 0) + 1;
this.values.set(key, String(next));
return next;
}
async quit() {
this.isReady = false;
this.isOpen = false;
}
destroy() {
this.isReady = false;
this.isOpen = false;
}
}
const silentLogger = { warn() {} };
{
const client = new FakeRedisClient();
const cache = await createRedisCache({
env: { REDIS_URL: 'redis://test', REDIS_CACHE_PREFIX: 'test', REDIS_CACHE_TTL_SECONDS: '30' },
logger: silentLogger,
clientFactory: () => client
});
let loads = 0;
const load = async () => ({ version: ++loads });
assert.deepEqual(await cache.remember('public', 'home', load), { version: 1 });
assert.deepEqual(await cache.remember('public', 'home', load), { version: 1 });
assert.equal(loads, 1, '相同缓存键应只读取一次数据源');
await cache.invalidate('public');
assert.deepEqual(await cache.remember('public', 'home', load), { version: 2 });
assert.equal(loads, 2, '命名空间失效后应重新读取数据源');
let resultLoads = 0;
const loadResults = async () => ({ version: ++resultLoads });
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 1 });
const database = withCacheInvalidation({
client: 'test',
async read() { return {}; },
async save() { return 'saved'; },
async close() {}
}, cache, method => method === 'save' ? ['public', 'results'] : ['public']);
assert.equal(await database.save(), 'saved');
assert.deepEqual(await cache.remember('public', 'home', load), { version: 3 });
assert.equal(loads, 3, '数据库写入后应让公开缓存失效');
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 2 });
assert.equal(resultLoads, 2, '成绩相关写入后应让成绩缓存失效');
await cache.invalidate('results');
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 3 });
assert.equal(resultLoads, 3, '后台手动刷新后应重新生成成绩缓存');
client.isReady = false;
let fallbackLoads = 0;
assert.equal(await cache.remember('public', 'runtime-fallback', async () => ++fallbackLoads), 1);
assert.equal(await cache.remember('public', 'runtime-fallback', async () => ++fallbackLoads), 1);
assert.equal(fallbackLoads, 1, 'Redis 运行中断开后,相同热点读取应由本机缓存合并');
client.isReady = true;
await cache.close();
}
{
const cache = await createRedisCache({ env: {}, logger: silentLogger });
let loads = 0;
assert.equal(await cache.remember('public', 'home', async () => ++loads), 1);
assert.equal(await cache.remember('public', 'home', async () => ++loads), 1);
assert.equal(loads, 1, '未配置 Redis 时应使用有界本机缓存,避免重复回源');
await cache.invalidate('public');
assert.equal(await cache.remember('public', 'home', async () => ++loads), 2);
assert.equal(loads, 2, '本机缓存应在数据库写入后立即失效');
assert.equal(cache.status, 'disabled');
let releaseOld;
const oldLoad = cache.remember('public', 'race', () => new Promise(resolve => { releaseOld = () => resolve('old'); }));
await cache.invalidate('public');
const newLoad = cache.remember('public', 'race', async () => 'new');
releaseOld();
assert.equal(await oldLoad, 'old');
assert.equal(await newLoad, 'new', '失效后不得等待失效前仍在运行的加载');
assert.equal(await cache.remember('public', 'race', async () => 'unexpected'), 'new', '旧加载完成后不得覆盖新缓存');
}
{
const client = new FakeRedisClient({ connectError: new Error('connection refused') });
const cache = await createRedisCache({
env: { REDIS_URL: 'redis://unavailable' },
logger: silentLogger,
clientFactory: () => client
});
assert.equal(cache.status, 'unavailable');
assert.equal(await cache.remember('public', 'home', async () => 'database'), 'database');
assert.equal(await cache.remember('public', 'home', async () => 'unexpected'), 'database', 'Redis 故障时本机缓存应继续承接重复读取');
}
console.log('Redis 缓存测试通过');
-57
View File
@@ -1,57 +0,0 @@
import assert from 'node:assert/strict';
import { createAdminViews } from '../src/client/admin-views.mjs';
import { createAdmissionViews } from '../src/client/admission-views.mjs';
import { api } from '../src/client/api.mjs';
import { createCandidateViews } from '../src/client/candidate-views.mjs';
import { createPublicViews } from '../src/client/public-views.mjs';
function protectedViewContext() {
let loginRequests = 0;
const context = {
state: { user: null },
app: { classList: { add() {}, remove() {} } },
requireLogin() { loginRequests += 1; }
};
return { context, loginRequests: () => loginRequests };
}
for (const createView of [createAdminViews, createCandidateViews, createAdmissionViews]) {
const fixture = protectedViewContext();
const views = createView(fixture.context);
const render = views.renderAdmin || views.renderCandidate || views.renderAdmission;
await render('dashboard');
assert.equal(fixture.loginRequests(), 1, '未登录访问受保护视图时应交给统一登录处理');
}
{
const app = { classList: { remove() {} }, innerHTML: '' };
const state = {
user: null,
authNotice: '登录状态已失效,请重新登录。',
publicData: { selfRegistrationEnabled: false }
};
const { renderAuth } = createPublicViews({
state,
app,
h: value => String(value ?? ''),
icons: { arrow: '', menu: '' }
});
renderAuth('login');
assert.match(app.innerHTML, /需要重新登录/);
assert.match(app.innerHTML, /登录状态已失效,请重新登录/);
}
{
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({ message: '请先登录' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
try {
await assert.rejects(() => api('/api/protected'), error => error.status === 401 && error.message === '请先登录');
} finally {
globalThis.fetch = originalFetch;
}
}
console.log('客户端登录失效处理测试通过');
-44
View File
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import { admissionNoticeCode, resolveDocumentVerificationSecret, safeCodeEqual, scoreReportCode } from '../src/security/document-verification.mjs';
const secret = 'test-document-verification-secret-32-characters';
const exam = { id: 'exam_1' };
const registration = { id: 'registration_1', userId: 'candidate_1' };
const results = [
{ subjectId: 'math', score: 118, publishedAt: '2026-07-20T08:00:00.000Z' },
{ subjectId: 'chinese', score: 112, publishedAt: '2026-07-20T08:00:00.000Z' }
];
const scoreCode = scoreReportCode(secret, registration, exam, results);
const reorderedCode = scoreReportCode(secret, registration, exam, [...results].reverse());
const changedScoreCode = scoreReportCode(secret, registration, exam, [{ ...results[0], score: 119 }, results[1]]);
assert.match(scoreCode, /^SR-[A-F0-9]{24}$/);
assert.equal(scoreCode, reorderedCode, '科目返回顺序不应改变同一成绩单的防伪码');
assert.notEqual(scoreCode, changedScoreCode, '成绩变化必须使旧防伪码失效');
assert.equal(safeCodeEqual(scoreCode, scoreCode.toLowerCase()), true);
assert.equal(safeCodeEqual(scoreCode, `${scoreCode}0`), false);
const placement = {
id: 'placement_1', userId: 'candidate_1', schoolId: 'school_1',
payload: { categoryCode: 'general', noticeNumber: 'AD01-EX-2026-000001' }, updatedAt: '2026-07-21T08:00:00.000Z'
};
const noticeCode = admissionNoticeCode(secret, placement, exam);
const movedSchoolCode = admissionNoticeCode(secret, { ...placement, schoolId: 'school_2' }, exam);
const changedNumberCode = admissionNoticeCode(secret, { ...placement, payload: { ...placement.payload, noticeNumber: 'AD01-EX-2026-000002' } }, exam);
assert.match(noticeCode, /^AN-[A-F0-9]{24}$/);
assert.notEqual(noticeCode, movedSchoolCode, '录取学校变化必须使旧通知书防伪码失效');
assert.notEqual(noticeCode, changedNumberCode, '录取通知书编号变化必须使旧防伪码失效');
assert.throws(
() => resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: 'too-short' }),
/至少 32 个字符/,
'生产环境不得静默使用弱密钥或开发回退值'
);
assert.equal(
resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: secret }),
secret
);
console.log('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');
@@ -1,48 +0,0 @@
import { DatabaseSync } from 'node:sqlite';
import { admissionNoticeCode } from '../../src/security/document-verification.mjs';
const databasePath = process.argv[2];
if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径');
const database = new DatabaseSync(databasePath);
const rows = database.prepare(
"SELECT id, exam_id, user_id, school_id, payload_json, updated_at FROM admission_records WHERE kind = 'placement' ORDER BY id"
).all();
let row = rows.map(item => ({ ...item, payload: JSON.parse(item.payload_json || '{}') }))[0];
if (!row) {
const exam = database.prepare('SELECT id FROM exams ORDER BY id LIMIT 1').get();
const user = database.prepare("SELECT id FROM users WHERE role = 'candidate' ORDER BY id LIMIT 1").get();
const school = database.prepare('SELECT id FROM schools WHERE is_admission_school = 1 ORDER BY id LIMIT 1').get();
if (!exam || !user || !school) throw new Error('测试数据库缺少考试、考生或招生学校');
row = {
id: 'smoke_final_placement',
exam_id: exam.id,
user_id: user.id,
school_id: school.id,
updated_at: '2026-07-22T00:00:00.000Z',
payload: { categoryCode: 'general', categoryName: '普通生', noticeNumber: 'SMOKE-EXAM-000001' }
};
database.prepare(
"INSERT INTO admission_records (id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at) VALUES (?, 'placement', ?, ?, ?, 'final', ?, ?, ?)"
).run(row.id, row.exam_id, row.user_id, row.school_id, JSON.stringify(row.payload), row.updated_at, row.updated_at);
} else {
row.payload.noticeNumber ||= 'SMOKE-EXAM-000001';
row.updated_at = '2026-07-22T00:00:00.000Z';
database.prepare(
"UPDATE admission_records SET status = 'final', payload_json = ?, updated_at = ? WHERE id = ?"
).run(JSON.stringify(row.payload), row.updated_at, row.id);
}
database.close();
console.log(admissionNoticeCode(
process.env.DOCUMENT_VERIFICATION_SECRET,
{
id: row.id,
userId: row.user_id,
schoolId: row.school_id,
payload: row.payload,
updatedAt: row.updated_at
},
{ id: row.exam_id }
));
-5
View File
@@ -1,5 +0,0 @@
import { totpAtStep } from '../../src/security/totp.mjs';
const secret = String(process.argv[2] || '');
if (!secret) throw new Error('TOTP secret is required');
console.log(totpAtStep(secret, Math.floor(Date.now() / 1000 / 30)));
@@ -1,8 +0,0 @@
import { DatabaseSync } from 'node:sqlite';
const databasePath = process.argv[2];
if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径');
const database = new DatabaseSync(databasePath);
database.prepare('UPDATE schema_metadata SET self_registration_enabled = 1 WHERE id = 1').run();
database.close();
@@ -1,13 +0,0 @@
import { DatabaseSync } from 'node:sqlite';
const databasePath = process.argv[2];
if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径');
const database = new DatabaseSync(databasePath);
database.prepare(`
UPDATE test_rooms
SET capacity = 2000,
seat_end = seat_start + 1999
WHERE status = 'active'
`).run();
database.close();
@@ -1,21 +0,0 @@
import { DatabaseSync } from 'node:sqlite';
const databasePath = process.argv[2];
if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径');
const database = new DatabaseSync(databasePath);
const setting = database.prepare("SELECT id, payload_json FROM admission_records WHERE kind = 'setting' ORDER BY created_at, id LIMIT 1").get();
if (!setting) throw new Error('测试数据库缺少招生设置');
const payload = {
...JSON.parse(setting.payload_json || '{}'),
enabled: true,
preferenceStart: '2000-01-01T00:00:00.000Z',
preferenceEnd: '2099-12-31T23:59:59.999Z',
maxChoices: 3,
maxSubmissions: 2,
round: 2,
progress: 'ASP.NET Core 志愿提交冒烟验证'
};
database.prepare("UPDATE admission_records SET status = 'filling', payload_json = ?, updated_at = ? WHERE id = ?")
.run(JSON.stringify(payload), '2026-07-22T00:00:00.000Z', setting.id);
database.close();
@@ -1,58 +0,0 @@
import { DatabaseSync } from 'node:sqlite';
const databasePath = process.argv[2];
const candidateNumber = process.argv[3];
if (!databasePath || !candidateNumber) throw new Error('缺少 SQLite 路径或报名号');
const database = new DatabaseSync(databasePath);
const registration = database.prepare(`
SELECT registration.id, registration.exam_id
FROM registrations registration
JOIN users user ON user.id = registration.user_id
WHERE user.candidate_number = ? AND registration.status = 'approved'
ORDER BY registration.created_at, registration.id LIMIT 1
`).get(candidateNumber);
if (!registration) throw new Error('未找到已审核报名');
const numberRule = database.prepare('SELECT id FROM admission_number_rules ORDER BY created_at, id LIMIT 1').get();
const center = database.prepare("SELECT id, code, name, province_name, city_name, district_name, address FROM test_centers WHERE status = 'active' ORDER BY id LIMIT 1").get();
const room = center ? database.prepare("SELECT id, code, name, building, floor FROM test_rooms WHERE center_id = ? AND status = 'active' ORDER BY code, id LIMIT 1").get(center.id) : null;
if (!numberRule || !center || !room) throw new Error('准考证冒烟数据缺少编号规则、考点或考场');
const now = '2026-07-22T00:00:00.000Z';
database.exec('BEGIN IMMEDIATE');
try {
database.prepare('UPDATE exams SET admit_download_start = ?, admit_download_end = ? WHERE id = ?')
.run('2000-01-01T00:00:00.000Z', '2099-12-31T23:59:59.999Z', registration.exam_id);
database.prepare(`
INSERT OR IGNORE INTO exam_arrangement_plans (
id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
subject_assignment_count, subject_combination_count, same_school_center_rate,
warnings_json, generated_by, generated_at
) VALUES (?, ?, ?, 'district', 'migration-smoke', 1, 1, 1, 1, 1, '[]', NULL, ?)
`).run('plan_migration_smoke', registration.exam_id, numberRule.id, now);
const plan = database.prepare('SELECT id FROM exam_arrangement_plans WHERE exam_id = ?').get(registration.exam_id);
const address = [center.province_name, center.city_name, center.district_name, center.address].filter(Boolean).join(' ');
database.prepare(`
INSERT OR REPLACE INTO admit_cards (
registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(registration.id, plan.id, 'MIGRATION-SMOKE-001', center.id, center.name, center.code, address, now);
const subjects = database.prepare('SELECT subject_id FROM registration_subjects WHERE registration_id = ? ORDER BY subject_id').all(registration.id);
const insertAssignment = database.prepare(`
INSERT OR REPLACE INTO admit_card_subjects (
registration_id, subject_id, room_id, room, room_code, exam_room_code,
building, floor, seat, subject_signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
subjects.forEach((subject, index) => insertAssignment.run(
registration.id, subject.subject_id, room.id, room.name, room.code, '001',
room.building, room.floor || '', '01', `migration-smoke-${index + 1}`
));
database.exec('COMMIT');
console.log(registration.id);
} catch (error) {
database.exec('ROLLBACK');
throw error;
} finally {
database.close();
}
@@ -1,24 +0,0 @@
import { DatabaseSync } from 'node:sqlite';
const databasePath = process.argv[2];
const candidateNumber = process.argv[3];
if (!databasePath || !candidateNumber) throw new Error('缺少 SQLite 路径或报名号');
const database = new DatabaseSync(databasePath);
const user = database.prepare("SELECT id FROM users WHERE candidate_number = ? AND role = 'candidate'").get(candidateNumber);
if (!user) throw new Error('未找到冒烟测试考生');
const reviewer = database.prepare("SELECT id FROM users WHERE role = 'admin' AND active = 1 ORDER BY created_at, id LIMIT 1").get();
database.prepare(
"UPDATE candidate_profiles SET status = 'approved', review_note = '', reviewed_at = ?, reviewer_id = ? WHERE user_id = ?"
).run('2026-07-22T00:00:00.000Z', reviewer?.id || null, user.id);
const exam = database.prepare("SELECT id FROM exams WHERE status = 'published' AND archived_at IS NULL ORDER BY created_at, id LIMIT 1").get();
if (!exam) throw new Error('未找到已发布考试');
database.prepare(
'UPDATE exams SET registration_start = ?, registration_end = ? WHERE id = ?'
).run('2000-01-01T00:00:00.000Z', '2099-12-31T23:59:59.999Z', exam.id);
const subject = database.prepare('SELECT id FROM exam_subjects WHERE exam_id = ? ORDER BY position, id LIMIT 1').get(exam.id);
if (!subject) throw new Error('考试缺少科目');
database.close();
console.log(JSON.stringify({ examId: exam.id, subjectId: subject.id }));
-48
View File
@@ -1,48 +0,0 @@
import assert from 'node:assert/strict';
import { createSeedDatabase } from '../src/data/seed.mjs';
const state = createSeedDatabase({ nowIso: () => '2026-07-21T00:00:00.000Z', hashPassword: password => `test-${password}` });
const mainExam = state.exams.find(exam => exam.id === 'exam_autumn_2026');
const mainRegistrations = state.registrations.filter(registration => registration.examId === mainExam.id);
const mainRegistrationIds = new Set(mainRegistrations.map(registration => registration.id));
const mainResults = state.results.filter(result => mainRegistrationIds.has(result.registrationId));
const mainPreferences = state.admissionRecords.filter(record => record.kind === 'preference' && record.examId === mainExam.id && Number(record.payload?.round || 1) === 1);
const mainPlans = state.admissionRecords.filter(record => record.kind === 'plan' && record.examId === mainExam.id && record.status === 'approved');
const specialtyProfiles = state.candidateProfiles.filter(profile => profile.specialtyCategory && profile.specialtyType);
const specialtyUserIds = new Set(specialtyProfiles.map(profile => profile.userId));
const profilesByUserId = new Map(state.candidateProfiles.map(profile => [profile.userId, profile]));
assert.ok(state.exams.length >= 2, '演示数据至少包含两场考试');
assert.equal(state.schools.filter(school => school.isSourceSchool).length, 5, '演示数据应包含 5 所生源校');
assert.equal(state.schools.filter(school => school.isAdmissionSchool).length, 3, '演示数据应包含 3 所招生校');
assert.deepEqual(Object.fromEntries(mainExam.subjects.map(subject => [subject.name, subject.fullScore])), {
语文: 120, 数学: 120, 外语: 120, 历史: 75, 政治: 75, 物理: 80, 化学: 70, 实验: 20, 信息技术: 10
});
assert.equal(mainRegistrations.length, 1200, '主考试应有 1200 名考生');
assert.ok(mainRegistrations.every(registration => registration.status === 'approved' && registration.subjectIds.length === 9), '主考试报名应全部审核通过并包含 9 科');
assert.equal(mainResults.length, 1200 * 9, '每名主考试考生都应有完整的 9 科成绩');
assert.ok(mainResults.every(result => result.published), '主考试成绩应全部发布');
assert.equal(mainPreferences.length, 1200, '主考试每名考生都应完成第一轮志愿');
assert.ok(mainPreferences.every(record => record.status === 'submitted' && record.payload.submissionCount === 1 && record.payload.choices.length === 3), '第一轮志愿应提交并填满 3 个招生校');
assert.equal(mainPlans.length, 3, '三所招生校都应有已审核通过的招生计划');
assert.ok(mainPlans.every(plan => plan.payload.categories.find(category => category.code === 'general')?.quota === 350), '每所招生校普通类计划应为 350 人');
assert.ok(mainPlans.every(plan => plan.payload.categories.filter(category => category.specialtyCategory).reduce((sum, category) => sum + category.quota, 0) === 2), '每所招生校特长生计划合计应为 2 人');
assert.equal(specialtyProfiles.length, 150, '应有 150 名特长生');
assert.ok(mainRegistrations.filter(registration => specialtyUserIds.has(registration.userId)).every(registration => registration.featureScore >= 80 && registration.featureScore <= 100), '特长生特征分应分布在 80-100 分');
assert.ok(mainPreferences.every(preference => {
const profile = profilesByUserId.get(preference.userId);
const categories = preference.payload.choices.map(choice => choice.categoryCode);
return profile.specialtyCategory ? categories[0] === profile.specialtyCategory && categories.slice(1).every(code => code === 'general') : categories.every(code => code === 'general');
}), '特长生第一志愿应匹配本人特长类别,其余志愿及普通考生志愿应填报普通类');
assert.deepEqual(new Set(state.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置账号密码应统一为 12345678');
for (const subject of mainExam.subjects) {
const scores = mainResults.filter(result => result.subjectId === subject.id).map(result => result.score);
const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
const standardDeviation = Math.sqrt(scores.reduce((sum, score) => sum + (score - mean) ** 2, 0) / scores.length);
assert.ok(mean > subject.fullScore * 0.66 && mean < subject.fullScore * 0.78, `${subject.name}平均分应符合正态样本预期`);
assert.ok(standardDeviation > subject.fullScore * 0.09 && standardDeviation < subject.fullScore * 0.18, `${subject.name}标准差应符合正态样本预期`);
assert.ok(scores.every(score => score >= 0 && score <= subject.fullScore), `${subject.name}成绩不得超出满分`);
}
console.log('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过');
-41
View File
@@ -1,41 +0,0 @@
import assert from 'node:assert/strict';
import { createStateCache } from '../src/database/state-cache.mjs';
let loads = 0;
let release;
const gate = new Promise(resolve => { release = resolve; });
const coalesced = createStateCache({
maxAgeMs: 30000,
async load() {
loads += 1;
await gate;
return { load: loads };
}
});
const firstPending = coalesced.read();
const secondPending = coalesced.read();
release();
const [first, second] = await Promise.all([firstPending, secondPending]);
assert.equal(loads, 1, '并发冷读取应合并为一次全量加载');
assert.strictEqual(first, second, '并发读取应共享同一份快照');
assert.strictEqual(await coalesced.read(), first, '有效期内应直接复用内存快照');
coalesced.invalidate();
const afterInvalidation = await coalesced.read();
assert.equal(loads, 2, '应用写入失效后应重新加载');
assert.notStrictEqual(afterInvalidation, first, '失效后不得继续返回旧快照');
let version = 1;
let versionLoads = 0;
const versioned = createStateCache({
version: () => version,
load: () => ({ load: ++versionLoads })
});
const versionOne = await versioned.read();
assert.strictEqual(await versioned.read(), versionOne, '数据库版本未变化时应复用快照');
version += 1;
const versionTwo = await versioned.read();
assert.equal(versionLoads, 2, '外部数据库版本变化后应重新加载');
assert.notStrictEqual(versionTwo, versionOne, '外部写入后不得返回旧快照');
console.log('State cache tests passed');
File diff suppressed because it is too large Load Diff