项目迁移

This commit is contained in:
2026-07-22 18:22:52 +08:00 Unverified
commit ecb3dc63ed
68 changed files with 21894 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
.git
.agents
.codex
node_modules
tests
.env
.env.*
!.env.example
!.env.docker.example
data/*
!data/.gitkeep
*.log
npm-debug.log*
Dockerfile*
compose*.yml
compose*.yaml
README.md
LICENSE
+27
View File
@@ -0,0 +1,27 @@
# 首次启动前请复制为 .env.docker,并替换下面三个值。
# 两项密钥必须至少 32 个字符、彼此独立,部署后不得随意更换。
TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
DOCUMENT_VERIFICATION_SECRET=replace-with-an-independent-random-secret-of-at-least-32-characters
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=replace-with-a-strong-admin-password
INITIAL_ADMIN_DISPLAY_NAME=系统管理员
# 下列公开站点信息可按需修改。
PUBLIC_SITE_NAME=海州市教育考试中心
PUBLIC_SITE_CODE=HZ-EDU-032
PUBLIC_SITE_PHONE=0518-8602 3158
PUBLIC_SITE_ADDRESS=江苏省连云港市海州区文教路 18
PUBLIC_SITE_EMAIL=
PUBLIC_SITE_HERO_EYEBROW=HAIZHOU EXAMINATION SERVICE
PUBLIC_SITE_HERO_TITLE=一个报名号,
PUBLIC_SITE_HERO_HIGHLIGHT=贯穿每一次考试。
PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。
PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
# 可选 Redis;容器外的 Redis 不应填写 127.0.0.1。
# 普通接口缓存使用 DB 0,认证状态会自动使用独立 DB 1。
# REDIS_URL=redis://redis-host:6379/0
# REDIS_SESSION_DB=1
# 也可让认证状态使用另一台 Redis(此时 URL 中可指定自己的逻辑 DB)。
# REDIS_SESSION_URL=rediss://session-redis-host:6379/1
+60
View File
@@ -0,0 +1,60 @@
# 本地开发(默认)
NODE_ENV=development
DATABASE_CLIENT=sqlite
SQLITE_PATH=./data/exam.sqlite
HOST=127.0.0.1
PORT=4173
# 可选 Redis。普通接口缓存使用 DB 0;配置 REDIS_URL 后,登录状态默认自动使用独立的 DB 1。
# 未设置任何 Redis 地址时,接口缓存和认证状态均使用本机内存模式。
# REDIS_URL=redis://127.0.0.1:6379/0
# REDIS_CACHE_PREFIX=exam-information
# REDIS_CACHE_TTL_SECONDS=60
# REDIS_RESULTS_CACHE_TTL_SECONDS=86400
# REDIS_CONNECT_TIMEOUT_MS=1500
# REDIS_SESSION_DB=1
# REDIS_SESSION_PREFIX=exam-information:auth
# AUTH_SESSION_TTL_SECONDS=28800
# 如需让认证状态使用另一台 Redis,可设置独立地址;URL 中可直接指定逻辑 DB。
# REDIS_SESSION_URL=rediss://session-redis.example.com:6379/1
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=Admin123!
INITIAL_ADMIN_DISPLAY_NAME=系统管理员
# TOTP 密钥加密主密钥。生产环境必填且至少 32 个字符;修改后已绑定的 TOTP 将无法解密。
# TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
# 成绩单与录取通知书防伪码签名密钥。生产环境必须独立设置并长期稳定保存。
# DOCUMENT_VERIFICATION_SECRET=replace-with-an-independent-random-secret-of-at-least-32-characters
# 公开首页文案与联系方式(修改后重启应用生效)
PUBLIC_SITE_NAME=海州市教育考试中心
PUBLIC_SITE_CODE=HZ-EDU-032
PUBLIC_SITE_PHONE=0518-8602 3158
PUBLIC_SITE_ADDRESS=江苏省连云港市海州区文教路 18
PUBLIC_SITE_EMAIL=
PUBLIC_SITE_HERO_EYEBROW=HAIZHOU EXAMINATION SERVICE
PUBLIC_SITE_HERO_TITLE=一个报名号,
PUBLIC_SITE_HERO_HIGHLIGHT=贯穿每一次考试。
PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。
PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
# MySQL 8.4 生产环境:将 DATABASE_CLIENT 改为 mysql,并配置以下变量。
# NODE_ENV=production
# DATABASE_CLIENT=mysql
# MYSQL_HOST=127.0.0.1
# MYSQL_PORT=3306
# MYSQL_USER=exam_app
# MYSQL_PASSWORD=replace-with-a-strong-password
# MYSQL_DATABASE=exam_information
# MYSQL_CONNECTION_LIMIT=10
# HOST=0.0.0.0
# 也可以用单个连接地址替代全部 MYSQL_* 连接参数:
# DATABASE_URL=mysql://exam_app:password@127.0.0.1:3306/exam_information
# 全量数据库状态快照的最长复用时间(毫秒)。应用内写入会立即失效;MySQL 外部直写最长延迟此时间可见。
DATABASE_STATE_CACHE_TTL_MS=30000
# Redis 未配置或暂不可用时,本机响应缓存的最大条目数。
LOCAL_CACHE_MAX_ENTRIES=200
+103
View File
@@ -0,0 +1,103 @@
name: Build and publish Docker images
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
publish:
name: Test, build and publish
runs-on: ubuntu-latest
permissions:
contents: read
releases: write
steps:
- name: Check out repository
uses: https://github.com/actions/checkout@v4
- name: Set up Node.js
uses: https://github.com/actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Set up QEMU
uses: https://github.com/docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: https://github.com/docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to Gitea Container Registry
uses: https://github.com/docker/login-action@v3
with:
registry: git.biss.click
username: ${{ vars.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Generate image tags and labels
id: metadata
uses: https://github.com/docker/metadata-action@v5
with:
images: |
docker.io/${{ vars.DOCKERHUB_IMAGE }}
git.biss.click/biss/exam-information-system
flavor: latest=false
tags: |
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/master' }}
type=sha,format=short,prefix=sha-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push image
uses: https://github.com/docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=registry,ref=docker.io/${{ vars.DOCKERHUB_IMAGE }}:buildcache
cache-to: type=registry,ref=docker.io/${{ vars.DOCKERHUB_IMAGE }}:buildcache,mode=max
- name: Detect release type
id: release_type
if: gitea.ref_type == 'tag'
shell: bash
run: |
case "$GITHUB_REF_NAME" in
*-*) prerelease=true ;;
*) prerelease=false ;;
esac
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
- name: Create Gitea release
if: gitea.ref_type == 'tag'
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.GITEA_TOKEN }}
tag_name: ${{ gitea.ref_name }}
name: ${{ gitea.ref_name }}
prerelease: ${{ steps.release_type.outputs.prerelease }}
body: |
Docker 镜像已发布:
```text
docker pull docker.io/${{ vars.DOCKERHUB_IMAGE }}:${{ steps.metadata.outputs.version }}
docker pull git.biss.click/biss/exam-information-system:${{ steps.metadata.outputs.version }}
```
+9
View File
@@ -0,0 +1,9 @@
data/db.json
data/test-db.json
data/*.sqlite
data/*.sqlite-shm
data/*.sqlite-wal
node_modules/
.env
.env.docker
*.log
+25
View File
@@ -0,0 +1,25 @@
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts \
&& npm cache clean --force
COPY --chown=node:node . .
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4173 \
DATABASE_CLIENT=sqlite \
SQLITE_PATH=/app/data/exam.sqlite
EXPOSE 4173
VOLUME ["/app/data"]
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || '4173') + '/api/public/home').then(response => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));"]
CMD ["node", "server.mjs"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 BISS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+371
View File
@@ -0,0 +1,371 @@
# 衡准 · 考试信息管理系统
一个完整可运行的分级权限考试信息管理系统,使用 Node.js 后端;本地开发采用 SQLite,生产环境支持 MySQL 8.4。
## 已实现功能
### 公开服务首页
- 通知公告首页展示与详情阅读
- 已发布考试、报名时间、考试时间和科目展示
- 考试服务办理流程说明
- 考生注册与双角色登录入口
### 考生中心
- 报名号即考生账户,同一考生参加不同考试始终使用同一个号码
- 学校管理员按一个或多个班级填写人数并提交批量申领(单人也按 1 人批次走审批)
- 首次登录强制修改初始密码,完成后才能补全个人信息
- 完整维护姓名、性别、证件号码、籍贯、出生日期、民族、家庭住址、手机号、邮箱、学校、班级、监护人和紧急联系人
- 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号
- 资料审核状态与管理员审核意见
- 查看开放考试并自主选择多个报考科目
- 查看报名审核、应缴金额、缴费状态及班级负责人确认记录
- 准考证生成状态、开放时间与下载
- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论
- 下载带 HMAC 防伪查询码和二维码的 PDF 成绩单
- 查看正式录取结果、录取通知书编号,并下载招生学校自定义样式的 PDF 录取通知书
- 通知公告中心
### 管理后台
- 超级、校级、班级三级管理员,同一级支持多个账号
- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程
- 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更
- 班级管理员可查看并审批本班考生、报名与成绩复议流程,并维护本班考生缴费状态;成绩录入仍仅限超级管理员
- 报名终审与缴费确认相互独立,系统不接入支付 SDK;超级、校级、班级管理员均可在各自数据范围内修改缴费状态,确认缴费时记录办理人和时间
- 超级、校级、班级管理员均可按各自数据范围筛选、查看及导出 Excel 缴费名单
- 考生信息修改、考试报名、成绩复议、批量报名号申领、考点考场变更使用可配置的多步骤审批流程
- 班级和校级审批自动限定到考生所属班级、学校;同范围多名管理员按当前待办与历史分配量自动均分
- 当前处理人可将流程转交给同范围的同级管理员
- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号
- 报名号在创建考生账户时只生成一次,后续考试报名自动复用
- 超级管理员只维护号码规则;校级批量申请最终批准后,系统原子生成固定报名号、随机初始密码和待补录账户
- 批次结果按班级返回校级管理员,并可导出 Excel 安全下发
- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位编排说明、类型和状态
- 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案
- 班级、班级管理员、报名号班级配额、考生资料、考点考场和成绩均提供 Excel 模板、导入与当前数据导出;只读角色保留对应导出能力
- Excel 导入逐行校验并返回具体行号;考生资料和考点考场的批量修改仍必须经过配置好的审批流程
- 考务指标与审计日志
- 考生资料审核、通过或退回修改
- 考试报名及科目审核
- 创建考试并结构化配置科目日期、时间、费用和满分;每科可独立选择固定分、排名前百分比或不设单科线,并汇总总分
- 支持固定总分线、总成绩排名前百分比、单科均达线及不判定四类整场合格策略
- 通知发布、草稿、撤回及首页置顶
- 按整场考试预检并批量编排准考证,支持班内、校内、县区内、市内和省内五级混编
- 预置“县区编号+考场号+座位号”“县区号+考场号+流水号”“考点学校代码+考场号+座位号”“考生学校代码+考场号+座位号”四种号码规则
- 多科目考生固定在同一考点,各科独立分配考场和座位;同科目组合优先相邻编排
- 编排前校验科目时间冲突、考点容量、档案完整性和号码唯一性,默认保留备用考场并支持稳定种子复现
- 每科可独立设置固定及格分、排名前百分比或不设单科线;成绩等级按同场同科排名百分位自动计算
- 默认排名等级区间为前 10% A+、前 25% A、前 50% B+、前 70% B、前 90% C、其余 D;同分共享名次
- 成绩管理中心按多场考试切换,展示录入/发布进度、成绩出齐人数、整场合格率、缺失科次与复议数量,并提供可筛选成绩台账
- 成绩复议终审表单展示考试、科目、原分、当前排名和达线规则;批准后在同一事务内更新成绩并只重算该考生的排名区间结论
- 成绩 Excel 采用“上传解析与逐行校验—页面暂存预览—确认后原子批量写库”的两阶段流程,预览不会修改数据库
- 超级管理员可将整场考试不可逆归档;归档后手工录入、Excel 导入、复议改分和考试配置全部锁定,历史报名、准考证与成绩默认折叠展示
- 管理员与考生接口权限隔离
### 系统能力
- PBKDF2 加盐密码哈希
- 可选 TOTP 二次验证,支持验证器扫码绑定、一次性恢复码与登录防重放
- TOTP 密钥使用 AES-256-GCM 加密存储,恢复码仅保存带服务端密钥的哈希
- HttpOnly、SameSite 登录 Cookie
- 服务端角色权限校验
- SQLite / MySQL 8.4 双数据库持久化
- 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源
- Redis 登录状态存储,和普通接口缓存使用不同逻辑数据库,支持多实例共享会话
- 规范关系模型、外键、唯一约束和业务索引
- 业务写入与审计日志使用原子事务提交
- 关键管理操作审计日志
- 组织、学校、班级三级数据范围在服务端强制过滤
- 审批实例、当前责任人、转交和监督操作全程留痕
- 桌面端与移动端响应式布局
- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小
- 成绩单与录取通知书以 PDF 下载,使用服务端 HMAC 防伪码支持公开验真
## 运行
需要 Node.js 22.5 或更高版本(SQLite 使用 Node.js 内置驱动)。
```powershell
npm install
npm start
```
打开 <http://127.0.0.1:4173>。
账户可在“账户安全”中启用 TOTP 二次验证。生产环境必须设置至少 32 个字符的 `TOTP_ENCRYPTION_KEY`;该值用于加密 TOTP 密钥并保护恢复码哈希,部署后必须稳定保存,不能随意更换。本地开发未设置时会使用仅适合开发的稳定派生值。
生产环境还必须单独设置至少 32 个字符的 `DOCUMENT_VERIFICATION_SECRET`。系统用它为成绩单和录取通知书生成 HMAC 防伪查询码;更换该值会使此前下载文书的查询码失效,因此应独立生成、稳定保存且不得与 TOTP 密钥共用。
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME``INITIAL_ADMIN_PASSWORD``INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v17;v16 数据库会自动增加 TOTP 字段,低于 v15 的开发库会提示重建。
### Docker
项目根目录包含生产镜像和 Docker Compose 配置。默认使用 SQLite,数据库保存在命名卷 `exam-information-data` 中,因此重建容器不会丢失数据。
先创建容器环境文件,并将其中的 TOTP 主密钥、文书防伪签名密钥和初始管理员密码替换为相互独立的安全随机值:
```powershell
Copy-Item .env.docker.example .env.docker
```
然后构建并启动:
```powershell
docker compose up --build --detach
```
启动完成后访问 <http://127.0.0.1:4173>。查看状态和日志可运行:
```powershell
docker compose ps
docker compose logs --follow app
```
停止服务使用 `docker compose down`;该命令会保留数据库卷。只有明确需要删除全部 SQLite 数据时才使用 `docker compose down --volumes`
也可以只构建镜像:
```powershell
docker build --tag hengzhun-exam-system:local .
```
镜像默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/api/public/home` 执行健康检查。需要连接 MySQL 或 Redis 时,用运行环境变量覆盖 `DATABASE_CLIENT``DATABASE_URL`/`MYSQL_*``REDIS_URL`;认证状态默认使用同一 Redis 服务的独立 DB 1,也可以通过 `REDIS_SESSION_DB``REDIS_SESSION_URL` 单独配置。此时 SQLite 数据卷可以移除。
#### Gitea Actions 自动发布到 Docker Hub 与 Gitea 软件包
工作流位于 `.gitea/workflows/docker-publish.yml`。它会先安装依赖并运行测试,然后构建 `linux/amd64``linux/arm64` 双架构镜像,并同时推送到 Docker Hub 与 `git.biss.click/biss/exam-information-system`
使用前需要完成以下配置:
1. 在 Docker Hub 创建目标仓库,并创建具有该仓库 Read & Write 权限的访问令牌。
2. 在 Gitea 仓库的 Actions Variables 中添加 `DOCKERHUB_IMAGE`,值为不带 registry 和 tag 的完整镜像名,例如 `yourname/exam-information-system`
3. 在 Gitea 仓库的 Actions Secrets 中添加 `DOCKERHUB_USERNAME``DOCKERHUB_TOKEN`。前者填写 Docker Hub 用户名,后者填写访问令牌,不要填写账户密码。
4. 使用对 `biss` 组织拥有软件包写权限的 Gitea 账号,在“设置 → 应用 → 生成新令牌”中创建具有 Package Read & Write 权限的个人访问令牌。在仓库 Actions Variables 中添加 `REGISTRY_USERNAME`,值为该令牌所属的用户名;在 Actions Secrets 中添加 `REGISTRY_TOKEN`,值为个人访问令牌。自定义 Secret 不能使用 Gitea 保留的 `GITEA_` 前缀,也不能用工作流内置的 `GITEA_TOKEN` 代替该软件包令牌。
5. 确保 Gitea Actions 与仓库的软件包注册表已启用,并且 `ubuntu-latest` Runner 能访问 Docker daemon、GitHub、Docker Hub 和 `git.biss.click`。双架构构建还需要 Runner 允许 QEMU 注册步骤运行。
推送到 `master` 后会发布 `latest``sha-<短提交号>`;推送形如 `v1.2.3` 的 Git 标签后会发布 `1.2.3``1.2` 和对应的提交标签,并在镜像推送成功后自动创建同名正式 Gitea Release。例如:
```powershell
git tag v1.2.3
git push origin v1.2.3
```
带连字符预发布后缀的语义化版本标签会自动创建 Gitea Pre-release,例如:
```powershell
git tag v1.3.0-rc.1
git push origin v1.3.0-rc.1
```
该版本的镜像标签为 `1.3.0-rc.1`,不会覆盖稳定版的 `1.3``latest` 标签。
工作流也支持从 Gitea Actions 页面手动运行。构建缓存保存为同一 Docker Hub 仓库中的 `buildcache` 标签,以加快后续构建。Release 使用工作流内置的 `GITEA_TOKEN` 创建,无需添加额外 Secret;仓库或组织“Actions → General”中的任务令牌最大权限必须允许 Releases Write。
首次成功推送后,容器镜像会出现在 `biss` 所有者的软件包列表。Gitea 的软件包归属于用户或组织,不会天然归属于某个仓库;打开该软件包的设置页面,将它关联到 `Exam-Information-System`,即可让它显示在此仓库的“软件包”页。之后可使用 `docker pull git.biss.click/biss/exam-information-system:latest` 拉取。
需要清空并重建空业务库时运行 `npm run reset-db`;该命令与 `npm run initialize-system` 使用同一套初始化流程,会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。也可通过 `npm run reset-db -- --sqlite``npm run reset-db -- --mysql` 显式选择数据库;MySQL 中存在无法识别为样例数据的业务记录时仍会拒绝覆盖,只有确认目标可清空后才能追加 `--force`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会生成 5 所学校、1200 名批量考生及对应的不同状态报名数据。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`
## 数据库配置
应用启动时会自动读取项目根目录的 `.env`,可先运行 `Copy-Item .env.example .env` 创建配置文件。命令行或部署平台已经注入的进程环境变量优先于 `.env`。应用根据 `DATABASE_CLIENT` 使用不同数据库;未设置时,开发/测试环境默认 `sqlite``NODE_ENV=production` 默认 `mysql`
公开首页的机构名称、机构代码、电话、地址、邮箱、主标语和页脚提示分别由 `PUBLIC_SITE_NAME``PUBLIC_SITE_CODE``PUBLIC_SITE_PHONE``PUBLIC_SITE_ADDRESS``PUBLIC_SITE_EMAIL``PUBLIC_SITE_HERO_*``PUBLIC_SITE_FOOTER_NOTICE` 配置。修改 `.env` 后需要重启应用;这些配置会覆盖数据库中的演示机构信息,且只通过公开首页接口返回非敏感展示字段。
### 本地 SQLite
```powershell
$env:DATABASE_CLIENT = 'sqlite'
$env:SQLITE_PATH = './data/exam.sqlite'
npm start
```
`SQLITE_PATH` 可省略,默认路径就是 `./data/exam.sqlite`
### 生产 MySQL 8.4
先在 MySQL 8.4 中创建数据库和最小权限账号:
```sql
CREATE DATABASE exam_information CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE USER 'exam_app'@'%' IDENTIFIED BY 'replace-with-a-strong-password';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'exam_app'@'%';
```
启动应用时设置连接信息,应用会自动创建以下关系表和系统基础配置,但不会自动写入测试业务数据:
- `schools``school_classes``users``candidate_profiles`
- `school_student_partitions`(学校学生专属表登记)
- `exams``exam_subjects`
- `exam_data_partitions`(考试专属表登记)
- `registrations``registration_subjects`
- `admission_number_rules``exam_arrangement_plans``admit_cards``admit_card_subjects`
- `results``notices``audit_logs`
- `test_centers``test_rooms``center_change_requests``center_change_rooms`
- `number_rules``number_rule_segments`
- `candidate_account_batches``candidate_account_batch_items`
- `workflow_definitions``workflow_steps``workflow_instances``workflow_actions`
- `organization``schema_metadata`
所有关联均有外键约束,账号、证件号、考试代码、报名关系、准考证号和单科成绩均有对应唯一约束。
系统采用“总表索引 + 独立物理分表”存储:创建每场考试时,会立即创建该场考试专用的
`exam_<分区键>_candidates``exam_<分区键>_admissions``exam_<分区键>_results`
`exam_<分区键>_centers` 四张表;创建每所学校时,会创建 `school_<分区键>_students`
学生专属表。分区键由业务 ID 的 SHA-256 摘要生成,不直接拼接用户输入。报名、缴费、准考证编排、
成绩和学生资料发生变化后,专属表会自动同步;总表继续承担跨考试、跨学校查询和外键完整性约束。
```powershell
$env:NODE_ENV = 'production'
$env:DATABASE_CLIENT = 'mysql'
$env:MYSQL_HOST = '127.0.0.1'
$env:MYSQL_PORT = '3306'
$env:MYSQL_USER = 'exam_app'
$env:MYSQL_PASSWORD = 'replace-with-a-strong-password'
$env:MYSQL_DATABASE = 'exam_information'
$env:HOST = '0.0.0.0'
npm start
```
也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;将模板复制为 `.env` 后取消 MySQL 配置项的注释并填写实际连接信息即可。生产部署仍建议由部署平台注入环境变量,避免在服务器文件中保存密码。
### Redis 缓存(可选)
配置 `REDIS_URL` 后,应用会缓存公开首页、已发布公告详情和每名考生的已发布成绩查询。公开数据默认 TTL 为 60 秒,成绩默认 TTL 为 24 小时;成绩录入/发布、批量导入、考试归档和成绩复议会自动使成绩缓存失效,超级管理员也可以在成绩管理中心手动刷新全部成绩缓存。Redis 未配置或暂时不可用时,应用会自动切换到有界本机缓存(默认最多 200 条),避免热点接口反复回源;写入后的失效语义保持不变。
同一个 `REDIS_URL` 还会启用 Redis 认证状态存储:登录 Session、TOTP 登录挑战和 TOTP 绑定临时状态全部写入 Redis。普通接口缓存按 `REDIS_URL` 使用 DB 0 时,认证状态默认自动选择独立 DB 1;可通过 `REDIS_SESSION_DB` 修改逻辑 DB,或通过 `REDIS_SESSION_URL` 指向另一台 Redis。应用会拒绝让认证状态与普通缓存使用同一 Redis 端点的同一逻辑 DB。认证 Redis 配置后连接失败会阻止服务启动,避免多实例之间悄悄退回本机状态而出现随机掉线。完全未配置 Redis 时,认证状态仍使用本机内存,服务重启会要求重新登录。
数据库关系表会按写入版本复用进程内只读快照,避免每个请求重复扫描并转换全部业务表。应用自身写入会立即使快照失效;SQLite 还通过 `PRAGMA data_version` 检测其他连接的提交,MySQL 外部直写默认最多延迟 30 秒可见。可通过 `DATABASE_STATE_CACHE_TTL_MS` 调整 MySQL 快照时间,通过 `LOCAL_CACHE_MAX_ENTRIES` 控制本机接口缓存上限。
```powershell
$env:REDIS_URL = 'redis://127.0.0.1:6379/0'
$env:REDIS_CACHE_PREFIX = 'exam-information'
$env:REDIS_CACHE_TTL_SECONDS = '60'
$env:REDIS_RESULTS_CACHE_TTL_SECONDS = '86400'
$env:REDIS_SESSION_DB = '1'
$env:AUTH_SESSION_TTL_SECONDS = '28800'
npm start
```
生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。若 Redis Cluster 不支持非 0 逻辑 DB,请用 `REDIS_SESSION_URL` 为认证状态配置独立 Redis 实例。
### 导入服务器 MySQL 测试数据
先停止正在运行的应用进程,确认服务器 `.env` 中已经设置 `DATABASE_CLIENT=mysql` 及完整 MySQL 连接参数,然后执行:
```powershell
npm run seed-test-data:mysql
```
脚本会读取 `.env`,校验当前连接的数据库名称、v15 表结构和已有数据。目标是新数据库时会自动建表并导入;目标只有首次启动生成的空业务结构时会在事务中替换为样例数据。若检测到学校、考生、考试、报名等业务数据,脚本默认拒绝覆盖。
仅在确认目标是可以完全覆盖的测试库时使用:
```powershell
npm run seed-test-data:mysql -- --force
```
强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`
## 中考志愿填报与招生录取
系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
1. 超级管理员设置填报时间、普通志愿数、最多提交次数和当前阶段;考生只有在当次成绩全部发布后才能填报,达到提交上限后自动锁定。
2. 招生学校账号以结构化表单上传本校普通生、特长生与生源校指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
3. 生源校学校管理员按考试逐人确认指标分配资格;本校资料已完善的在册考生全部确认后,系统自动公开有无资格及对应特长类型。超级管理员和班级管理员均不能代确认。
4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。
5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。
6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
7. 超级管理员签发正式录取后,系统按“招生学校代码 + 考试代码 + 校内独立流水号”生成稳定的录取通知书编号,并开启招生学校报到工作台。
8. 招生学校可逐人暂存 Y/N/P 报到状态,也可导出带下拉校验的 Excel、修改后导入,或扫描录取通知书二维码核验并登记;完整报到情况提交前不会进入审批。
9. 学校提交报到情况后可选择不补录或申请补录。超级管理员审批所有学校决定后,系统按缺额进入下一轮补录或结束录取;计划录取率和实际报到率在双方工作台实时显示。
10. 审批通过的报到情况会自动进入公开通知,包含计划数、正式录取数、已报到数、缺额和学校说明;无补录时标题不会出现“补录”。录取结束后继续自动发布脱敏录取名单及按学校、类别统计的录取分数线。
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可设计本校录取通知书的标题、正文、落款和配色,通知书不再添加“录取专用章”。管理后台以一级业务域分组,并把招生录取拆为录取设置、招生账户、招生计划、报到与补录、投档监督等二级菜单。
数据结构版本为 v20`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`
## 手动测试数据账号
测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建:
为了便于临时联调,所有预置样例账号统一使用密码 `12345678`,且预置考生不会在首次登录时被要求改密。通过系统业务流程后续新建的账号仍按正式规则生成随机初始密码。
| 角色 | 账号 | 密码 |
| --- | --- | --- |
| 超级管理员 | `admin` | `12345678` |
| 超级管理员(监督演示) | `supervisor` | `12345678` |
| 校级管理员 | `school_admin` | `12345678` |
| 同校校级管理员(转交演示) | `school_admin_2` | `12345678` |
| 班级管理员 | `class_admin` | `12345678` |
| 同班班级管理员(均分演示) | `class_admin_2` | `12345678` |
| 考生 | `2026-HZ01-F-0001` | `12345678` |
## 测试结束后初始化系统
先停止应用,然后运行以下命令。命令会读取 `.env` 并自动选择 SQLite 或 MySQL,删除样例学校、考生、考试、报名等业务数据,恢复系统基础配置和一个初始超级管理员:
```powershell
npm run initialize-system
```
也可以明确指定数据库类型:
```powershell
npm run initialize-system:sqlite
npm run initialize-system:mysql
```
MySQL 模式会自动识别由本项目生成的批量样例数据并清理。若目标包含无法识别为样例数据的业务记录,命令会拒绝执行;只有明确确认目标可完全清空时才可运行 `npm run initialize-system:mysql -- --force`。初始化完成后,初始管理员账号由 `.env` 中的 `INITIAL_ADMIN_USERNAME``INITIAL_ADMIN_PASSWORD``INITIAL_ADMIN_DISPLAY_NAME` 决定,然后再重新启动应用。
## 自动化测试
```powershell
npm test
```
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、三级管理员范围内缴费状态修改与名单导出、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。
## 项目结构
项目采用模块化单体架构:仍由一个 Node.js 进程部署,但 HTTP、权限、业务路由、数据库适配和前端页面按职责分开。
```text
index.html 页面入口
styles.css 公共首页、考生端、管理端响应式样式
app.js 前端路由、事件与表单控制器
server.mjs HTTP 服务启动、模块装配与静态文件服务
database.mjs 数据仓储与数据库模块装配
excel.mjs Excel 模板、导入解析与导出工作簿
src/data/base.mjs 空业务库与系统基础配置
src/data/seed.mjs 手动测试数据生成器
scripts/import-test-data.mjs 独立测试数据导入脚本
src/http/responses.mjs JSON、文件与请求体处理
src/security/auth-state.mjs Redis / 本机会话与 TOTP 临时状态
src/security/session.mjs Cookie 解析与当前用户
src/security/authorization.mjs 管理层级、权限和数据范围
src/routes/public.routes.mjs 公开 API
src/routes/auth.routes.mjs 登录、注册与改密 API
src/routes/candidate.routes.mjs 考生业务 API
src/routes/admin.routes.mjs 管理业务 API
src/database/schema.mjs SQLite / MySQL 关系模型
src/database/sqlite-adapter.mjs SQLite 初始化、迁移与事务适配
src/database/mysql-adapter.mjs MySQL 初始化、迁移与事务适配
src/client/state.mjs 前端共享状态
src/client/api.mjs 浏览器 API 请求封装
src/client/ui.mjs 格式化、图标与通用 UI 工具
src/client/public-views.mjs 公共首页与登录注册视图
src/client/candidate-views.mjs 考生中心视图
src/client/admin-views.mjs 管理后台视图
tests/system.test.mjs 端到端系统测试
data/exam.sqlite 本地运行后生成的 SQLite 数据库
.env.example 开发与生产环境变量模板
```
+1634
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
services:
app:
build:
context: .
image: exam-information-system:local
init: true
restart: unless-stopped
env_file:
- .env.docker
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4173
DATABASE_CLIENT: sqlite
SQLITE_PATH: /app/data/exam.sqlite
ports:
- "4173:4173"
volumes:
- exam-information-data:/app/data
volumes:
exam-information-data:
+1
View File
@@ -0,0 +1 @@
+1596
View File
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
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();
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#132451" />
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
<title>衡准 · 考试信息管理系统</title>
<link rel="stylesheet" href="/styles.css?v=20260722" />
</head>
<body>
<div id="app" aria-live="polite">
<div class="boot-screen">
<div class="brand-symbol"><i></i><i></i><i></i></div>
<strong>衡准</strong>
<span>系统正在加载</span>
</div>
</div>
<div id="modalRoot"></div>
<div class="toast" id="toast" role="status" aria-live="polite">
<span class="toast-icon"></span>
<div><strong>操作成功</strong><small>更改已保存</small></div>
</div>
<script type="module" src="/app.js?v=20260722"></script>
</body>
</html>
+4355
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "hengzhun-exam-system",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs",
"test": "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": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs",
"seed-test-data:sqlite": "node scripts/import-test-data.mjs --sqlite",
"seed-test-data:mysql": "node scripts/import-test-data.mjs --mysql",
"initialize-system": "node scripts/import-test-data.mjs --empty",
"initialize-system:sqlite": "node scripts/import-test-data.mjs --empty --sqlite",
"initialize-system:mysql": "node scripts/import-test-data.mjs --empty --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
@@ -0,0 +1,55 @@
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}`);
+202
View File
@@ -0,0 +1,202 @@
import { pbkdf2Sync, randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { loadEnvFile } from 'node:process';
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
import { createBaseDatabase } from '../src/data/base.mjs';
import { createSeedDatabase } from '../src/data/seed.mjs';
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
const root = resolve(process.cwd());
const envPath = join(root, '.env');
if (existsSync(envPath)) loadEnvFile(envPath);
const options = new Set(process.argv.slice(2));
if (options.has('--mysql') && options.has('--sqlite')) throw new Error('不能同时指定 --mysql 和 --sqlite');
const configuredClient = options.has('--mysql') ? 'mysql' : options.has('--sqlite') ? 'sqlite' : process.env.DATABASE_CLIENT;
const client = String(configuredClient || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase();
if (!['sqlite', 'mysql'].includes(client)) throw new Error(`不支持的 DATABASE_CLIENT${client}`);
process.env.DATABASE_CLIENT = client;
const force = options.has('--force');
const initializeEmpty = options.has('--empty');
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
return `${salt}:${hash}`;
}
const createTargetState = () => initializeEmpty
? createBaseDatabase({
nowIso: () => new Date().toISOString(),
hashPassword,
initialAdmin: {
username: process.env.INITIAL_ADMIN_USERNAME,
password: process.env.INITIAL_ADMIN_PASSWORD,
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
}
})
: createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
async function prepareSqlite() {
const databasePath = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite'));
const relativePath = relative(root, databasePath);
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
throw new Error('拒绝覆盖工作区以外的 SQLite 数据库');
}
await rm(databasePath, { force: true });
await rm(`${databasePath}-shm`, { force: true });
await rm(`${databasePath}-wal`, { force: true });
process.env.SQLITE_PATH = databasePath;
return databasePath;
}
function mysqlPoolOptions(mysql) {
if (process.env.DATABASE_URL) return mysql.createPool(process.env.DATABASE_URL);
if (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !process.env.MYSQL_DATABASE) {
throw new Error('MySQL 配置不完整:请在 .env 设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE');
}
return 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: process.env.MYSQL_DATABASE,
waitForConnections: true,
connectionLimit: 2,
charset: 'utf8mb4',
timezone: 'Z',
enableKeepAlive: true
});
}
const mysqlBusinessTables = [
'schools', 'school_classes', 'candidate_profiles', 'notices', 'exams', 'exam_subjects',
'registrations', 'registration_subjects', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects',
'results', 'test_centers', 'test_rooms', 'center_change_requests', 'center_change_rooms',
'candidate_account_batches', 'candidate_account_batch_items', 'workflow_instances', 'workflow_actions', 'admission_records', 'audit_logs'
];
async function prepareMysql() {
const { default: mysql } = await import('mysql2/promise');
const pool = mysqlPoolOptions(mysql);
let connection;
try {
connection = await pool.getConnection();
const [[databaseRow]] = await connection.query('SELECT DATABASE() AS name');
const databaseName = String(databaseRow?.name || '');
if (!databaseName || ['mysql', 'information_schema', 'performance_schema', 'sys'].includes(databaseName.toLowerCase())) {
throw new Error(`拒绝向系统数据库导入测试数据:${databaseName || '未选择数据库'}`);
}
const [tableRows] = await connection.query(`
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
`);
const existingTables = new Set(tableRows.map(row => row.TABLE_NAME));
const existingAppTables = relationalTables.filter(table => existingTables.has(table));
if (!existingAppTables.length) return { location: `MySQL database ${databaseName}`, initialized: false };
if (existingAppTables.length !== relationalTables.length) {
const missing = relationalTables.filter(table => !existingTables.has(table));
throw new Error(`MySQL 数据库结构不完整,缺少:${missing.join(', ')}。请先使用空数据库启动一次应用完成建表`);
}
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
if (Number(metadataRows[0]?.schema_version) !== CURRENT_SCHEMA_VERSION) {
throw new Error(`MySQL 数据库结构版本不是 v${CURRENT_SCHEMA_VERSION}(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
}
const [examPartitionRows] = await connection.query(
'SELECT candidates_table, admissions_table, results_table, centers_table FROM exam_data_partitions'
);
const [schoolPartitionRows] = await connection.query('SELECT students_table FROM school_student_partitions');
const dynamicPartitionTables = [
...examPartitionRows.flatMap(row => [row.candidates_table, row.admissions_table, row.results_table, row.centers_table]),
...schoolPartitionRows.map(row => row.students_table)
];
if (dynamicPartitionTables.some(table => !/^[a-z][a-z0-9_]{0,63}$/.test(table))) {
throw new Error('分表登记中存在非法表名,拒绝替换测试数据');
}
const nonEmpty = [];
for (const table of mysqlBusinessTables) {
const [[row]] = await connection.query(`SELECT COUNT(*) AS count FROM \`${table}\``);
if (Number(row.count) > 0) nonEmpty.push(`${table}=${row.count}`);
}
const [[userRow]] = await connection.query('SELECT COUNT(*) AS count FROM users');
if (Number(userRow.count) > 1) nonEmpty.push(`users=${userRow.count}`);
let recognizedTestData = false;
if (initializeEmpty && nonEmpty.length) {
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')");
recognizedTestData = Number(bulkUsers.count) >= 1100 && Number(testSchools.count) === 5;
}
if (nonEmpty.length && !force && !recognizedTestData) {
const forceCommand = initializeEmpty
? 'npm run initialize-system:mysql -- --force'
: 'npm run seed-test-data:mysql -- --force';
throw new Error(
`MySQL 数据库 ${databaseName} 已有业务数据(${nonEmpty.slice(0, 8).join(', ')}${nonEmpty.length > 8 ? ', ...' : ''})。` +
`如确认这是可覆盖的测试库,请运行 ${forceCommand}`
);
}
if (nonEmpty.length) {
console.warn(`[${recognizedTestData ? 'test-data cleanup' : 'force'}] Replacing existing business data in MySQL database ${databaseName}`);
} else {
console.log(`Preparing empty MySQL database ${databaseName} for ${initializeEmpty ? 'system initialization' : 'test data'}`);
}
const state = createTargetState();
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
await connection.beginTransaction();
try {
// 先移除考试,使归档成绩保护触发器在清理 results 时不再命中。
const clearOrder = ['exams', ...[...relationalTables].reverse().filter(table => !['schema_metadata', 'exams'].includes(table))];
for (const table of clearOrder) await connection.query(`DELETE FROM \`${table}\``);
for (const operation of buildSeedOperations(state)) await connection.execute(operation.sql, operation.params);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
}
for (const table of dynamicPartitionTables) await connection.query(`DROP TABLE IF EXISTS \`${table}\``);
return { location: `MySQL database ${databaseName}`, initialized: true };
} finally {
connection?.release();
await pool.end();
}
}
let location;
let mysqlAlreadyImported = false;
if (client === 'sqlite') {
location = await prepareSqlite();
} else {
const prepared = await prepareMysql();
location = prepared.location;
mysqlAlreadyImported = prepared.initialized;
}
const database = await createDatabase({
root,
seed: createTargetState
});
const state = await database.read();
await database.close();
const pending = state.registrations.filter(item => item.status === 'pending').length;
const rejected = state.registrations.filter(item => item.status === 'rejected').length;
const unpaid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').length;
const paid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'paid').length;
if (initializeEmpty) {
console.log(`Initialized empty system in ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
} else {
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
console.log(`${state.schools.filter(item => item.isSourceSchool).length} source schools, ${state.schools.filter(item => item.isAdmissionSchool).length} admission schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
console.log(`${state.results.length} published subject scores, ${state.admissionRecords.filter(item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1).length} first-round preferences`);
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
console.log('all predefined test account passwords: 12345678');
}
+4
View File
@@ -0,0 +1,4 @@
// Keep the historical reset-db entry point, but run the same guarded initializer used
// by initialize-system so SQLite, MySQL, .env loading and schema checks stay in sync.
if (!process.argv.slice(2).includes('--empty')) process.argv.push('--empty');
await import('./import-test-data.mjs');
+1042
View File
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
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;
};
}
});
}
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
import { specialtyCatalog } from '../data/specialty-types.mjs';
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>`;
}
export function admissionCategoryEditor(h, sourceSchools = [], category = {}) {
const specialty = Boolean(category.specialtyCategory);
const selectedCategory = specialtyCatalog.find(item => item.code === category.specialtyCategory);
return `<article class="admission-category-editor"><header><div><span>招生类别</span><strong>${h(category.name || '新类别')}</strong></div><button type="button" data-action="remove-admission-category">移除类别</button></header><div class="admission-category-fields"><label><span>类别名称 *</span><input name="categoryName" required maxlength="80" value="${h(category.name || '')}" placeholder="例如:普通生、艺术特长生"></label><label><span>计划人数 *</span><input name="categoryQuota" required type="number" min="1" step="1" value="${h(category.quota || '')}" placeholder="人数"></label><label><span>类别性质</span><select name="categoryKind" data-action="plan-category-kind"><option value="general" ${specialty ? '' : 'selected'}>普通 / 政策类</option><option value="specialty" ${specialty ? 'selected' : ''}>特长生</option></select></label><div class="specialty-plan-fields ${specialty ? '' : 'hidden'}" data-plan-specialty><label><span>特长大类 *</span><select name="categorySpecialtyCategory" data-action="specialty-category" ${specialty ? '' : 'disabled'}><option value="">请选择大类</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyCategory ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长小类 *</span><select name="categorySpecialtyType" data-specialty-type ${specialty && selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyType ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div></div><section class="indicator-allocation-editor"><div class="indicator-allocation-head"><div><strong>指标分配</strong><small>可把本类别计划的一部分定向分配给生源校,合计不得超过计划人数。</small></div><button type="button" class="row-action" data-action="add-indicator-allocation">添加生源校指标</button></div><div data-indicator-allocations>${(category.indicatorAllocations || []).map(item => indicatorAllocationEditor(h, sourceSchools, item)).join('')}</div></section></article>`;
}
export function admissionCategoriesEditor(h, sourceSchools = [], categories = []) {
const initial = categories.length ? categories : [{ name: '普通生', quota: '', indicatorAllocations: [] }];
return `<section class="admission-categories-builder"><div class="admission-builder-head"><div><strong>招生类别与计划</strong><small>逐项设置类别、资格范围和生源校指标。</small></div><button type="button" class="row-action primary" data-action="add-admission-category">添加招生类别</button></div><div data-admission-categories>${initial.map(category => admissionCategoryEditor(h, sourceSchools, category)).join('')}</div></section>`;
}
+87
View File
@@ -0,0 +1,87 @@
export function createAdmissionViews(context) {
const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['reporting','考生报到','users','招生业务'],['notice-template','通知书模板','ticket','文书中心']];
function shell(page, content, title, description) {
const groups = [...new Set(nav.map(item => item[3]))];
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">招生学校 · ${h(state.pageData?.school?.name || '')}</p><nav class="portal-nav-groups">${groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(item => item[3] === group).map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).join('')}</section>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>仅本校招生数据</strong><small>考生志愿不可见、不可修改</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar">${icons.menu}</button><div><span>招生学校</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><span class="user-avatar">${h((state.user?.displayName || '招').slice(0,1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>招生学校账号</small></span><button class="logout-button" data-action="logout">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">SCHOOL ADMISSION</p><h1>${h(title)}</h1><p>${h(description)}</p></div></div>${content}</section></main></div>`;
}
async function renderAdmission(page) {
if (state.user?.role !== 'admission_school') return requireLogin();
if (!nav.some(item => item[0] === page)) page = 'dashboard';
const meta = { dashboard:['招生工作台','查看本校计划完成率、报到进度与待办事项。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], reporting:['考生报到','暂存报到状态,支持 Excel 批量维护和通知书二维码核验。'], 'notice-template':['录取通知书模板','设计本校录取通知书的标题、正文、落款与主色,正式录取后由考生下载。'] };
app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
try {
const endpoint = page === 'dashboard' ? 'context' : page;
const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : page === 'placements' ? placements(data) : page === 'reporting' ? reporting(data) : noticeTemplate(data);
app.innerHTML = shell(page, content, ...meta[page]);
} catch (error) { renderError(error); }
}
function dashboard(data) {
const progress = data.plans || [];
return `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section>${progress.length ? `<section class="admission-progress-grid">${progress.map(plan => `<article><header><span>${h(plan.examName)}</span><strong>${h(plan.progress.admissionRate)}%</strong></header><div class="progress-meter"><i style="width:${Math.min(100, plan.progress.admissionRate)}%"></i></div><p>计划 ${h(plan.progress.totalQuota)} 人 · 正式录取 ${h(plan.progress.finalCount)} 人 · 已报到 ${h(plan.progress.reportedCount)} 人</p><small>实际报到完成率 ${h(plan.progress.reportingRate)}%</small></article>`).join('')}</section>` : ''}<div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button><button data-route="admission_school/reporting"><i>到</i><span><strong>登记考生报到</strong><small>暂存、Excel 导入或扫描通知书二维码</small></span>${icons.arrow}</button></section>${data.notifications?.length ? `<section class="panel compact-notices"><div class="panel-title"><h2>系统自动通知</h2><span>${data.notifications.length} 条</span></div>${data.notifications.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section>` : ''}</div>`;
}
function reporting(data) {
if (!data.batches?.length) return `<section class="panel empty-state"><h2>暂无报到批次</h2><p>超级管理员签发正式录取通知书并开启报到后,本页会生成报到台账。</p></section>`;
const statusLabels = { draft: '暂存中', submitted: '报到已提交', pending_approval: '补录决定待审批', approved: '已审批并公示', rejected: '审批退回', not_started: '尚未开始' };
return data.batches.map(batch => {
const key = `reporting-${batch.exam.id}-${batch.round}`;
const page = paged(batch.rows, key, 20);
const editable = ['draft', 'rejected'].includes(batch.status);
const importSummary = state.reportingImportSummaries?.[batch.exam.id];
const rowHtml = page.items.map(item => `<tr>${editable ? `<td class="selection-cell"><input type="checkbox" data-reporting-select value="${h(item.placementId)}" aria-label="选择 ${h(item.name)}"></td>` : ''}<td><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber)}</small></td><td><strong class="mono">${h(item.noticeNumber)}</strong><small>${h(item.categoryName)}</small></td><td><select name="status" data-reporting-status data-placement-id="${h(item.placementId)}" ${editable ? '' : 'disabled'}><option value="pending" ${item.status === 'pending' ? 'selected' : ''}>P · 待确认</option><option value="reported" ${item.status === 'reported' ? 'selected' : ''}>Y · 已报到</option><option value="not_reported" ${item.status === 'not_reported' ? 'selected' : ''}>N · 未报到</option></select></td><td><input name="note" data-reporting-note data-placement-id="${h(item.placementId)}" value="${h(item.note)}" placeholder="选填报到备注" ${editable ? '' : 'disabled'}></td></tr>`).join('');
const actions = editable ? `<div class="reporting-actions"><button type="submit" class="ghost-button">暂存当前页</button><button type="button" class="solid-button" data-action="submit-admission-reporting" data-exam-id="${h(batch.exam.id)}">提交全部报到情况</button></div>` : batch.status === 'submitted' ? `<form class="reporting-decision" data-form="admission-reporting-decision"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><div><strong>报到情况已提交</strong><p>请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。</p></div><label><span>学校决定</span><select name="supplement"><option value="false">不进行补录</option><option value="true" ${batch.progress.reportingGap ? '' : 'disabled'}>申请补录 ${h(batch.progress.reportingGap)} 人</option></select></label><label><span>决定说明</span><input name="decisionNote" placeholder="填写补录原因或不补录说明"></label><button class="solid-button" type="submit">提交超级管理员审批</button></form>` : `<div class="reporting-readonly-note"><strong>${h(statusLabels[batch.status] || batch.status)}</strong><p>${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}</p></div>`;
const bulkTools = editable ? `<div class="reporting-bulk-bar" data-reporting-bulk data-table-id="${h(key)}"><label class="bulk-check"><input type="checkbox" data-reporting-select-all data-table-id="${h(key)}"><span>全选本页</span></label><strong data-reporting-selected-count>已选 0 人</strong><label><span>统一状态</span><select data-reporting-bulk-status><option value="reported">Y · 确认报到</option><option value="not_reported">N · 确认未报到</option><option value="pending">P · 待确认</option></select></label><label class="bulk-note"><span>统一备注(留空则保留原备注)</span><input data-reporting-bulk-note placeholder="例如:现场核验通过"></label><button type="button" class="ghost-button" data-action="bulk-reporting-apply">应用到所选</button></div>` : '';
const ledger = `<div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索考生、报名号、通知书编号或类别"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="reported">已报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="not_reported">未报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="pending">待确认</button></div></div>${bulkTools}<div class="table-scroll"><table id="${h(key)}"><thead><tr>${editable ? '<th class="selection-cell">选择</th>' : ''}<th>考生</th><th>通知书 / 类别</th><th>报到状态码</th><th>备注</th></tr></thead><tbody>${rowHtml || `<tr><td colspan="${editable ? '5' : '4'}" class="empty-state">本轮没有正式录取考生</td></tr>`}</tbody></table></div>${pagination(page)}`;
const ledgerBlock = editable ? `<form data-form="admission-reporting-draft" data-exam-id="${h(batch.exam.id)}">${ledger}${actions}</form>` : `<div class="reporting-ledger-readonly">${ledger}</div>${actions}`;
return `<section class="reporting-workbench"><header><div><span>${h(batch.exam.code)} · 第 ${h(batch.round)} 轮</span><h2>${h(batch.exam.name)}</h2><p>计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。</p></div><div class="reporting-rate"><strong>${h(batch.progress.reportingRate)}%</strong><span>计划报到完成率</span></div></header><div class="reporting-stat-strip"><span>正式录取 <b>${h(batch.progress.finalCount)}</b></span><span>已报到 <b>${h(batch.progress.reportedCount)}</b></span><span>未报到 <b>${h(batch.progress.notReportedCount)}</b></span><span>计划缺额 <b>${h(batch.progress.reportingGap)}</b></span><em>${h(statusLabels[batch.status] || batch.status)}</em></div>${editable ? `<section class="reporting-tools"><div class="reporting-excel-tool"><div><strong>Excel 批量维护</strong><small>黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。</small></div><div class="tool-buttons"><button class="ghost-button" data-action="download-admission-reporting" data-exam-id="${h(batch.exam.id)}">导出 Excel</button><label class="solid-button">导入暂存<input type="file" accept=".xlsx" data-admission-reporting-file data-exam-id="${h(batch.exam.id)}" hidden></label></div>${importSummary ? `<div class="reporting-import-summary ${importSummary.changedCount ? 'changed' : 'unchanged'}"><strong>${importSummary.changedCount ? `最近导入已更新 ${h(importSummary.changedCount)}` : '最近导入没有产生变化'}</strong><span>读取 ${h(importSummary.count)} 行 · 未变化 ${h(importSummary.unchangedCount)} 行</span>${importSummary.changes?.length ? `<small>${importSummary.changes.slice(0, 3).map(item => `${h(item.name)}${h(item.fromCode)}${h(item.toCode)}`).join('')}</small>` : '<small>Excel 内容与当前暂存状态一致。</small>'}</div>` : ''}</div><div class="reporting-scan-tool"><div><strong>通知书二维码核验</strong><small>打开实时相机扫描;识别后先核对考生,再点击暂存。</small></div><button type="button" class="camera-button" data-action="open-reporting-camera" data-exam-id="${h(batch.exam.id)}">${icons.camera || ''}<span>打开相机扫码</span></button><form data-form="admission-reporting-scan-preview"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><input name="code" placeholder="也可粘贴 AN 防伪码或二维码链接" required><button class="ghost-button" type="submit">核验</button></form></div></section>` : ''}${ledgerBlock}</section>`;
}).join('');
}
function paged(items, key, defaultPageSize = 50) {
items = filterTableItems(state, items, key);
const current = state.tablePages[key] || {};
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
const total = items.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(Math.max(1, Number(current.page) || 1), totalPages);
state.tablePages[key] = { page, pageSize };
return { items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total, totalPages, key };
}
function noticeTemplate(data) {
const template = data.template || {};
return `<section class="notice-template-studio" data-notice-template><form class="panel notice-template-form" data-form="admission-notice-template"><input type="hidden" name="examId" value="${h(data.exams?.[0]?.id || '')}"><div class="panel-title"><div><h2>模板设计</h2><p>正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}</p></div><span>${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}</span></div><div class="field-row"><label><span>英文眉题</span><input name="eyebrow" maxlength="60" value="${h(template.eyebrow || 'ADMISSION NOTICE')}"></label><label><span>中文主标题</span><input name="title" maxlength="80" value="${h(template.title || '录 取 通 知 书')}" required></label></div><label><span>通知书正文 *</span><textarea name="body" rows="9" maxlength="1600" required>${h(template.body || '')}</textarea></label><label><span>页脚说明</span><textarea name="footer" rows="3" maxlength="300">${h(template.footer || '')}</textarea></label><div class="template-color-row"><label><span>学校主色</span><input name="primaryColor" type="color" value="${h(template.primaryColor || '#8d2028')}"></label><label><span>强调色</span><input name="accentColor" type="color" value="${h(template.accentColor || '#c9a45b')}"></label></div><button class="solid-button" type="submit">保存并启用模板</button></form><article class="notice-template-preview" style="--template-primary:${h(template.primaryColor || '#8d2028')};--template-accent:${h(template.accentColor || '#c9a45b')}"><div class="template-frame"><small data-template-preview="eyebrow">${h(template.eyebrow || 'ADMISSION NOTICE')}</small><h2 data-template-preview="title">${h(template.title || '录 取 通 知 书')}</h2><h3>${h(data.school?.name)}</h3><div class="template-notice-number">通知书编号:AD01-EX-2026-ZK-000001</div><strong>张同学:</strong><p data-template-preview="body">${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}</p><footer><span data-template-preview="footer">${h(template.footer || '')}</span><b>${h(data.school?.name)}</b></footer><div class="template-qr-placeholder">防伪二维码</div></div><p>右侧为 A4 通知书预览;正式下载件会自动写入通知书编号、防伪查询码与二维码。</p></article></section>`;
}
function pagination(meta) {
if (!meta || meta.total <= meta.pageSize) return '';
const start = (meta.page - 1) * meta.pageSize + 1;
const end = Math.min(meta.total, meta.page * meta.pageSize);
const pages = [...new Set([1, meta.page - 1, meta.page, meta.page + 1, meta.totalPages])].filter(page => page >= 1 && page <= meta.totalPages);
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button>${pages.map((page, index) => `${index && page - pages[index - 1] > 1 ? '<i>…</i>' : ''}<button type="button" class="${page === meta.page ? 'active' : ''}" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${page}">${page}</button>`).join('')}<button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
}
function plans(data) {
const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20);
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolAdmissionPlanTable" placeholder="跨页搜索考试、类别、指标学校或审核意见"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="schoolAdmissionPlanTable"><thead><tr><th>考试</th><th>类别计划</th><th>实时完成率</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td><strong>${h(plan.progress?.admissionRate || 0)}%</strong><small>正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}</small><small>实际报到 ${h(plan.progress?.reportingRate || 0)}%</small></td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)}`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
}
function placements(data) {
const exportBar = data.completedExams?.length ? `<section class="panel admission-export-bar"><div><span>FINAL ROSTER</span><strong>正式录取考生信息 Excel</strong><small>仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。</small></div><label><span>已完成考试</span><select name="exportExamId">${data.completedExams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><button class="solid-button" data-action="download-admitted-candidates">下载 Excel</button></section>` : '';
const exams = [...new Map(data.placements.map(item => [item.examId, item.examName])).entries()];
const categories = [...new Set(data.placements.map(item => item.payload.categoryName).filter(Boolean))];
const pendingCount = data.placements.filter(item => item.status === 'school_review').length;
const placementPage = paged(data.placements, 'placementReviewTable');
const rows = placementPage.items.map(item => `<tr data-status="${h(item.status)}" data-exam="${h(item.examId)}" data-category="${h(item.payload.categoryName)}"><td><input type="checkbox" data-placement-select value="${h(item.id)}" ${item.status === 'school_review' ? '' : 'disabled'} aria-label="选择 ${h(item.candidate.name)}"></td><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small><small>${h(item.examName)}</small></td><td>${h(item.candidate.specialtyLabel || '普通生')}<small>${h(item.candidate.specialtyCertificate || '')}</small><small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写至少 8 字理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('');
return `${exportBar}<section class="panel data-panel placement-review-ledger"><div class="panel-title"><div><h2>本校投档审核台账</h2><p>可搜索、筛选和多选批量处理;仅待审核记录可被选中。</p></div><span>${pendingCount} 人待审 / 共 ${data.placements.length} 人</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="placementReviewTable" placeholder="跨页搜索姓名、报名号、考试、类别或资格"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="placementReviewTable"><option value="">全部考试</option>${exams.map(([id, name]) => `<option value="${h(id)}">${h(name)}</option>`).join('')}</select><select data-table-filter="category" data-target="placementReviewTable"><option value="">全部招生类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button type="button" class="row-action" data-action="clear-table-filters" data-target="placementReviewTable">清除筛选</button></div></div><div class="filter-pills placement-status-pills"><button type="button" class="active" data-action="status-filter" data-target="placementReviewTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="school_review">待审核</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="admitted">已接收</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="withdrawal_pending">退档待审</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="final">正式录取</button></div><div class="placement-bulk-toolbar"><label><input type="checkbox" data-placement-select-all data-target="placementReviewTable"><span>选择当前页筛选结果中的待审核考生</span></label><div><strong data-placement-selected-count>已选 0 人</strong><button type="button" class="ghost-button" data-action="bulk-placement-review" data-decision="withdraw">批量申请退档</button><button type="button" class="solid-button" data-action="bulk-placement-review" data-decision="accept">批量接收</button></div></div><div class="table-scroll"><table id="placementReviewTable"><thead><tr><th class="select-column">选择</th><th>考生 / 考试</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>单人审核</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
}
return { renderAdmission };
}
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
import { filterTableItems } from './table-state.mjs';
+34
View File
@@ -0,0 +1,34 @@
const pendingReads = new Map();
async function request(path, options) {
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
const response = await fetch(path, {
credentials: 'same-origin',
headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
...options,
body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body
});
const type = response.headers.get('content-type') || '';
const data = type.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) {
const error = new Error(data?.message || '操作未完成,请稍后重试');
error.status = response.status;
throw error;
}
return data;
}
export function api(path, options = {}) {
const method = String(options.method || 'GET').toUpperCase();
if (method !== 'GET' || options.body != null || options.signal) return request(path, options);
// A quick double click or repeated render must not download and parse the
// same large JSON response more than once while the first request is active.
const key = String(path);
if (pendingReads.has(key)) return pendingReads.get(key);
const loading = request(path, options).finally(() => {
if (pendingReads.get(key) === loading) pendingReads.delete(key);
});
pendingReads.set(key, loading);
return loading;
}
+247
View File
@@ -0,0 +1,247 @@
import { mountRegionSelects } from './region-select.mjs';
import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.mjs';
export function createCandidateViews(context) {
const {
state,
app,
h,
formatDate,
dateRange,
badge,
money,
passPolicyText,
statusLabels,
icons,
api,
renderError,
requireLogin,
emptyState,
brand
} = context;
const candidateNav = [
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['admissions', '志愿与录取', 'check'], ['notices', '通知公告', 'bell'],
['security', '账户安全', 'user']
];
function adminNavForUser() {
const level = state.user?.adminLevel || 'super';
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['payments', level === 'class' ? '缴费确认' : '缴费名单', 'ticket'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
const security = ['security', '账户安全', 'user'];
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], core[4], ['flows', '流程中心', 'check'], security];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], core[4], ['centers', '考场信息', 'exam'], ['flows', '流程中心', 'check'], security];
return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], ['exams', '考试与科目', 'exam'], core[2], core[3], ['admit', '准考证编排', 'ticket'], core[4], ['admission-settings', '录取设置', 'check'], ['admission-accounts', '招生账户', 'users'], ['admission-plans', '招生计划', 'exam'], ['admission-reporting', '报到与补录', 'bell'], ['admission-supervision', '投档监督', 'check'], ['notices', '通知发布', 'bell'], ['centers', '考场信息', 'exam'], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], security];
}
function portalShell(role, page, content, title, description) {
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
const roleName = role === 'admin' ? '管理后台' : '考生中心';
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
const groupFor = id => role === 'candidate'
? ({ dashboard: '个人总览', profile: '账户与档案', security: '账户与档案', exams: '考试服务', registrations: '考试服务', admit: '考试服务', results: '考试服务', admissions: '招生录取', notices: '招生录取' }[id] || '其他')
: ({ dashboard: '运行总览', schools: '组织与账户', organization: '组织与账户', admins: '组织与账户', 'account-batches': '组织与账户', candidates: '报名考务', registrations: '报名考务', payments: '报名考务', admit: '报名考务', exams: '考试与成绩', results: '考试与成绩', admissions: '招生录取', 'admission-settings': '招生录取', 'admission-accounts': '招生录取', 'admission-plans': '招生录取', 'admission-reporting': '招生录取', 'admission-supervision': '招生录取', 'indicator-qualifications': '招生录取', notices: '招生录取', centers: '场所与流程', flows: '场所与流程', 'flow-design': '系统配置', 'number-rules': '系统配置', security: '系统配置' }[id] || '其他');
const groups = [...new Set(nav.map(([id]) => groupFor(id)))];
const navHtml = groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</section>`).join('');
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav class="portal-nav-groups">${navHtml}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
}
function portalHeadingAction(role, page) {
if (role === 'admin' && page === 'notices') return `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`;
if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`;
if (role === 'admin' && page === 'schools') return `<button class="solid-button" data-action="new-school">${icons.plus} 创建学校</button>`;
if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`;
if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`;
if (role === 'admin' && page === 'organization') return `<button class="solid-button" data-action="new-school-class">${icons.plus} 新增班级</button>`;
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
return '';
}
function loadingPanel() {
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
}
function mountAdmissionProfileFields(profile = {}) {
const actions = app.querySelector('.profile-form .form-actions');
if (!actions || app.querySelector('[data-admission-profile-fields]')) return;
const qualification = resolveProfileSpecialty(profile);
const selectedCategory = specialtyCatalog.find(item => item.code === qualification.category);
actions.insertAdjacentHTML('beforebegin', `<div class="form-section-title" data-admission-profile-fields><span>04</span><div><h2>中考招生资格</h2><p>特长资格按大类和小类登记,填志愿时系统只显示与本人资格相符的招生类别。</p></div></div><div class="form-grid specialty-qualification-grid"><label><span>特长生大类</span><select name="specialtyCategory" data-action="specialty-category"><option value="">无特长生资格</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === qualification.category ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长生小类</span><select name="specialtyType" data-specialty-type ${selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === qualification.type ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长证明编号</span><input name="specialtyCertificate" value="${h(profile.specialtyCertificate || '')}" placeholder="证书或统一测试材料编号"></label><label class="wide-field"><span>政策资格说明</span><input name="policyEligibility" value="${h(profile.policyEligibility || '')}" placeholder="例如:指标生资格已核验"></label></div>`);
}
function onboardingShell(stage, content) {
const passwordDone = stage !== 'password';
return `<main class="onboarding-page"><aside class="onboarding-identity">${brand()}<span>固定报名号</span><strong>${h(state.user.candidateNumber)}</strong><p>这个号码就是你的考生账户。以后参加不同考试,仍然使用同一个报名号。</p><div class="onboarding-steps"><div class="${stage === 'password' ? 'current' : 'done'}"><i>${passwordDone ? '✓' : '1'}</i><span><b>修改初始密码</b><small>设置仅本人知道的新密码</small></span></div><div class="${stage === 'profile' ? 'current' : passwordDone ? '' : ''}"><i>2</i><span><b>补全个人信息</b><small>实名、籍贯、住址和学籍信息</small></span></div><div><i>3</i><span><b>等待资料审核</b><small>审核通过后开始考试报名</small></span></div></div><button data-action="logout">退出当前账户</button></aside><section class="onboarding-work"><div class="onboarding-work-head"><span>FIRST SIGN-IN</span><h1>${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}</h1><p>${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}</p></div>${content}</section></main>`;
}
function passwordOnboardingForm() {
return `<section class="panel password-onboarding"><div class="password-rule"><b>新密码要求</b><span>至少 8 位,且不能与初始密码相同。</span></div><form class="stack-form" data-form="candidate-password"><label><span>当前初始密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>设置新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码并继续 ${icons.arrow}</button></form></section>`;
}
async function renderCandidate(page) {
if (state.user?.role !== 'candidate') return requireLogin();
app.classList.remove('admin-readable');
if (state.user.mustChangePassword) {
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
return;
}
if (!state.profile?.profileCompleted) {
try {
const data = await api('/api/candidate/profile');
state.pageData = data; state.profile = data.profile;
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
mountAdmissionProfileFields(data.profile);
mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
} catch (error) { renderError(error); }
return;
}
const meta = {
dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'],
profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'],
exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'],
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
admissions: ['志愿填报与录取', '成绩发布后由本人填报志愿,并在这里查看投档与录取进度。'],
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'],
security: ['账户安全', '使用当前密码设置新的登录密码。']
};
if (!meta[page]) page = 'dashboard';
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
try {
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations';
const data = page === 'notices' ? await api('/api/candidate/notices') : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
state.pageData = data;
if (data.profile) state.profile = data.profile;
const content = {
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
results: () => candidateResults(data), admissions: () => candidateAdmissions(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
}[page]();
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
if (page === 'profile') { mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); }
} catch (error) { renderError(error); }
}
function candidateDashboard(data) {
const registration = data.registrations[0];
const steps = [
['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'],
['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'],
['考试报名', Boolean(registration), registration ? '已报名' : '未报名'],
['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'],
['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length}` : '待发布']
];
return `<section class="candidate-welcome"><div><span>${new Date().getHours() < 12 ? '上午好' : '下午好'}</span><h2>${h(data.profile?.name || state.user.displayName)},下一步已为你标出。</h2><p>${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}</p></div><div class="welcome-seal">准<br>考</div></section><div class="summary-grid"><article><span class="summary-icon">${icons.user}</span><div><small>个人资料</small><strong>${statusLabels[data.profile?.status] || '未填写'}</strong></div>${badge(data.profile?.status || 'pending')}</article><article><span class="summary-icon">${icons.exam}</span><div><small>已报名考试</small><strong>${data.registrations.length} 场</strong></div><button data-route="candidate/exams">去报名</button></article><article><span class="summary-icon">${icons.ticket}</span><div><small>可下载准考证</small><strong>${data.registrations.filter(item => item.admitCard).length} 份</strong></div><button data-route="candidate/admit">查看</button></article><article><span class="summary-icon">${icons.chart}</span><div><small>已发布成绩</small><strong>${data.results.length} 科</strong></div><button data-route="candidate/results">查分</button></article></div><div class="candidate-grid"><section class="panel progress-panel"><div class="panel-title"><h2>我的应考进度</h2><span>自动更新</span></div><div class="candidate-progress">${steps.map((step, index) => `<div class="progress-step ${step[1] ? 'done' : index === steps.findIndex(item => !item[1]) ? 'current' : ''}"><i>${step[1] ? '✓' : index + 1}</i><div><strong>${step[0]}</strong><small>${step[2]}</small></div></div>`).join('')}</div></section><section class="panel compact-notices"><div class="panel-title"><h2>最近通知</h2><button data-route="candidate/notices">全部通知</button></div>${data.notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section></div>`;
}
function candidateProfile(data, onboarding = false) {
const { profile, schools = [], classes = [], workflow } = data;
const step = workflow?.currentStepDetail;
const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber;
return `<section class="panel form-panel ${onboarding ? 'onboarding-profile' : ''}">${workflow ? `<div class="candidate-flow-note"><span>当前审批</span><strong>${h(step?.name || statusLabels[workflow.status])}</strong><small>${workflow.assignee ? `${h(workflow.assignee.displayName)} 处理` : '流程已结束'}</small></div>` : ''}<form class="profile-form" data-form="candidate-profile"><div class="form-section-title"><span>01</span><div><h2>身份信息</h2><p>姓名和证件号码须与有效证件完全一致。</p></div></div><div class="form-grid"><label><span>考生姓名 *</span><input name="name" required value="${h(profile?.name)}"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option ${profile?.gender === '男' ? 'selected' : ''}>男</option><option ${profile?.gender === '女' ? 'selected' : ''}>女</option></select></label><label><span>证件号码 *</span><input name="idNumber" required value="${h(idNumber)}"></label><label><span>出生日期</span><input name="birthDate" type="date" value="${h(profile?.birthDate)}"></label><label><span>籍贯 *</span><input name="nativePlace" required value="${h(profile?.nativePlace)}" placeholder="例如:江苏海州"></label><label><span>民族</span><input name="ethnicity" value="${h(profile?.ethnicity)}" placeholder="例如:汉族"></label></div><div class="form-section-title"><span>02</span><div><h2>学校与班级</h2><p>学校和班级决定资料审批范围。</p></div></div><div class="form-grid"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}" ${profile?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请选择班级</option>${classes.filter(item => item.schoolId === profile?.schoolId).map(item => `<option value="${h(item.id)}" ${profile?.classId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div><div class="form-section-title"><span>03</span><div><h2>家庭与联系信息</h2><p>用于考试通知、身份复核和紧急联系。</p></div></div><div class="form-grid"><label><span>手机号 *</span><input name="phone" required value="${h(profile?.phone)}"></label><label><span>电子邮箱 *</span><input name="email" type="email" required value="${h(profile?.email)}"></label><label class="wide-field"><span>家庭住址 *</span><input name="address" required value="${h(profile?.address)}" placeholder="请填写省、市、区及详细门牌"></label><label><span>邮政编码</span><input name="postalCode" value="${h(profile?.postalCode)}"></label><label><span>监护人姓名</span><input name="guardianName" value="${h(profile?.guardianName)}"></label><label><span>监护人电话</span><input name="guardianPhone" value="${h(profile?.guardianPhone)}"></label><label><span>紧急联系人</span><input name="emergencyContact" value="${h(profile?.emergencyContact)}"></label><label><span>紧急联系电话</span><input name="emergencyPhone" value="${h(profile?.emergencyPhone)}"></label></div>${profile?.reviewNote ? `<div class="review-note ${profile.status}"><strong>审核意见</strong><p>${h(profile.reviewNote)}</p></div>` : ''}<div class="form-actions"><p>${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}</p><button class="solid-button" type="submit">${onboarding ? '提交个人信息' : '保存并提交审批'}</button></div></form></section>`;
}
function candidateExams(data) {
return `<div class="exam-application-list">${data.exams.map(exam => `<article class="apply-card ${exam.registration ? 'registered' : ''}"><header><div><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</div><small>${exam.registrationCount || 0} 人已报名</small></header><div class="apply-card-main"><div class="apply-copy"><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名期限</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>计分规则</dt><dd>总分 ${h(exam.totalScore)} · ${h(passPolicyText(exam))}</dd></div><div><dt>考点安排</dt><dd>${h(exam.location)}</dd></div></dl></div><form class="subject-selector" data-form="exam-registration"><input type="hidden" name="examId" value="${h(exam.id)}"><div class="subject-title"><strong>选择报考科目</strong><span>可多选</span></div><div class="subject-options">${exam.subjects.map(subject => `<label><input type="checkbox" name="subjectIds" value="${h(subject.id)}" ${exam.registration?.subjectIds.includes(subject.id) ? 'checked disabled' : ''}><span><i>${h(subject.name.slice(0, 1))}</i><b>${h(subject.name)}</b><small>${h(subject.date)} · ${h(subject.start)} · 满分 ${h(subject.fullScore)}</small><em>${money(subject.fee)}</em></span></label>`).join('') || '<p class="empty-state">科目安排尚未发布</p>'}</div>${exam.registration ? `<div class="registered-banner">${icons.check}<span>已提交报名 · ${exam.registration.subjectIds.length} 个科目</span>${badge(exam.registration.status)}</div>` : `<div class="subject-total"><span>已选 <b data-subject-count>0</b> 科</span><strong data-subject-fee>满分 0 · ¥0.00</strong></div><button class="solid-button" type="submit" ${exam.registrationState !== 'open' || data.profileStatus !== 'approved' || !exam.subjects.length ? 'disabled' : ''}>${data.profileStatus !== 'approved' ? '资料审核通过后可报名' : exam.registrationState === 'open' ? '提交考试报名' : statusLabels[exam.registrationState]}</button>`}</form></div></article>`).join('')}</div>`;
}
function candidateRegistrations(registrations) {
if (!registrations.length) return emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名');
const card = reg => `<article class="registration-card"><header><div><span class="exam-code">${h(reg.exam.code)}</span><h2>${h(reg.exam.name)}</h2></div>${badge(reg.exam.archivedAt ? 'archived' : reg.status)}</header><div class="registration-info"><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || state.user.candidateNumber)}</dd></div><div><dt>当前审批</dt><dd>${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}</dd></div><div><dt>应缴金额</dt><dd>${money(reg.amountDue || 0)}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}${reg.paidAt ? `<small>${formatDate(reg.paidAt, true)} · ${h(reg.paidByName || '班级负责人')}</small>` : ''}</dd></div></dl><div class="selected-subjects"><strong>已选科目</strong><div>${reg.subjects.map(subject => `<span>${h(subject.name)}<small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('')}</div></div></div><footer><p>${reg.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : reg.paymentStatus === 'unpaid' ? '报名已通过,请线下完成缴费并等待班级负责人确认。' : '缴费已经确认,请留意准考证下载通知。'}</p>${reg.admitCard && !reg.exam.archivedAt ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`;
const current = registrations.filter(reg => !reg.exam.archivedAt);
const archived = registrations.filter(reg => reg.exam.archivedAt);
return `<div class="registration-cards">${current.map(card).join('')}</div>${archived.length ? `<details class="candidate-archive-fold"><summary><span><strong>历史报名记录</strong><small>${archived.length} 场归档考试 · 点击查阅</small></span><b>${archived.length}</b></summary><div class="registration-cards">${archived.map(card).join('')}</div></details>` : ''}`;
}
function candidateAdmit(registrations) {
const cards = registrations.filter(reg => reg.admitCard);
return cards.length ? `<div class="admit-list">${cards.map(reg => {
const now = Date.now();
const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime();
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
const subjectRows = reg.subjects.map(subject => {
const assignment = assignments.get(subject.id) || {};
return `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · ${h(assignment.examRoomCode || '')} · ${h(assignment.roomName || assignment.room || '')}${h(assignment.roomCode || '')}· ${h(assignment.building || '')} ${h(assignment.floor || '')} · ${h(assignment.seat || '')}</small></span>`;
}).join('');
const ticket = `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(reg.exam.archivedAt ? 'archived' : open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd><strong>${h(reg.admitCard.testCenter)}</strong><small>${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}</small></dd></div><div><dt>逐科详细安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open && !reg.exam.archivedAt ? '' : 'disabled'}>${reg.exam.archivedAt ? '已归档' : open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>${reg.exam.archivedAt ? '历史准考证仅供查阅' : '下载后请使用 A4 纸横向打印'}</small></div></article>`;
return reg.exam.archivedAt ? `<details class="candidate-archive-fold admit-archive-fold"><summary><span><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)} · ${formatDate(reg.exam.archivedAt, true)} 归档</small></span><b>查看历史准考证</b></summary>${ticket}</details>` : ticket;
}).join('')}</div>` : emptyState('', '', 'candidate/registrations', '');
}
function candidateResults(data) {
const { results, summaries = [] } = data;
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examId) : results.reduce((acc, item) => ((acc[item.examId] ||= []).push(item), acc), {});
const completeSummaries = summaries.filter(item => item.complete);
const overview = `<section class="candidate-result-overview"><article><small>已发布考试</small><strong>${Object.keys(grouped).length}</strong><span>场</span></article><article><small>已发布科目</small><strong>${results.length}</strong><span>科</span></article><article><small>整场已合格</small><strong>${completeSummaries.filter(item => item.qualified === true).length}</strong><span>场</span></article><article><small>复议处理中</small><strong>${results.filter(item => item.appeal?.status === 'pending').length}</strong><span>项</span></article></section>`;
return `${overview}<div class="result-groups">${Object.entries(grouped).sort(([, left], [, right]) => new Date(right[0]?.examStart || 0) - new Date(left[0]?.examStart || 0)).map(([, items]) => {
const examName = items[0].examName;
const summary = summaries.find(item => item.examId === items[0].examId);
const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线';
const detail = summary?.passPolicy === 'rank_percent' && summary.complete ? `${summary.rank} / ${summary.cohortSize}` : summary ? passPolicyText(summary) : '';
const scores = items.map(item => {
const appeal = item.appeal;
const latestAction = appeal?.actions?.at(-1);
const appealPanel = item.archivedAt
? `<div class="score-appeal-state archived-score-lock">${badge('archived')}<small>本场成绩已永久锁定,复议入口已关闭</small></div>`
: appeal?.status === 'pending'
? `<div class="score-appeal-state">${badge('pending')}<small>${h(appeal.currentStepDetail?.name || '等待处理')} · ${h(appeal.assignee?.displayName || '待分配')}</small></div>`
: appeal?.status === 'approved'
? `<div class="score-appeal-state">${badge('approved')}<small>${h(latestAction?.note || '复议流程已完成')}</small></div>`
: `${appeal ? `<div class="score-appeal-state">${badge('rejected')}<small>${h(latestAction?.note || '可补充理由后重新提交')}</small></div>` : ''}<form class="score-appeal-form" data-form="score-appeal"><input type="hidden" name="resultId" value="${h(item.id)}"><textarea name="reason" rows="2" minlength="5" maxlength="500" required placeholder="填写成绩复议理由(至少 5 个字)"></textarea><button class="row-action primary" type="submit">${appeal ? '重新申请复议' : '申请成绩复议'}</button></form>`;
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
return `<article class="${lineState}"><div class="score-subject-head"><span>${h(item.subjectName)}</span><i>${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}</i></div><strong>${h(item.score)}<small> / ${h(item.fullScore)}</small></strong><em>${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%</em><div class="rank-rule-line"><span>本科排名</span><b>${h(item.passText || '不设单科线')}</b></div>${appealPanel}</article>`;
}).join('');
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} ` : ` ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small></small><strong>${h(summary?.total ?? '')}<em> / ${h(summary?.fullScore ?? '')}</em></strong><i></i></span><span><small></small><strong>${h(summary?.featureScore ?? 0)}</strong><i></i></span><span><small></small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small></small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} </em></strong><i>${summary?.complete ? '' : ''}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '' : ''}</p><div class="result-footer-actions"><strong> ${items.length} </strong><button class="solid-button" data-action="download-score-report" data-exam-id="${h(items[0].examId)}"> PDF </button></div></footer></section>`;
return items[0].archivedAt ? `<details class="candidate-archive-fold result-archive-fold"><summary><span><strong>${h(examName)}</strong><small>${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定</small></span><b>历史成绩</b></summary>${panel}</details>` : panel;
}).join('')}</div>`;
}
function candidateAdmissions(data) {
const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', reporting: '考生报到中', supplementary: '补录填报中', completed: '录取结束' };
if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
const notificationCards = (data.notifications || []).map(notification => {
const invalid = ['withdrawn', 'forfeited'].includes(notification.placementStatus);
return `<article class="admission-notification ${invalid ? 'invalid' : ''}"><div class="admission-notification-mark"><span>ADMISSION</span><strong>${invalid ? '失' : '录'}</strong></div><div class="admission-notification-copy"><header><div><span>${invalid ? '录取状态已更新' : '录取结果已发布'}</span><h2>${h(notification.payload?.title || '录取结果通知')}</h2></div><time>${formatDate(notification.createdAt, true)}</time></header><p>${h(notification.payload?.message || '录取结果已经发布,请核对以下信息。')}</p><dl><div><dt>录取学校</dt><dd>${h(notification.schoolName || '招生学校')}</dd></div><div><dt>招生类别</dt><dd>${h(notification.categoryName || '以录取通知书为准')}</dd></div><div><dt>所属考试</dt><dd>${h(notification.examName || '—')}</dd></div>${notification.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(notification.noticeNumber)}</dd></div>` : ''}</dl></div><span class="admission-notification-status">${invalid ? '资格已失效' : '正式录取'}</span></article>`;
}).join('');
return `${notificationCards ? `<section class="admission-notification-stack" aria-label="录取结果通知">${notificationCards}</section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
const choices = item.preference?.payload?.choices || [];
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked && item.supplementEligible !== false;
const placementSchool = item.placementSchool?.name || item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '招生学校';
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
const progressIndex = item.status === 'supplementary' ? 1 : item.status === 'reporting' ? 3 : Math.max(0, progressSteps.indexOf(item.status));
const indicatorChoice = choices.find(choice => choice.preferenceType === 'indicator') || {};
const generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator');
const indicatorEligible = item.indicatorQualification?.payload?.eligible === true;
const choiceRow = (choice, preferenceType, index) => {
const eligiblePlans = item.plans.filter(plan => plan.categories.some(category => category.preferenceTypes?.includes(preferenceType)));
const plan = eligiblePlans.find(entry => entry.schoolId === choice.schoolId);
const categoryOptions = (plan?.categories || []).filter(category => category.preferenceTypes?.includes(preferenceType) && ((preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining) > 0 || category.code === choice.categoryCode));
const disabled = preferenceType === 'indicator' && !indicatorEligible;
return `<div class="preference-choice-row ${preferenceType}" data-preference-type="${preferenceType}"><b>${preferenceType === 'indicator' ? '指标' : index + 1}</b><label><span>${preferenceType === 'indicator' ? '' : ` ${index + 1} · `}</span><select name="choiceSchool" data-action="preference-school" data-exam-id="${h(item.examId)}" ${disabled ? 'disabled' : ''}><option value="">${disabled ? '' : ''}</option>${eligiblePlans.map(entry => `<option value="${h(entry.schoolId)}" ${entry.schoolId === choice.schoolId ? 'selected' : ''}>${h(entry.schoolCode)} · ${h(entry.schoolName)}</option>`).join('')}</select></label><label><span></span><select name="choiceCategory" ${plan && !disabled ? '' : 'disabled'}><option value="">${plan ? '' : ''}</option>${categoryOptions.map(category => `<option value="${h(category.code)}" ${category.code === choice.categoryCode ? 'selected' : ''}>${h(category.name)}${category.specialtyCategory ? `${h(specialtyLabel(category.specialtyCategory, category.specialtyType))}` : ''} · ${h(preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining)}</option>`).join('')}</select></label></div>`;
};
const choiceRows = choiceRow(indicatorChoice, 'indicator', 0) + Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => choiceRow(generalChoices[index] || {}, 'general', index)).join('');
const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); const schoolCode = choice.schoolCode || plan?.schoolCode || ''; const schoolName = choice.schoolName || plan?.schoolName || choice.schoolId; const categoryName = choice.categoryName || category?.name || choice.categoryCode; return `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b><i><strong>${h(schoolName)}</strong><small>${h([schoolCode, categoryName].filter(Boolean).join(' · '))}</small></i></span>`; }).join('');
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)}`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong>${item.noticeNumber ? `<small class="mono">录取通知书编号:${h(item.noticeNumber)}</small>` : ''}<small>${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout ${item.supplementEligible === false ? 'warning' : ''}">${h(item.supplementIneligibilityReason || (item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'))}</div>`}</section>`;
}).join('')}</div>`;
}
function candidateNotices(notices) {
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
}
function accountSecurity(totp = {}) {
const account = h(state.user?.candidateNumber || state.user?.username);
const type = state.user?.role === 'candidate' ? '考生账户' : statusLabels[state.user?.adminLevel] || '管理员账户';
const password = `<section class="panel account-security-panel"><div class="account-security-copy"><span>LOGIN PASSWORD</span><h2>修改登录密码</h2><p>密码修改成功后立即生效。请使用至少 8 位、且与当前密码不同的新密码。</p><dl><div><dt>当前账号</dt><dd class="mono">${account}</dd></div><div><dt>账户类型</dt><dd>${type}</dd></div></dl></div><form class="stack-form account-password-form" data-form="account-password"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码</button></form></section>`;
const totpPanel = totp.enabled
? `<section class="panel account-security-panel totp-security-panel enabled"><div class="account-security-copy"><span>TWO-STEP VERIFICATION</span><h2>TOTP 二次验证已开启</h2><p>登录密码验证通过后,还需要输入验证器应用生成的 6 位动态验证码。</p><div class="totp-signal"><i></i><strong>保护中</strong><span>剩余 ${h(totp.recoveryCodesRemaining)} 个恢复码</span></div></div><div class="totp-security-actions"><details><summary>重新生成恢复码</summary><form class="stack-form account-password-form" data-form="totp-recovery-codes"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>动态验证码或恢复码</span><input name="code" autocomplete="one-time-code" required></label><button class="solid-button" type="submit">生成新的恢复码</button></form></details><details class="danger-details"><summary>关闭二次验证</summary><form class="stack-form account-password-form" data-form="totp-disable"><p>关闭后,账户将仅使用密码登录。</p><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>动态验证码或恢复码</span><input name="code" autocomplete="one-time-code" required></label><button class="danger-button" type="submit">确认关闭二次验证</button></form></details></div></section>`
: `<section class="panel account-security-panel totp-security-panel"><div class="account-security-copy"><span>TWO-STEP VERIFICATION</span><h2>添加 TOTP 二次验证</h2><p>使用 Microsoft Authenticator、Google Authenticator、1Password 等验证器应用扫码。即使密码泄露,没有动态验证码也无法登录。</p></div><form class="stack-form account-password-form" data-form="totp-setup"><label><span>确认当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><p class="form-hint">绑定时会显示二维码和手动密钥;验证成功后请立即保存恢复码。</p><button class="solid-button large" type="submit">开始绑定验证器</button></form></section>`;
return `<div class="account-security-stack">${password}${totpPanel}</div>`;
}
return { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity };
}
+168
View File
@@ -0,0 +1,168 @@
const A4 = { width: 2480, height: 3508 };
function roundRect(ctx, x, y, width, height, radius = 18) {
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
}
function fitText(ctx, text, maxWidth, initialSize, weight = 400) {
let size = initialSize;
do {
ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
if (ctx.measureText(String(text)).width <= maxWidth) return size;
size -= 2;
} while (size > 24);
return size;
}
function drawText(ctx, text, x, y, { size = 36, weight = 400, color = '#17213f', align = 'left', maxWidth } = {}) {
if (maxWidth) size = fitText(ctx, text, maxWidth, size, weight);
ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
ctx.fillStyle = color;
ctx.textAlign = align;
ctx.textBaseline = 'alphabetic';
ctx.fillText(String(text ?? ''), x, y, maxWidth);
}
function jpegPdf(dataUrl, width, height) {
const binary = atob(dataUrl.split(',')[1]);
const image = Uint8Array.from(binary, char => char.charCodeAt(0));
const encoder = new TextEncoder();
const chunks = [];
const offsets = [0];
let length = 0;
const add = value => { const bytes = typeof value === 'string' ? encoder.encode(value) : value; chunks.push(bytes); length += bytes.length; };
add('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');
const object = (id, body) => { offsets[id] = length; add(`${id} 0 obj\n${body}\nendobj\n`); };
object(1, '<< /Type /Catalog /Pages 2 0 R >>');
object(2, '<< /Type /Pages /Kids [3 0 R] /Count 1 >>');
object(3, '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>');
offsets[4] = length;
add(`4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.length} >>\nstream\n`);
add(image); add('\nendstream\nendobj\n');
const stream = 'q\n595.28 0 0 841.89 0 0 cm\n/Im0 Do\nQ';
object(5, `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
const xref = length;
add(`xref\n0 6\n0000000000 65535 f \n`);
for (let id = 1; id <= 5; id += 1) add(`${String(offsets[id]).padStart(10, '0')} 00000 n \n`);
add(`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`);
const output = new Uint8Array(length);
let cursor = 0;
for (const chunk of chunks) { output.set(chunk, cursor); cursor += chunk.length; }
return new Blob([output], { type: 'application/pdf' });
}
function downloadCanvasPdf(canvas, filename) {
const blob = jpegPdf(canvas.toDataURL('image/jpeg', .94), canvas.width, canvas.height);
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename.replace(/[\\/:*?"<>|]/g, '-');
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 3000);
}
function loadImage(source) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('防伪二维码加载失败'));
image.src = source;
});
}
async function drawQrCode(ctx, dataUrl, x, y, size) {
if (!dataUrl) return;
const image = await loadImage(dataUrl);
ctx.fillStyle = '#ffffff';
ctx.fillRect(x - 10, y - 10, size + 20, size + 20);
ctx.drawImage(image, x, y, size, size);
}
export async function downloadScoreReport({ organization, candidate, exam, results, summary, verificationCode, verificationUrl, verificationQr }) {
const canvas = document.createElement('canvas');
Object.assign(canvas, A4);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f5f8fb'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#14234b'; ctx.fillRect(0, 0, canvas.width, 270);
ctx.fillStyle = '#c94b45'; ctx.fillRect(170, 234, 250, 12);
drawText(ctx, organization?.name || '考试服务平台', 170, 112, { size: 42, weight: 700, color: '#ffffff' });
drawText(ctx, '考 生 成 绩 单', 170, 205, { size: 76, weight: 700, color: '#ffffff' });
drawText(ctx, exam.code, 2300, 115, { size: 32, weight: 600, color: '#9eadce', align: 'right' });
drawText(ctx, exam.name, 2300, 192, { size: 38, weight: 500, color: '#ffffff', align: 'right', maxWidth: 1120 });
const box = (x, y, w, h, fill = '#ffffff') => { roundRect(ctx, x, y, w, h, 22); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#dce4ec'; ctx.lineWidth = 2; ctx.stroke(); };
box(170, 330, 2140, 300);
const meta = [['姓名', candidate.name], ['报名号', candidate.candidateNumber], ['考试', exam.name], ['发布时间', summary?.publishedAt ? new Date(summary.publishedAt).toLocaleString('zh-CN') : '以系统记录为准']];
meta.forEach(([label, value], index) => {
const x = 225 + (index % 2) * 1050, y = 420 + Math.floor(index / 2) * 115;
drawText(ctx, label, x, y, { size: 28, color: '#77839a' });
drawText(ctx, value, x + 155, y, { size: 34, weight: 600, maxWidth: 800 });
});
box(170, 690, 2140, 320, '#eaf4f1');
const totals = [['总分', `${summary?.total ?? '—'} / ${summary?.fullScore ?? '—'}`], ['特征分', summary?.featureScore ?? 0], ['合格结论', summary?.qualified == null ? '不判定' : summary.qualified ? '合格' : '未合格'], ['发布进度', `${summary?.publishedSubjects ?? results.length} / ${summary?.subjectCount ?? results.length}`]];
totals.forEach(([label, value], index) => {
const x = 235 + index * 520;
drawText(ctx, label, x, 790, { size: 28, color: '#5d766f' });
drawText(ctx, value, x, 900, { size: 48, weight: 700, color: '#173b35', maxWidth: 440 });
});
drawText(ctx, '科目成绩与等级排名', 170, 1115, { size: 42, weight: 700 });
drawText(ctx, '等级与排名均以系统正式发布数据为准', 2310, 1115, { size: 25, color: '#7b8598', align: 'right' });
const cols = 2, gap = 34, cardW = (2140 - gap) / cols, cardH = Math.min(300, Math.max(220, (1760 - Math.ceil(results.length / cols) * 20) / Math.ceil(results.length / cols)));
results.forEach((item, index) => {
const col = index % cols, row = Math.floor(index / cols), x = 170 + col * (cardW + gap), y = 1180 + row * (cardH + 20);
box(x, y, cardW, cardH);
drawText(ctx, item.subjectName, x + 42, y + 72, { size: 38, weight: 700, maxWidth: cardW - 450 });
drawText(ctx, item.qualified == null ? '不判定' : item.qualified ? '达线' : '未达线', x + cardW - 42, y + 70, { size: 27, weight: 600, color: item.qualified === false ? '#b43d38' : '#2d7462', align: 'right' });
drawText(ctx, item.score, x + 42, y + 158, { size: 62, weight: 700 });
drawText(ctx, `/ ${item.fullScore}`, x + 190, y + 156, { size: 28, color: '#8993a6' });
drawText(ctx, `${item.grade} · 第 ${item.rank} / ${item.cohortSize} 名 · 前 ${item.rankPercent}%`, x + 42, y + 220, { size: 28, color: '#455068', maxWidth: cardW - 84 });
drawText(ctx, item.passText || '不设单科线', x + 42, y + cardH - 30, { size: 24, color: '#7c8798', maxWidth: cardW - 84 });
});
const footerY = 3100;
box(170, footerY, 2140, 235, '#f0f3f7');
drawText(ctx, '防伪查询码', 225, footerY + 70, { size: 28, color: '#6f7a8e' });
drawText(ctx, verificationCode, 225, footerY + 135, { size: 38, weight: 700, color: '#17213f' });
drawText(ctx, '登录考试服务平台,在“文书防伪查询”中输入本码核验。', 225, footerY + 188, { size: 24, color: '#667085' });
drawText(ctx, verificationUrl, 2035, footerY + 135, { size: 21, color: '#53627b', align: 'right', maxWidth: 820 });
await drawQrCode(ctx, verificationQr, 2075, footerY + 26, 180);
drawText(ctx, `生成时间 ${new Date().toLocaleString('zh-CN')}`, 2310, 3435, { size: 22, color: '#8a94a6', align: 'right' });
downloadCanvasPdf(canvas, `${exam.name}-${candidate.name}-成绩单.pdf`);
}
export async function downloadAdmissionNotice({ organization, candidate, exam, placement, school, template, verificationCode, verificationUrl, verificationQr, noticeNumber }) {
const canvas = document.createElement('canvas'); Object.assign(canvas, A4);
const ctx = canvas.getContext('2d');
const primary = template.primaryColor || '#8d2028', accent = template.accentColor || '#c9a45b';
ctx.fillStyle = '#fffdf8'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = primary; ctx.lineWidth = 10; ctx.strokeRect(90, 90, 2300, 3328);
ctx.strokeStyle = accent; ctx.lineWidth = 3; ctx.strokeRect(120, 120, 2240, 3268);
drawText(ctx, template.eyebrow || 'ADMISSION NOTICE', 1240, 350, { size: 30, weight: 600, color: accent, align: 'center' });
drawText(ctx, template.title || '录 取 通 知 书', 1240, 560, { size: 96, weight: 700, color: primary, align: 'center', maxWidth: 1950 });
drawText(ctx, school.name, 1240, 700, { size: 42, weight: 600, align: 'center', maxWidth: 1900 });
drawText(ctx, `通知书编号:${noticeNumber || placement.payload.noticeNumber || '—'}`, 2180, 835, { size: 27, weight: 600, color: '#655d53', align: 'right', maxWidth: 1250 });
drawText(ctx, `${candidate.name} 同学:`, 300, 1040, { size: 48, weight: 700 });
const body = (template.body || '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。')
.replaceAll('{{考生姓名}}', candidate.name).replaceAll('{{考试名称}}', exam.name).replaceAll('{{录取学校}}', school.name).replaceAll('{{录取类别}}', placement.payload.categoryName || '招生类别');
const lines = [];
for (const paragraph of body.split(/\n+/)) {
let line = '';
for (const char of paragraph) {
ctx.font = '400 42px "Microsoft YaHei", sans-serif';
if (ctx.measureText(line + char).width > 1840) { lines.push(line); line = char; } else line += char;
}
if (line) lines.push(line); lines.push('');
}
lines.slice(0, 13).forEach((line, index) => drawText(ctx, line, 320, 1210 + index * 82, { size: 42, color: '#332f2c' }));
drawText(ctx, template.footer || '请妥善保管本通知书,报到时出示。', 300, 2550, { size: 32, color: '#6c6257', maxWidth: 1700 });
drawText(ctx, school.name, 2080, 2750, { size: 38, weight: 700, color: primary, align: 'right' });
drawText(ctx, new Date().toLocaleDateString('zh-CN'), 2080, 2820, { size: 30, color: '#5f5951', align: 'right' });
roundRect(ctx, 240, 3040, 2000, 210, 20); ctx.fillStyle = '#f4efe5'; ctx.fill();
drawText(ctx, '防伪查询码', 300, 3115, { size: 26, color: '#756b5e' });
drawText(ctx, verificationCode, 300, 3185, { size: 35, weight: 700 });
drawText(ctx, verificationUrl, 1940, 3185, { size: 20, color: '#71695f', align: 'right', maxWidth: 820 });
await drawQrCode(ctx, verificationQr, 1995, 3055, 175);
drawText(ctx, organization?.name || '考试服务平台', 1240, 3380, { size: 23, color: '#8a8177', align: 'center' });
downloadCanvasPdf(canvas, `${school.name}-${candidate.name}-录取通知书.pdf`);
}
+161
View File
@@ -0,0 +1,161 @@
import { filterTableItems } from './table-state.mjs';
export function createPublicViews(context) {
const {
state,
app,
h,
formatDate,
dateRange,
badge,
money,
passPolicyText,
statusLabels,
icons,
api,
renderError,
emptyState
} = context;
function brand() {
return `<a class="brand" href="#home" data-route="home"><span class="brand-symbol"><i></i><i></i><i></i></span><span><strong>衡准</strong><small>EXAM SERVICE</small></span></a>`;
}
function publicHeader() {
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#notices" data-route="notices">通知公告</a><a href="#verify" data-route="verify">文书防伪查询</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
}
function renderHome() {
app.classList.remove('admin-readable');
const { notices, exams, stats, organization } = state.publicData;
const siteCopy = state.publicData.siteCopy || {};
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
const topNotice = notices[0];
app.innerHTML = `${publicHeader()}<main class="public-main">
<section class="hero">
<div class="hero-grid">
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-route="notice/${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}</p><h1>${h(siteCopy.heroTitle || '一个报名号,')}<br><em>${h(siteCopy.heroHighlight || '贯穿每一次考试。')}</em></h1><p class="hero-lead">${h(siteCopy.heroDescription || '')}</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
</div>
</section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>招生录取公示已纳入通知公告,可按类别统一查询。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-route="notice/${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div><button class="notice-archive-link" data-route="notices">查看全部通知与录取公示 ${icons.arrow}</button></div></section>
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
}
function noticeDocuments(data = state.publicAnnouncements) {
const ordinary = (state.publicData.notices || []).filter(item => !String(item.id).startsWith('system-')).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt }));
const plans = (data.plans || []).map(item => ({ ...item, documentId: `plan-${item.id}`, documentType: 'plan', category: '招生公示', subtype: '招生计划', title: `${item.examName} · ${item.schoolName}招生计划公示`, summary: `${item.rows.reduce((sum, row) => sum + Number(row.quota || 0), 0)} 个招生名额,计划审核通过后由系统自动公示。` }));
const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` }));
const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: item.round ? `${item.round} 轮录取名单` : '最终录取名单', title: item.title || `${item.examName}最终录取名单`, summary: `${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` }));
const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` }));
const reports = (data.reports || []).map(item => ({ ...item, documentId: `reporting-${item.id}`, documentType: 'reporting', category: '录取公示', subtype: item.supplementDecision === 'supplement' ? '报到与补录' : '报到情况', title: item.title, summary: item.summary }));
return [...ordinary, ...plans, ...qualifications, ...admissions, ...cutoffs, ...reports].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
}
function publicPaged(items, key, pageSize = 50) {
const filtered = filterTableItems(state, items, key);
state.tablePages ||= {};
const current = state.tablePages[key] || { page: 1, pageSize };
const size = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : pageSize;
const totalPages = Math.max(1, Math.ceil(filtered.length / size));
const page = Math.min(totalPages, Math.max(1, Number(current.page || 1)));
state.tablePages[key] = { page, pageSize: size };
return { items: filtered.slice((page - 1) * size, page * size), total: filtered.length, totalPages, page, pageSize: size, key };
}
function publicPagination(meta) {
if (!meta || meta.total <= meta.pageSize) return '';
const start = (meta.page - 1) * meta.pageSize + 1;
const end = Math.min(meta.total, meta.page * meta.pageSize);
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button><button type="button" class="active" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page}">${meta.page}</button><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
}
function renderPublicQualification(document) {
const key = `publicQualification-${document.documentId}`;
const page = publicPaged(document.rows.map(row => ({ ...row, status: row.eligible ? 'eligible' : 'ineligible' })), key);
return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名或特长类型"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button data-action="status-filter" data-target="${h(key)}" data-status="eligible">有资格</button><button data-action="status-filter" data-target="${h(key)}" data-status="ineligible">无资格</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${page.items.map(row => `<tr data-status="${h(row.status)}"><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('') || '<tr><td colspan="4" class="empty-state">没有符合条件的资格记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
}
function renderPublicAdmission(document) {
const key = `publicAdmission-${document.documentId}`;
const page = publicPaged(document.rows, key);
const schools = [...new Set(document.rows.map(row => row.admittedSchool).filter(Boolean))];
const categories = [...new Set(document.rows.map(row => row.categoryName).filter(Boolean))];
return `<p class="document-lead">${document.round ? `本公示为第 ${h(document.round)} 轮录取通知书签发时生成的名单快照。` : '本公示为全部录取与报到流程结束后的最终名单。'}报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名、学校或类别"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="${h(key)}"><option value="">全部录取学校</option>${schools.map(school => `<option value="${h(school)}">${h(school)}</option>`).join('')}</select><select data-table-filter="category" data-target="${h(key)}"><option value="">全部录取类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="${h(key)}">清除筛选</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${page.items.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的录取记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
}
function renderDocumentBody(document) {
if (document.documentType === 'notice') return `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`;
if (document.documentType === 'plan') return `<p class="document-lead">招生计划经考试中心审核通过后由系统自动公示。计划人数包含普通计划与定向指标,具体执行以本公示为准。</p><div class="table-scroll"><table><thead><tr><th>类别代码</th><th>招生类别</th><th>计划人数</th><th>其中定向指标</th><th>指标分配</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.code)}</td><td><strong>${h(row.name)}</strong><small>${h(row.specialtyLabel || '普通 / 政策类')}</small></td><td><strong>${h(row.quota)} 人</strong></td><td>${h(row.indicatorQuota || 0)} 人</td><td>${row.indicatorAllocations?.length ? row.indicatorAllocations.map(allocation => `<span>${h(allocation.sourceSchoolName)} ${h(allocation.quota)} 人</span>`).join('<br>') : '无定向指标'}</td></tr>`).join('')}</tbody></table></div>${document.note ? `<p class="document-note"><strong>计划说明:</strong>${h(document.note)}</p>` : ''}`;
if (document.documentType === 'qualification') return renderPublicQualification(document);
if (document.documentType === 'admission') return renderPublicAdmission(document);
if (document.documentType === 'reporting') {
const stats = document.statistics || {};
return `<p class="document-lead">本公示由招生学校提交报到情况和补录决定,经超级管理员审批后自动发布。</p><div class="reporting-public-stats"><article><span>招生计划</span><strong>${h(stats.totalQuota || 0)}</strong><small>人</small></article><article><span>正式录取</span><strong>${h(stats.finalCount || 0)}</strong><small>人</small></article><article><span>已报到</span><strong>${h(stats.reportedCount || 0)}</strong><small>人</small></article><article><span>计划完成率</span><strong>${h(stats.reportingRate || 0)}%</strong><small>按实际报到</small></article></div><p class="document-note"><strong>学校说明:</strong>${h(document.decisionNote || (document.supplementDecision === 'supplement' ? '学校申请补录并已获批准。' : '本轮不进行补录。'))}</p>`;
}
return `<p class="document-lead">录取分数线为对应学校、招生类别最终录取考生的最低总成绩。</p><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${document.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div>`;
}
function renderNoticeCenter(data = state.publicAnnouncements, selectedId = '') {
app.classList.remove('admin-readable');
const documents = noticeDocuments(data);
const selected = documents.find(item => item.documentId === selectedId);
const organization = state.publicData.organization || {};
if (selected) {
app.innerHTML = `${publicHeader()}<main class="public-main notice-document-page"><div class="notice-breadcrumb"><button data-route="notices">通知公告</button><span>/</span><strong>${h(selected.subtype)}</strong></div><article class="notice-document"><header><span>${h(selected.category)} · ${h(selected.subtype)}</span><h1>${h(selected.title)}</h1><p>${formatDate(selected.publishedAt, true)}${selected.author ? ` · ${h(selected.author)}` : ''}</p></header><section>${renderDocumentBody(selected)}</section><footer><button class="ghost-button" data-route="notices">返回通知公告列表</button></footer></article></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>公开信息以本页面正式发布内容为准</span></footer>`;
return;
}
const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))];
const category = categories.includes(state.noticeCategory) ? state.noticeCategory : '全部';
const searched = filterTableItems(state, documents, 'publicNoticeDirectory');
const filtered = category === '全部' ? searched : searched.filter(item => item.category === category);
const pageSize = 8;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1)));
state.noticeCategory = category; state.noticePage = page;
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize);
app.innerHTML = `${publicHeader()}<main class="public-main notice-center-page"><section class="notice-center-hero"><div><p class="overline">PUBLIC NOTICE ARCHIVE</p><h1>通知公告</h1><p>考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。</p></div><strong>${h(documents.length)}<small>份公开文件</small></strong></section><section class="notice-center-shell"><nav class="notice-category-nav">${categories.map(item => `<button class="${item === category ? 'active' : ''}" data-action="notice-category" data-category="${h(item)}">${h(item)}<span>${item === '全部' ? documents.length : documents.filter(document => document.category === item).length}</span></button>`).join('')}</nav><div class="notice-directory"><header><div><strong>${h(category)}</strong><span>第 ${h(page)} / ${h(totalPages)} 页</span></div><small>共 ${h(filtered.length)} 条</small></header><div class="data-toolbar notice-directory-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="publicNoticeDirectory" placeholder="搜索全部通知、公示标题、分类或摘要"></label><button class="row-action" data-action="clear-table-filters" data-target="publicNoticeDirectory">清除搜索</button></div><div class="notice-directory-list">${pageRows.map(item => `<button data-route="notice/${h(item.documentId)}"><time><strong>${String(new Date(item.publishedAt).getDate()).padStart(2,'0')}</strong><span>${new Date(item.publishedAt).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit'}).replace('/','.')}</span></time><span class="notice-directory-copy"><em>${h(item.subtype)}</em><strong>${h(item.title)}</strong><small>${h(item.summary || '')}</small></span><span class="notice-directory-arrow">${icons.arrow}</span></button>`).join('') || '<div class="empty-state">当前分类暂无公开信息</div>'}</div><footer class="notice-pagination"><button data-action="notice-page" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>${Array.from({length:totalPages},(_,index) => index + 1).map(value => `<button class="${value === page ? 'active' : ''}" data-action="notice-page" data-page="${value}">${value}</button>`).join('')}<button data-action="notice-page" data-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}></button></footer></div></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span></span></footer>`;
}
function renderHeroTicket(exam) {
const status = exam.registrationState;
return `<article class="hero-ticket"><div class="ticket-main"><header><span>${badge(status)}</span><small>${h(exam.code)}</small></header><p>UPCOMING EXAM</p><h2>${h(exam.name)}</h2><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(exam.location)}</dd></div></dl><div class="subject-chips">${exam.subjects.slice(0, 5).map(subject => `<span>${h(subject.name)}</span>`).join('')}${exam.subjects.length > 5 ? `<span>+${exam.subjects.length - 5}</span>` : ''}</div></div><div class="ticket-stub"><span>报名人数</span><strong>${h(exam.registrationCount || 0)}</strong><i></i><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${status === 'open' ? '立即报名' : '查看详情'}</button></div></article>`;
}
function renderNoticeRow(notice) {
return `<button class="notice-row" data-route="notice/${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
}
function renderPublicExam(exam) {
return `<article class="public-exam-card"><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</header><h3>${h(exam.name)}</h3><p>${h(exam.description)}</p><div class="exam-meta"><span><b>报名</b>${dateRange(exam.registrationStart, exam.registrationEnd)}</span><span><b>考试</b>${dateRange(exam.examStart, exam.examEnd)}</span><span><b>总分</b>${h(exam.totalScore)} 分 · ${h(passPolicyText(exam))}</span></div><footer><span>${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名</span><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow}</button></footer></article>`;
}
function renderAuth(kind) {
app.classList.remove('admin-readable');
const login = kind === 'login';
const selfRegistration = state.publicData.selfRegistrationEnabled;
const authNotice = login && state.authNotice ? `<div class="auth-session-notice" role="status"><strong>需要重新登录</strong><span>${h(state.authNotice)}</span></div>` : '';
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${authNotice}${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
}
function renderVerification(code = '', result = null, error = '') {
app.classList.remove('admin-readable');
const organization = state.publicData.organization || {};
const document = result?.document;
const outcome = document ? `<section class="verification-result verified"><span>✓</span><div><small>VERIFIED DOCUMENT</small><h2>文书真实有效</h2><p>该查询码由系统签发,当前数据与签发记录一致。</p></div><dl><div><dt>文书类型</dt><dd>${h(document.typeName)}</dd></div>${document.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(document.noticeNumber)}</dd></div>` : ''}<div><dt>考生</dt><dd>${h(document.candidateName)}</dd></div><div><dt>考试</dt><dd>${h(document.examName)}</dd></div>${document.schoolName ? `<div><dt>录取学校</dt><dd>${h(document.schoolName)}</dd></div>` : ''}${document.categoryName ? `<div><dt>录取类别</dt><dd>${h(document.categoryName)}</dd></div>` : ''}${document.totalScore != null ? `<div><dt>成绩摘要</dt><dd>${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}</dd></div>` : ''}<div><dt>签发时间</dt><dd>${formatDate(document.issuedAt, true)}</dd></div></dl></section>` : error ? `<section class="verification-result invalid"><span>!</span><div><small>NOT VERIFIED</small><h2>未找到有效文书</h2><p>${h(error)}</p></div></section>` : '';
app.innerHTML = `${publicHeader()}<main class="public-main verification-page"><section class="verification-hero"><div><p class="overline">DOCUMENT AUTHENTICITY</p><h1>文书防伪查询</h1><p>输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。</p></div><form data-form="document-verification"><label><span>防伪查询码</span><input name="code" value="${h(code)}" required autocomplete="off" placeholder="例如 SR-XXXXXXXXXXXXXXXXXXXXXXXX"></label><button class="solid-button" type="submit">立即核验 ${icons.arrow}</button></form></section>${outcome}<section class="verification-notice"><strong>安全提示</strong><p>查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。</p></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>系统签名实时核验</span></footer>`;
}
function loginForm() {
return `<form class="stack-form" data-form="login"><label><span>报名号 / 管理员账号</span><input name="username" autocomplete="username" required placeholder="例如 2026-HZ01-F-0001"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="首次登录请输入学校下发的初始密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`;
}
function registerForm() {
const schools = state.publicData.schools || [];
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
}
return { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification };
}
+42
View File
@@ -0,0 +1,42 @@
import { chinaRegions } from '../data/china-regions.mjs';
const option = (value, label, selected = false) => `<option value="${value}" ${selected ? 'selected' : ''}>${label}</option>`;
export function regionSelects(region = {}, { required = true, className = 'region-selects' } = {}) {
const province = chinaRegions.find(item => item.code === region.provinceCode);
const city = province?.cities.find(item => item.code === region.cityCode);
const requiredText = required ? 'required' : '';
return `<div class="${className}" data-region-group>
<label><span> / 自治区 / 直辖市${required ? ' *' : ''}</span><select name="provinceCode" data-region-level="province" ${requiredText}><option value=""></option>${chinaRegions.map(item => option(item.code, item.name, item.code === region.provinceCode)).join('')}</select></label>
<label><span> / 州${required ? ' *' : ''}</span><select name="cityCode" data-region-level="city" ${requiredText}><option value=""></option>${(province?.cities || []).map(item => option(item.code, item.name, item.code === region.cityCode)).join('')}</select></label>
<label><span> / 县${required ? ' *' : ''}</span><select name="districtCode" data-region-level="district" ${requiredText}><option value=""></option>${(city?.districts || []).map(item => option(item.code, item.name, item.code === region.districtCode)).join('')}</select></label>
</div>`;
}
export function updateRegionSelects(select) {
const group = select.closest('[data-region-group]');
if (!group) return;
const provinceSelect = group.querySelector('[name="provinceCode"]');
const citySelect = group.querySelector('[name="cityCode"]');
const districtSelect = group.querySelector('[name="districtCode"]');
const province = chinaRegions.find(item => item.code === provinceSelect?.value);
if (select.dataset.regionLevel === 'province') {
citySelect.innerHTML = `<option value="">请选择城市</option>${(province?.cities || []).map(item => option(item.code, item.name)).join('')}`;
districtSelect.innerHTML = '<option value="">请先选择城市</option>';
} else if (select.dataset.regionLevel === 'city') {
const city = province?.cities.find(item => item.code === citySelect?.value);
districtSelect.innerHTML = `<option value="">请选择区县</option>${(city?.districts || []).map(item => option(item.code, item.name)).join('')}`;
}
}
export function mountRegionSelects(root, region = {}, options = {}) {
const address = root?.querySelector('[name="address"]');
if (!address || root.querySelector('[data-region-group]')) return;
address.closest('label')?.insertAdjacentHTML('beforebegin', regionSelects(region, options));
}
export function formatRegionAddress(region = {}) {
const parts = [region.provinceName, region.cityName, region.districtName]
.filter((value, index, values) => value && value !== values[index - 1]);
return [...parts, region.address].filter(Boolean).join('');
}
+20
View File
@@ -0,0 +1,20 @@
export const state = {
user: null,
profile: null,
publicData: { organization: {}, notices: [], exams: [], stats: {} },
publicAnnouncements: { plans: [], qualifications: [], admissions: [], cutoffs: [] },
noticeCategory: '全部',
noticePage: 1,
permissions: [],
scopeLabel: '',
authNotice: '',
pageData: null,
resultExamFilter: '',
resultSubjectFilter: '',
resultExamCatalog: null,
resultImportPreview: null,
reportingImportSummaries: {},
tablePages: {},
tableFilters: {},
loading: false
};
+44
View File
@@ -0,0 +1,44 @@
function controlState(state, key) {
state.tableFilters ||= {};
return state.tableFilters[key] ||= { query: '', status: 'all', filters: {} };
}
function searchable(value) {
if (value == null) return '';
if (Array.isArray(value)) return value.map(searchable).join(' ');
if (typeof value === 'object') return Object.values(value).map(searchable).join(' ');
return String(value);
}
function statusTokens(item) {
const tokens = [item?.status, item?.paymentStatus];
if (typeof item?.active === 'boolean') tokens.push(item.active ? 'active approved' : 'inactive disabled closed');
if (typeof item?.published === 'boolean') tokens.push(item.published ? 'published visible' : 'draft hidden');
if (typeof item?.qualified === 'boolean') tokens.push(item.qualified ? 'qualified' : 'unqualified');
if (typeof item?.confirmed === 'boolean') tokens.push(item.confirmed ? (item.eligible ? 'confirmed eligible' : 'confirmed ineligible') : 'unconfirmed');
return tokens.filter(Boolean).join(' ').toLowerCase();
}
export function filterTableItems(state, items, key) {
const control = controlState(state, key);
const query = String(control.query || '').trim().toLocaleLowerCase('zh-CN');
const status = String(control.status || 'all').toLowerCase();
const filters = Object.values(control.filters || {}).filter(Boolean).map(value => String(value).toLocaleLowerCase('zh-CN'));
return (items || []).filter(item => {
const haystack = searchable(item).toLocaleLowerCase('zh-CN');
if (query && !query.split(/\s+/).every(word => haystack.includes(word))) return false;
if (status !== 'all' && !statusTokens(item).split(/\s+/).includes(status)) return false;
return filters.every(value => haystack.includes(value));
});
}
export function setTableControl(state, key, patch) {
const current = controlState(state, key);
Object.assign(current, patch);
if (patch.filters) current.filters = { ...(current.filters || {}), ...patch.filters };
if (state.tablePages?.[key]) state.tablePages[key].page = 1;
}
export function getTableControl(state, key) {
return controlState(state, key);
}
+58
View File
@@ -0,0 +1,58 @@
export const statusLabels = {
pending: '待审核', approved: '已通过', rejected: '需修改',
published: '已发布', visible: '已显示', hidden: '已隐藏', draft: '草稿', closed: '已结束', archived: '已归档',
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
super: '超级管理员', school: '校级管理员', class: '班级管理员'
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
reporting: '考生报到中', supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', forfeited: '未报到失效', final: '正式录取', unread: '未读', submitted: '已提交', pending_approval: '待审批'
};
export const icons = {
home: '<svg viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5v8a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
user: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4.5 21a7.5 7.5 0 0 1 15 0"/></svg>',
exam: '<svg viewBox="0 0 24 24"><path d="M6 3h12v18H6zM9 8h6M9 12h6M9 16h4"/></svg>',
ticket: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 0 0 4v6h18v-6a2 2 0 0 0 0-4V5H3zM8 5v12"/></svg>',
chart: '<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>',
bell: '<svg viewBox="0 0 24 24"><path d="M18 9a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 22h4"/></svg>',
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 4a4 4 0 0 1 0 8M18 15a6 6 0 0 1 4 6"/></svg>',
check: '<svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>',
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
logout: '<svg viewBox="0 0 24 24"><path d="M14 8V4H4v16h10v-4M10 12h11M18 9l3 3-3 3"/></svg>',
menu: '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m16 16 5 5"/></svg>',
arrow: '<svg viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>'
};
export function h(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));
}
export function passPolicyText(exam) {
const value = Number(exam?.passValue ?? 60);
return {
fixed_score: `总分达到 ${value}`,
score_ratio: `总成绩排名前 ${value}%`,
rank_percent: `总成绩排名前 ${value}%`,
subject_scores: '所有报考科目均达单科线',
none: '仅发布成绩,不判定合格'
}[exam?.passPolicy || 'rank_percent'];
}
export function formatDate(value, withTime = false) {
if (!value) return '待定';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return h(value);
return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
}
export function dateRange(start, end) {
return `${formatDate(start)}${formatDate(end)}`;
}
export function badge(status) {
return `<span class="status status-${h(status)}">${h(statusLabels[status] || status)}</span>`;
}
export function money(value) {
return `¥${Number(value || 0).toFixed(2)}`;
}
+90
View File
@@ -0,0 +1,90 @@
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: []
};
}
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,396 @@
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;
}
+76
View File
@@ -0,0 +1,76 @@
export const specialtyCatalog = Object.freeze([
Object.freeze({
code: 'sports',
name: '体育',
types: Object.freeze([
Object.freeze({ code: 'track_field', name: '田径' }),
Object.freeze({ code: 'basketball', name: '篮球' }),
Object.freeze({ code: 'football', name: '足球' }),
Object.freeze({ code: 'volleyball', name: '排球' }),
Object.freeze({ code: 'table_tennis', name: '乒乓球' }),
Object.freeze({ code: 'badminton', name: '羽毛球' }),
Object.freeze({ code: 'swimming', name: '游泳' }),
Object.freeze({ code: 'martial_arts', name: '武术' }),
Object.freeze({ code: 'aerobics_cheer', name: '健美操与啦啦操' })
])
}),
Object.freeze({
code: 'arts',
name: '艺术',
types: Object.freeze([
Object.freeze({ code: 'vocal_music', name: '声乐' }),
Object.freeze({ code: 'instrumental_music', name: '器乐' }),
Object.freeze({ code: 'dance', name: '舞蹈' }),
Object.freeze({ code: 'fine_arts', name: '美术' }),
Object.freeze({ code: 'calligraphy', name: '书法' }),
Object.freeze({ code: 'drama_broadcasting', name: '戏剧与播音' })
])
})
]);
const categoryMap = new Map(specialtyCatalog.map(category => [category.code, category]));
const typeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.code, { ...type, categoryCode: category.code, categoryName: category.name }])));
const legacyTypeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.name, { category: category.code, type: type.code }])));
export function specialtyCategory(code) {
return categoryMap.get(String(code || '')) || null;
}
export function specialtyType(code) {
return typeMap.get(String(code || '')) || null;
}
export function isValidSpecialty(categoryCode, typeCode) {
if (!categoryCode && !typeCode) return true;
const category = specialtyCategory(categoryCode);
const type = specialtyType(typeCode);
return Boolean(category && type && type.categoryCode === category.code);
}
export function resolveProfileSpecialty(profile = {}) {
if (isValidSpecialty(profile.specialtyCategory, profile.specialtyType) && profile.specialtyCategory) {
return { category: profile.specialtyCategory, type: profile.specialtyType };
}
const legacy = (Array.isArray(profile.specialtyTypes) ? profile.specialtyTypes : []).map(value => legacyTypeMap.get(String(value))).find(Boolean);
return legacy || { category: '', type: '' };
}
export function specialtyLabel(categoryCode, typeCode) {
const category = specialtyCategory(categoryCode);
const type = specialtyType(typeCode);
if (!category) {
const legacy = legacyTypeMap.get(String(typeCode || ''));
return legacy ? specialtyLabel(legacy.category, legacy.type) : '';
}
return type?.categoryCode === category.code ? `${category.name}·${type.name}` : category.name;
}
export function candidateEligibleForCategory(profile, category) {
const legacy = !category?.specialtyCategory ? legacyTypeMap.get(String(category?.specialtyType || '')) : null;
const requiredCategory = category?.specialtyCategory || legacy?.category || '';
const requiredType = legacy?.type || category?.specialtyType || '';
if (!requiredCategory) return true;
const qualification = resolveProfileSpecialty(profile);
if (qualification.category !== requiredCategory) return false;
return !requiredType || qualification.type === requiredType;
}
+455
View File
@@ -0,0 +1,455 @@
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
@@ -0,0 +1,324 @@
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);
}
}
File diff suppressed because it is too large Load Diff
+526
View File
@@ -0,0 +1,526 @@
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
@@ -0,0 +1,55 @@
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
@@ -0,0 +1 @@
export const CURRENT_SCHEMA_VERSION = 20;
+46
View File
@@ -0,0 +1,46 @@
export function sendJson(response, status, payload, headers = {}) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
response.end(JSON.stringify(payload));
}
export function sendError(response, status, message, details) {
sendJson(response, status, { ok: false, message, ...(details ? { details } : {}) });
}
export async function readJson(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 1024 * 1024) throw Object.assign(new Error('请求内容过大'), { status: 413 });
chunks.push(chunk);
}
if (!chunks.length) return {};
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw Object.assign(new Error('请求数据格式不正确'), { status: 400 });
}
}
export async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 });
chunks.push(chunk);
}
if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 });
return Buffer.concat(chunks);
}
export function sendWorkbook(response, buffer, filename) {
response.writeHead(200, {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
'Content-Length': buffer.length,
'Cache-Control': 'no-store'
});
response.end(buffer);
}
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
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
@@ -0,0 +1,270 @@
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
@@ -0,0 +1,346 @@
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
@@ -0,0 +1,146 @@
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
@@ -0,0 +1,259 @@
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
@@ -0,0 +1,40 @@
export const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' };
export const permissionsByLevel = {
super: ['*'],
school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', '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
@@ -0,0 +1,28 @@
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
@@ -0,0 +1,94 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,121 @@
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
@@ -0,0 +1,276 @@
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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,350 @@
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';
+1110
View File
File diff suppressed because one or more lines are too long
+136
View File
@@ -0,0 +1,136 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,209 @@
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
@@ -0,0 +1,129 @@
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
@@ -0,0 +1,57 @@
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
@@ -0,0 +1,44 @@
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('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');
+48
View File
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,41 @@
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