项目迁移
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
|
```
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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 开发与生产环境变量模板
|
||||||
|
```
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+1596
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||||
Generated
+4355
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
Vendored
+181
@@ -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
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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('');
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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)}`;
|
||||||
|
}
|
||||||
@@ -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
@@ -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('');
|
||||||
|
}
|
||||||
@@ -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: '教学楼 A:001、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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const CURRENT_SCHEMA_VERSION = 20;
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { admissionRecords, admissionRoundPublications, admissionSetting } from './volunteer-admission.mjs';
|
||||||
|
|
||||||
|
const h = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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));
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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('志愿投档、指标名额与脱敏公示测试通过');
|
||||||
@@ -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');
|
||||||
@@ -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 临时状态');
|
||||||
@@ -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 缓存测试通过');
|
||||||
@@ -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('客户端登录失效处理测试通过');
|
||||||
@@ -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('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');
|
||||||
@@ -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('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过');
|
||||||
@@ -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
Reference in New Issue
Block a user