本轮迁移已完成,生产运行链路现已切换为纯 ASP.NET Core 10。

主要完成:
补齐招生学校计划、投档审核、报到、扫码、补录等原生接口。
新增 SQLite/MySQL 空库初始化及默认审批流、号码规则。
删除 Node 兼容代理,未知 API 直接返回原生 404。
Docker、Compose、Gitea CI 全部切换到 Eis.Web.dll。
CKEditor 已固化到 Web 发布资源,不再依赖 node_modules。
新增纯 .NET 冒烟脚本:[smoke-dotnet-native.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/smoke-dotnet-native.ps1:1)。
迁移状态已更新:[MIGRATION.md (line 14)](C:/Users/BI/Documents/EIS-dotnet/MIGRATION.md:14)。
容器入口见 [Dockerfile (line 31)](C:/Users/BI/Documents/EIS-dotnet/Dockerfile:31)。
This commit is contained in:
2026-07-23 15:14:42 +08:00 Unverified
parent fea91a19af
commit e3c04dad8a
36 changed files with 1449 additions and 491 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码
PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
# 可选 Redis;容器外的 Redis 不应填写 127.0.0.1。
# 普通接口缓存使用 DB 0,认证状态会自动使用独立 DB 1。
# 普通接口缓存使用 DB 0,认证状态会自动使用独立 DB 1;单实例可不配置 Redis
# REDIS_URL=redis://redis-host:6379/0
# REDIS_SESSION_DB=1
# 也可让认证状态使用另一台 Redis(此时 URL 中可指定自己的逻辑 DB)。
+4 -39
View File
@@ -1,9 +1,8 @@
# 本地开发(默认)
NODE_ENV=development
ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://127.0.0.1:4173
DATABASE_CLIENT=sqlite
SQLITE_PATH=./data/exam.sqlite
HOST=127.0.0.1
PORT=4173
# 可选 Redis。普通接口缓存使用 DB 0;配置 REDIS_URL 后,登录状态默认自动使用独立的 DB 1。
# 未设置任何 Redis 地址时,接口缓存和认证状态均使用本机内存模式。
@@ -20,40 +19,6 @@ PORT=4173
# 如需让认证状态使用另一台 Redis,可设置独立地址;URL 中可直接指定逻辑 DB。
# REDIS_SESSION_URL=rediss://session-redis.example.com:6379/1
# 原生 ASP.NET Core 认证切换。迁移期间默认关闭;生产环境开启时必须配置共享 Redis,
# 以便尚未迁移的 Node 受保护接口识别由 ASP.NET Core 创建的会话。
AUTH_NATIVE_ENABLED=false
# 第一批考生只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 同时使用。
CANDIDATE_NATIVE_ENABLED=false
# 第一批管理端只读接口切换;必须与 AUTH_NATIVE_ENABLED=true 及共享 Redis 同时使用。
ADMIN_NATIVE_READS_ENABLED=false
# 学校、班级、管理员维护及自主注册开关切换;同样要求原生认证和共享 Redis。
ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED=false
# 批量报名号申领、审批与账号生成;必须同时启用管理端只读接口。
ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED=false
# 报名号规则与审批流程定义维护;必须同时启用管理端只读接口。
ADMIN_NATIVE_CONFIGURATION_ENABLED=false
# 通知公告列表、手工公告创建修改及系统公示显隐控制;必须同时启用管理端只读接口。
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false
# 考点考场读取、变更提交及审批;必须同时启用管理端只读接口。
ADMIN_NATIVE_CENTERS_ENABLED=false
# 考生档案、报名记录与缴费记录读取;必须同时启用管理端只读接口。
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=false
# 考生账户归档、密码重置及资料流程审核;必须同时启用考生/报名/缴费读取。
ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED=false
# 报名审核与缴费状态更新;必须同时启用考生/报名/缴费读取。
ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED=false
# 工作流收件箱、同级转交与超级管理员监督调整;必须同时启用管理端基础读取。
ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED=false
# 考试创建、修改、发布状态及不可逆归档;必须同时启用管理端基础读取。
ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED=false
# 准考证全场预检、编排应用、批量打印及 Excel 考务材料;必须同时启用管理端基础读取。
ADMIN_NATIVE_ARRANGEMENTS_ENABLED=false
# 成绩工作区、成绩录入发布、特征分、复议及成绩台账导出;必须同时启用管理端基础读取和业务读取。
ADMIN_NATIVE_RESULTS_ENABLED=false
# 指标资格、招生账户、志愿设置、计划审核、投档录取、报到补录与退档监督。
ADMIN_NATIVE_ADMISSIONS_ENABLED=false
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=Admin123!
@@ -78,7 +43,7 @@ PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码
PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
# MySQL 8.4 生产环境:将 DATABASE_CLIENT 改为 mysql,并配置以下变量。
# NODE_ENV=production
# ASPNETCORE_ENVIRONMENT=Production
# DATABASE_CLIENT=mysql
# MYSQL_HOST=127.0.0.1
# MYSQL_PORT=3306
@@ -86,7 +51,7 @@ PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
# MYSQL_PASSWORD=replace-with-a-strong-password
# MYSQL_DATABASE=exam_information
# MYSQL_CONNECTION_LIMIT=10
# HOST=0.0.0.0
# ASPNETCORE_URLS=http://0.0.0.0:4173
# 也可以用单个连接地址替代全部 MYSQL_* 连接参数:
# DATABASE_URL=mysql://exam_app:password@127.0.0.1:3306/exam_information
+4 -7
View File
@@ -18,16 +18,13 @@ jobs:
- name: Check out repository
uses: https://github.com/actions/checkout@v4
- name: Set up Node.js
uses: https://github.com/actions/setup-node@v4
- name: Set up .NET
uses: https://github.com/actions/setup-dotnet@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
dotnet-version: "10.0.x"
- name: Run tests
run: npm test
run: dotnet test Eis.slnx --configuration Release
- name: Set up QEMU
uses: https://github.com/docker/setup-qemu-action@v3
+19 -13
View File
@@ -1,25 +1,31 @@
FROM node:22-bookworm-slim
FROM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build
WORKDIR /src
COPY . .
RUN dotnet restore src/Eis.Web/Eis.Web.csproj \
&& dotnet publish src/Eis.Web/Eis.Web.csproj --configuration Release --output /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install --yes --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build --chown=$APP_UID:$APP_UID /app/publish .
RUN mkdir -p /app/data \
&& chown -R $APP_UID:$APP_UID /app/data
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 \
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://0.0.0.0:4173 \
DATABASE_CLIENT=sqlite \
SQLITE_PATH=/app/data/exam.sqlite
EXPOSE 4173
VOLUME ["/app/data"]
USER node
USER $APP_UID
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 ["curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:4173/health/live"]
CMD ["node", "server.mjs"]
ENTRYPOINT ["dotnet", "Eis.Web.dll"]
+30 -73
View File
@@ -1,92 +1,49 @@
# ASP.NET Core 10 迁移
迁移采用兼容优先的渐进式方案:ASP.NET Core 作为统一入口,尚未迁移的 `/api/*` 请求暂时转发给运行在 `4174` 端口的 Node.js 服务。每完成一个功能域,就在 ASP.NET Core 中注册对应原生端点并停止转发该路径
系统业务 API、认证、管理后台、审批流、考务编排、招生录取、Excel、文书和缓存均已迁移到 ASP.NET Core 10。生产宿主和容器不再启动或转发到 Node.js;旧 `.mjs` 服务端源码仅保留作历史行为对照
## 当前阶段
## 完成情况
- [x] ASP.NET Core 10 解决方案与分层项目
- [x] 原前端静态资源无修改托管
- [x] 旧 API 兼容转发,包含 Cookie、请求体、文件下载和状态码
- [x] 存活与迁移就绪检查
- [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取)
- [x] 招生公示与 HMAC 文书验真公开接口
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
- [x] 考生业务
- [x] 管理后台、审批流和考务编排
- [x] 考生志愿填报与招生录取查询
- [x] Excel、文书和缓存
- [ ] 容器入口切换及 Node.js 后端移除
- [x] ASP.NET Core 10 分层解决方案与前端静态资源托管
- [x] 公开首页、公告、招生公示和 HMAC 文书验真
- [x] 登录、自主注册、Session、TOTP 和考生中心
- [x] 管理后台、审批流、考务编排和成绩管理
- [x] 指标资格、招生账户、志愿、计划、投档、录取、报到和退档
- [x] ClosedXML 文件导入导出、文书模板和 Redis/本机缓存
- [x] SQLite / MySQL 自动建库和基础配置初始化
- [x] 容器入口切换及 Node.js 后端运行依赖移除
## 本地运行
先在一个终端运行旧 API
```powershell
$env:PORT = '4174'
npm start
```
再在另一个终端运行 ASP.NET Core 入口:
需要 .NET 10 SDK
```powershell
dotnet restore .\Eis.slnx
dotnet run --project .\src\Eis.Web\Eis.Web.csproj
```
浏览器访问 <http://127.0.0.1:4173>。
浏览器访问 <http://127.0.0.1:4173>。首次启动空库时,系统会建立 v20 结构、基础规则、默认审批流程和初始超级管理员。
- `GET /health/live`只检查 ASP.NET Core 宿主。
- `GET /health/migration`检查迁移宿主和旧 API 转发链路
- `GET /health/live`ASP.NET Core 宿主存活检查
- `GET /health/migration`原生功能、认证状态后端和缓存状态;`legacyApiRemoved` 固定为 `true`
- 未注册的 `/api/*` 返回原生 `404`,不再转发旧服务。
可通过配置 `LegacyNode:Enabled=false` 禁用兼容转发;此时尚未迁移的 API 会返回 `501`
`appsettings.json` 已默认启用全部原生域,无需再设置 `AUTH_NATIVE_ENABLED``CANDIDATE_NATIVE_ENABLED``ADMIN_NATIVE_*` 迁移开关。Redis 为可选项;单实例使用进程内会话和有界本机缓存,多实例部署应配置 Redis 共享会话
认证域的 ASP.NET Core 原生实现已经覆盖 `/api/auth/*`,包括现有 PBKDF2 密码、`hz_session` Cookie、登录挑战、TOTP、防重放、恢复码和自主注册。迁移期间默认仍由 Node 处理认证;显式设置以下变量后切换到原生实现:
## 已迁移的文件与文书能力
- ClosedXML 处理班级、管理员、报名号、考生、缴费、考点考场、成绩、志愿、录取、报到和正式录取名册工作簿。
- 成绩导入先预检再确认,报到导入按招生学校和可维护批次限定范围。
- 录取通知书模板由原生接口保存;成绩单、准考证和录取通知书的数据及防伪验真均由 ASP.NET Core 提供。
- CKEditor 浏览器资源已随 Web 项目发布,不依赖 `node_modules`
## 验证
```powershell
$env:AUTH_NATIVE_ENABLED = 'true'
dotnet test .\Eis.slnx
dotnet publish .\src\Eis.Web\Eis.Web.csproj -c Release
pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\smoke-dotnet-native.ps1
docker compose up --build --detach
```
开发环境未配置 Redis 时可以使用进程内状态独立验证原生认证。生产环境以及仍需访问 Node 受保护接口的联调环境必须配置 `REDIS_URL``REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力
考生域全部端点(首页、通知、个人资料读取与提交、可报名考试、我的报名读取与提交、成绩与总分排名、成绩单防伪码、成绩复议、准考证下载、志愿填报与录取查询)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用:
```powershell
$env:AUTH_NATIVE_ENABLED = 'true'
$env:CANDIDATE_NATIVE_ENABLED = 'true'
```
管理后台第一至十三批已经覆盖基础读取、组织账户、批量建号、流程定义与收件箱、通知公示、考点审批、报名缴费、考试维护、准考证编排和成绩管理。第十四批补齐指标资格确认、招生学校账号、志愿设置、招生计划审核、分数优先投档、逐轮录取公示、报到补录审批、退档监督和成绩预览提交;管理后台、审批流及考务编排的业务 API 至此全部由 ASP.NET Core 原生处理。
Excel、文书和缓存阶段已经完成:
- ClosedXML 原生处理班级、管理员、报名号配额及结果、考生、缴费、考点考场、成绩、志愿、录取、报到和正式录取名册工作簿;模板保留必填提示、数据验证、冻结标题、筛选与数值格式。
- 成绩 Excel 上传先生成预检结果,不直接发布成绩;报到 Excel 只更新本校当前可维护批次,并返回逐行变更摘要。
- 录取通知书模板由 ASP.NET Core 原生读取与保存;成绩单、准考证和录取通知书继续由现有前端排版导出,但其业务数据、防伪码和验真接口均已脱离 Node.js。
- 公开首页和考生成绩改用 Redis 命名空间版本缓存;Redis 未配置或暂不可用时使用有界进程内缓存。考试、组织、通知、招生和成绩写入后会立即失效对应命名空间,`GET /health/migration` 会报告缓存状态。
管理端各功能域均可独立切换;在兼容运行期仍要求原生认证和共享 Redis:
```powershell
$env:AUTH_NATIVE_ENABLED = 'true'
$env:ADMIN_NATIVE_READS_ENABLED = 'true'
$env:ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED = 'true'
$env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true'
$env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
$env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
$env:ADMIN_NATIVE_CENTERS_ENABLED = 'true'
$env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
$env:ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED = 'true'
$env:ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED = 'true'
$env:ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED = 'true'
$env:ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED = 'true'
$env:ADMIN_NATIVE_ARRANGEMENTS_ENABLED = 'true'
$env:ADMIN_NATIVE_RESULTS_ENABLED = 'true'
$env:ADMIN_NATIVE_ADMISSIONS_ENABLED = 'true'
```
旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的各 `administration.native*Enabled` 字段和 `administration.nativeRoutes` 会报告这些端点是否已切换,其中成绩与招生管理域分别对应 `administration.nativeResultsEnabled``administration.nativeAdmissionsEnabled`
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
```powershell
pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\smoke-dotnet-migration.ps1
```
`scripts/smoke-dotnet-native.ps1` 会发布并启动纯 .NET 产物,在操作系统临时目录创建一次性 SQLite 数据库,检查健康状态、首页、CKEditor、原生登录和未知 API 404 后清理。`scripts/smoke-dotnet-migration.ps1` 保留为迁移期 Node/.NET 行为对照工具,不属于生产启动链路
+30 -48
View File
@@ -1,6 +1,6 @@
# 衡准 · 考试信息管理系统
一个完整可运行的分级权限考试信息管理系统,使用 Node.js 后端;本地开发采用 SQLite,生产环境支持 MySQL 8.4。
一个完整可运行的分级权限考试信息管理系统,后端使用 ASP.NET Core 10;本地开发采用 SQLite,生产环境支持 MySQL 8.4。
## 已实现功能
@@ -80,16 +80,16 @@
- 组织、学校、班级三级数据范围在服务端强制过滤
- 审批实例、当前责任人、转交和监督操作全程留痕
- 桌面端与移动端响应式布局
- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小
- Excel 文件使用 ClosedXML 生成和解析,并限制上传文件大小
- 成绩单与录取通知书以 PDF 下载,使用服务端 HMAC 防伪码支持公开验真
## 运行
需要 Node.js 22.5 或更高版本(SQLite 使用 Node.js 内置驱动)
需要 .NET 10 SDK
```powershell
npm install
npm start
dotnet restore .\Eis.slnx
dotnet run --project .\src\Eis.Web\Eis.Web.csproj
```
打开 <http://127.0.0.1:4173>。
@@ -98,7 +98,7 @@ npm start
生产环境还必须单独设置至少 32 个字符的 `DOCUMENT_VERIFICATION_SECRET`。系统用它为成绩单和录取通知书生成 HMAC 防伪查询码;更换该值会使此前下载文书的查询码失效,因此应独立生成、稳定保存且不得与 TOTP 密钥共用。
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME``INITIAL_ADMIN_PASSWORD``INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v17;v16 数据库会自动增加 TOTP 字段,低于 v15 的开发库会提示重建
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库写入系统基础配置、默认审批流程和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME``INITIAL_ADMIN_PASSWORD``INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v20
### Docker
@@ -131,11 +131,11 @@ docker compose logs --follow app
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 数据卷可以移除。
镜像仅包含 ASP.NET Core 10 运行时,默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/health/live` 执行健康检查。需要连接 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`
工作流位于 `.gitea/workflows/docker-publish.yml`。它会使用 .NET 10 运行测试,然后构建 `linux/amd64``linux/arm64` 双架构镜像,并同时推送到 Docker Hub 与 `git.biss.click/biss/exam-information-system`
使用前需要完成以下配置:
@@ -165,11 +165,11 @@ git push origin v1.3.0-rc.1
首次成功推送后,容器镜像会出现在 `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`
应用会在空库上自动创建结构和基础配置。旧版 Node 数据重置、样例数据和地区快照构建脚本仍保留为开发维护工具,不会被生产镜像复制或执行;运行这些脚本前必须明确数据库目标,且不得对生产业务库执行
## 数据库配置
应用启动时会自动读取项目根目录的 `.env`,可先运行 `Copy-Item .env.example .env` 创建配置文件。命令行或部署平台已经注入的进程环境变量优先于 `.env`。应用根据 `DATABASE_CLIENT` 使用不同数据库;未设置时,开发/测试环境默认 `sqlite``NODE_ENV=production` 默认 `mysql`
应用启动时会自动读取项目根目录的 `.env`,可先运行 `Copy-Item .env.example .env` 创建配置文件。命令行或部署平台已经注入的进程环境变量优先于 `.env`。应用根据 `DATABASE_CLIENT` 使用不同数据库;未设置时,开发/测试环境默认 `sqlite`ASP.NET Core 生产环境默认 `mysql`
公开首页的机构名称、机构代码、电话、地址、邮箱、主标语和页脚提示分别由 `PUBLIC_SITE_NAME``PUBLIC_SITE_CODE``PUBLIC_SITE_PHONE``PUBLIC_SITE_ADDRESS``PUBLIC_SITE_EMAIL``PUBLIC_SITE_HERO_*``PUBLIC_SITE_FOOTER_NOTICE` 配置。修改 `.env` 后需要重启应用;这些配置会覆盖数据库中的演示机构信息,且只通过公开首页接口返回非敏感展示字段。
@@ -178,7 +178,7 @@ git push origin v1.3.0-rc.1
```powershell
$env:DATABASE_CLIENT = 'sqlite'
$env:SQLITE_PATH = './data/exam.sqlite'
npm start
dotnet run --project .\src\Eis.Web\Eis.Web.csproj
```
`SQLITE_PATH` 可省略,默认路径就是 `./data/exam.sqlite`
@@ -217,15 +217,15 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'ex
成绩和学生资料发生变化后,专属表会自动同步;总表继续承担跨考试、跨学校查询和外键完整性约束。
```powershell
$env:NODE_ENV = 'production'
$env:ASPNETCORE_ENVIRONMENT = '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
$env:ASPNETCORE_URLS = 'http://0.0.0.0:4173'
dotnet run --project .\src\Eis.Web\Eis.Web.csproj
```
也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;将模板复制为 `.env` 后取消 MySQL 配置项的注释并填写实际连接信息即可。生产部署仍建议由部署平台注入环境变量,避免在服务器文件中保存密码。
@@ -245,7 +245,7 @@ $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
dotnet run --project .\src\Eis.Web\Eis.Web.csproj
```
生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。若 Redis Cluster 不支持非 0 逻辑 DB,请用 `REDIS_SESSION_URL` 为认证状态配置独立 Redis 实例。
@@ -325,47 +325,29 @@ MySQL 模式会自动识别由本项目生成的批量样例数据并清理。
## 自动化测试
```powershell
npm test
dotnet test .\Eis.slnx
pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\smoke-dotnet-native.ps1
```
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、三级管理员范围内缴费状态修改与名单导出、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分
xUnit 测试使用独立临时 SQLite 数据库,覆盖认证兼容性、迁移开关约束、文书防伪码、文件处理、缓存以及空库 v20 初始化;测试不会连接或修改生产数据库
## 项目结构
项目采用模块化单体架构:仍由一个 Node.js 进程部署,但 HTTP、权限、业务路由、数据库适配和前端页面按职责分
项目采用 ASP.NET Core 10 模块化单体架构由一个 `Eis.Web` 进程部署HTTP、权限、业务服务、数据库适配和前端页面按职责分
```text
index.html 页面入口
styles.css 公共首页、考生端、管理端响应式样式
app.js 前端路由、事件与表单控制器
server.mjs HTTP 服务启动、模块装配与静态文件服务
database.mjs 数据仓储与数据库模块装配
excel.mjs Excel 模板、导入解析与导出工作簿
Eis.slnx .NET 10 解决方案
src/Eis.Domain 领域模型与业务值
src/Eis.Application 应用服务契约
src/Eis.Infrastructure SQLite/MySQL、认证、缓存、Excel 与业务实现
src/Eis.Web ASP.NET Core 宿主、端点和发布静态资源
tests/Eis.Infrastructure.Tests xUnit 自动化测试
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 端到端系统测试
index.html / styles.css / app.js 前端入口、样式和控制器
src/client 公共、考生、管理及招生前端模块
src/database/schema.mjs 兼容 SQLite/MySQL 的 v20 建库定义
scripts 迁移验证与旧版开发数据维护工具
server.mjs / src/routes 仅保留的旧 Node 行为对照源码
data/exam.sqlite 本地运行后生成的 SQLite 数据库
.env.example 开发与生产环境变量模板
```
+2 -3
View File
@@ -8,9 +8,8 @@ services:
env_file:
- .env.docker
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4173
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://0.0.0.0:4173
DATABASE_CLIENT: sqlite
SQLITE_PATH: /app/data/exam.sqlite
ports:
+3 -2
View File
@@ -4,8 +4,9 @@
"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",
"start": "dotnet run --project src/Eis.Web/Eis.Web.csproj",
"test": "dotnet test Eis.slnx",
"test:legacy": "node tests/client-auth.test.mjs && node tests/api-dedup.test.mjs && node tests/cache.test.mjs && node tests/auth-state.test.mjs && node tests/state-cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
"test:cache": "node tests/cache.test.mjs",
"reset-db": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs",
+17 -1
View File
@@ -1134,13 +1134,29 @@ try {
}
}
$admissionSchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$legacyAdmissionSchoolSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$admissionSchoolLoginBody = @{ username = 'admission_1_admin'; password = '12345678' } | ConvertTo-Json -Compress
Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $admissionSchoolLoginBody -WebSession $admissionSchoolSession | Out-Null
foreach ($admissionSchoolRoute in @('notice-template', 'reporting', 'placements')) {
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $admissionSchoolLoginBody -WebSession $legacyAdmissionSchoolSession | Out-Null
foreach ($admissionSchoolRoute in @('context', 'plans', 'notice-template', 'reporting', 'placements')) {
$legacyAdmissionSchoolResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admission/$admissionSchoolRoute" -WebSession $legacyAdmissionSchoolSession
$admissionSchoolResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admission/$admissionSchoolRoute" -WebSession $admissionSchoolSession
if ($admissionSchoolResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw "Native admission-school route '$admissionSchoolRoute' did not use ASP.NET Core"
}
Assert-JsonEquivalent -Expected $legacyAdmissionSchoolResponse.Content -Actual $admissionSchoolResponse.Content -Label "Admission-school route '$admissionSchoolRoute'"
}
$invalidAdmissionSchoolPlan = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admission/plans" -Method Post -ContentType 'application/json' -Body '{"examId":"missing","categories":[]}' -WebSession $admissionSchoolSession -SkipHttpErrorCheck
if ($invalidAdmissionSchoolPlan.StatusCode -ne 404 -or $invalidAdmissionSchoolPlan.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native admission-school plan endpoint did not reject a missing exam'
}
$emptyAdmissionPlacementBulk = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admission/placements/bulk" -Method Post -ContentType 'application/json' -Body '{"ids":[],"decision":"accept"}' -WebSession $admissionSchoolSession -SkipHttpErrorCheck
if ($emptyAdmissionPlacementBulk.StatusCode -ne 400 -or $emptyAdmissionPlacementBulk.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native admission-school placement bulk endpoint accepted an empty selection'
}
$invalidAdmissionScan = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admission/reporting/scan-preview" -Method Post -ContentType 'application/json' -Body '{"code":"invalid"}' -WebSession $admissionSchoolSession -SkipHttpErrorCheck
if ($invalidAdmissionScan.StatusCode -ne 400 -or $invalidAdmissionScan.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw 'Native admission-school scan preview accepted an invalid verification code'
}
$legacyResultsAfterWrites = Invoke-WebRequest -Uri "$legacyBaseUrl$resultsUri" -WebSession $session
$nativeResultsAfterWrites = Invoke-WebRequest -Uri "$nativeAuthBaseUrl$resultsUri" -WebSession $nativeSession
+140
View File
@@ -0,0 +1,140 @@
[CmdletBinding()]
param(
[string]$PublishDirectory = (Join-Path $PSScriptRoot '..\artifacts\native-smoke-publish')
)
$ErrorActionPreference = 'Stop'
$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
$publishPath = [IO.Path]::GetFullPath(
$(if ([IO.Path]::IsPathRooted($PublishDirectory)) {
$PublishDirectory
}
else {
Join-Path $repositoryRoot $PublishDirectory
}))
dotnet publish (Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj') `
--configuration Release `
--output $publishPath `
--no-restore
if ($LASTEXITCODE -ne 0) {
throw 'ASP.NET Core 发布失败。'
}
$tempBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$testRoot = Join-Path $tempBase ('eis-native-smoke-' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $testRoot | Out-Null
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
$listener.Start()
$port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
$listener.Stop()
$baseUrl = "http://127.0.0.1:$port"
$stdoutPath = Join-Path $testRoot 'stdout.log'
$stderrPath = Join-Path $testRoot 'stderr.log'
$databasePath = Join-Path $testRoot 'eis.sqlite'
$process = $null
try {
$process = Start-Process `
-FilePath 'dotnet' `
-ArgumentList (Join-Path $publishPath 'Eis.Web.dll') `
-WorkingDirectory $publishPath `
-Environment @{
ASPNETCORE_ENVIRONMENT = 'Development'
ASPNETCORE_URLS = $baseUrl
DATABASE_CLIENT = 'sqlite'
SQLITE_PATH = $databasePath
INITIAL_ADMIN_USERNAME = 'admin'
INITIAL_ADMIN_PASSWORD = 'NativeSmoke123456'
INITIAL_ADMIN_DISPLAY_NAME = '原生冒烟管理员'
REDIS_URL = ''
REDIS_SESSION_URL = ''
} `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath `
-WindowStyle Hidden `
-PassThru
$ready = $false
for ($attempt = 0; $attempt -lt 60; $attempt++) {
if ($process.HasExited) {
break
}
try {
$live = Invoke-RestMethod -Uri "$baseUrl/health/live" -TimeoutSec 2
$ready = $true
break
}
catch {
Start-Sleep -Milliseconds 250
}
}
if (-not $ready) {
$stdout = Get-Content -Raw $stdoutPath -ErrorAction SilentlyContinue
$stderr = Get-Content -Raw $stderrPath -ErrorAction SilentlyContinue
throw "ASP.NET Core 发布产物未就绪。`nstdout: $stdout`nstderr: $stderr"
}
$migration = Invoke-RestMethod -Uri "$baseUrl/health/migration" -TimeoutSec 5
$homeResponse = Invoke-WebRequest -Uri "$baseUrl/" -TimeoutSec 5
$ckeditor = Invoke-WebRequest -Uri "$baseUrl/vendor/ckeditor5/ckeditor5.js" -TimeoutSec 10
$session = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$loginBody = @{
username = 'admin'
password = 'NativeSmoke123456'
} | ConvertTo-Json -Compress
$login = Invoke-RestMethod `
-Uri "$baseUrl/api/auth/login" `
-Method Post `
-ContentType 'application/json' `
-Body $loginBody `
-WebSession $session `
-TimeoutSec 5
$currentUser = Invoke-RestMethod `
-Uri "$baseUrl/api/auth/me" `
-WebSession $session `
-TimeoutSec 5
$missing = Invoke-WebRequest `
-Uri "$baseUrl/api/removed-node-route" `
-SkipHttpErrorCheck `
-TimeoutSec 5
if ($live.status -ne 'healthy' -or
$migration.legacyApiRemoved -ne $true -or
$homeResponse.StatusCode -ne 200 -or
$ckeditor.RawContentLength -lt 100000 -or
$login.user.role -ne 'admin' -or
$login.user.adminLevel -ne 'super' -or
$currentUser.user.username -ne 'admin' -or
$missing.StatusCode -ne 404) {
throw '原生宿主冒烟断言失败。'
}
[pscustomobject]@{
Process = $process.ProcessName
Live = $live.status
LegacyRemoved = $migration.legacyApiRemoved
HomeStatus = $homeResponse.StatusCode
CkeditorBytes = $ckeditor.RawContentLength
LoginRole = $login.user.role
CurrentUser = $currentUser.user.username
UnknownApiStatus = $missing.StatusCode
DatabaseTarget = $databasePath
} | Format-List
}
finally {
if ($null -ne $process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force
$process.WaitForExit()
}
$resolvedTestRoot = [IO.Path]::GetFullPath($testRoot)
if ($resolvedTestRoot.StartsWith($tempBase, [StringComparison]::OrdinalIgnoreCase) -and
(Test-Path -LiteralPath $resolvedTestRoot)) {
Remove-Item -LiteralPath $resolvedTestRoot -Recurse -Force
}
}
@@ -18,12 +18,22 @@ public interface IAdminAdmissionService
Task<AdminEndpointResult> ReviewReportingAsync(string sessionToken, string recordId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewWithdrawalAsync(string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolNoticeTemplateAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolContextAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolPlansAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolPlanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolNoticeTemplateAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolReportingAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAdmissionSchoolReportingAsync(string sessionToken, string examId, CancellationToken cancellationToken);
Task<AdminEndpointResult> ImportAdmissionSchoolReportingAsync(string sessionToken, string examId, byte[] content, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolReportingDraftAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> PreviewAdmissionSchoolReportingScanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolReportingScanAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SubmitAdmissionSchoolReportingAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> SaveAdmissionSchoolReportingDecisionAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> GetAdmissionSchoolPlacementsAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAdmissionSchoolPlacementsAsync(string sessionToken, string examId, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementAsync(string sessionToken, string placementId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementsAsync(string sessionToken, JsonObject body, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAdminLedgerAsync(
string sessionToken,
string kind,
@@ -0,0 +1,501 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Eis.Application.Administration;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Security;
namespace Eis.Infrastructure.Administration;
internal sealed partial class AdminAdmissionService
{
private static readonly Regex AdmissionNoticePattern =
new(@"AN-[A-F0-9]{24}", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
public async Task<AdminEndpointResult> GetAdmissionSchoolContextAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var plans = Records(data, "plan")
.Where(item => item.SchoolId == school.Id && item.Status == "approved")
.Select(item =>
{
var output = RecordJson(item);
output["examName"] = Text(data.Operational.Exams.FirstOrDefault(exam => exam.Id == item.ExamId)?.Data["name"]);
output["progress"] = PlanProgress(data, item);
return (JsonNode)output;
}).ToArray();
var home = await publicQueries.GetHomeAsync(cancellationToken);
var notifications = (home["notices"] as JsonArray ?? []).OfType<JsonObject>()
.Where(item => Text(item["schoolId"]).Length == 0 || Text(item["schoolId"]) == school.Id)
.Take(6)
.Select(item =>
{
var output = item.DeepClone().AsObject();
if (Text(output["noticeId"]).Length > 0) output["id"] = output["noticeId"]!.DeepClone();
return (JsonNode)output;
}).ToArray();
var exams = data.Operational.Exams.Where(item =>
item.Data["archivedAt"] is null &&
Records(data, "setting", item.Id).Any(setting => Boolean(setting.Payload["enabled"])))
.Select(item => (JsonNode)PublicExam(item)).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["plans"] = new JsonArray(plans),
["notifications"] = new JsonArray(notifications),
["exams"] = new JsonArray(exams)
});
}
public async Task<AdminEndpointResult> GetAdmissionSchoolPlansAsync(
string sessionToken,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var plans = Records(data, "plan").Where(item => item.SchoolId == school.Id).Select(item =>
{
var output = RecordJson(item);
output["remainingCategories"] = RemainingPlanQuota(data, item);
output["progress"] = PlanProgress(data, item);
return (JsonNode)output;
}).ToArray();
return Success(new JsonObject
{
["ok"] = true,
["school"] = SchoolJson(school),
["plans"] = new JsonArray(plans),
["exams"] = new JsonArray(data.Operational.Exams.Where(item => item.Data["archivedAt"] is null)
.Select(item => (JsonNode)PublicExam(item)).ToArray()),
["sourceSchools"] = new JsonArray(data.Directory.Schools
.Where(item => item.Active && item.IsSourceSchool)
.Select(item => (JsonNode)SchoolJson(item)).ToArray())
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolPlanAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, user);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var examId = Clean(Text(body["examId"]), 64);
var exam = data.Operational.Exams.FirstOrDefault(item =>
item.Id == examId && item.Data["archivedAt"] is null);
if (exam is null) return Error(404, "考试不存在或已经归档");
var categories = NormalizeCategories(body["categories"]);
if (categories.Count == 0) return Error(400, "请至少填写一个有效招生类别和计划人数");
if (categories.Select(item => Text(item["code"])).Distinct(StringComparer.Ordinal).Count() != categories.Count)
return Error(400, "招生类别代码不能重复");
if (categories.Any(item => !ValidSpecialty(Text(item["specialtyCategory"]), Text(item["specialtyType"]))))
return Error(400, "特长生招生类别的大类与小类不对应");
foreach (var category in categories)
{
var allocations = (category["indicatorAllocations"] as JsonArray ?? []).OfType<JsonObject>().ToArray();
if (allocations.Select(item => Text(item["sourceSchoolId"])).Distinct(StringComparer.Ordinal).Count() != allocations.Length)
return Error(400, "同一招生类别不能重复分配同一生源校指标");
if (allocations.Sum(item => Number(item["quota"])) > Number(category["quota"]))
return Error(400, "指标分配合计不能超过该类别计划人数");
if (allocations.Any(item => !data.Directory.Schools.Any(schoolItem =>
schoolItem.Id == Text(item["sourceSchoolId"]) && schoolItem.Active && schoolItem.IsSourceSchool)))
return Error(400, "指标分配中包含无效的生源学校");
}
var existing = Records(data, "plan", exam.Id).FirstOrDefault(item => item.SchoolId == school.Id);
if (existing?.Status == "approved") return Error(409, "已审核通过的招生计划只能由超级管理员调整");
var now = NowIso();
var plan = new CandidateAdmissionRecord(
existing?.Id ?? Uid("plan"),
"plan",
exam.Id,
existing?.UserId ?? user.Id,
school.Id,
"pending",
new JsonObject
{
["categories"] = new JsonArray(categories.Select(item => (JsonNode)item).ToArray()),
["note"] = Clean(Text(body["note"]), 500),
["submittedBy"] = user.DisplayName,
["reviewNote"] = ""
},
existing?.CreatedAt ?? now,
now);
await repository.SaveRecordsAsync(
[plan],
Audit(user, "提交招生计划", $"{school.Name} · {Text(exam.Data["name"])}"),
cancellationToken);
return new AdminEndpointResult(existing is null ? 201 : 200,
new JsonObject { ["ok"] = true, ["plan"] = RecordJson(plan) });
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingDraftAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(sessionToken, Text(body["examId"]), cancellationToken);
if (resolved.Error is not null) return resolved.Error;
var available = ReportingRows(resolved.Data!, resolved.Plan!, resolved.Record!)
.Select(item => Text(item["placementId"])).ToHashSet(StringComparer.Ordinal);
var now = NowIso();
var updates = (body["rows"] as JsonArray ?? []).OfType<JsonObject>().Select(item => new JsonObject
{
["placementId"] = Clean(Text(item["placementId"]), 64),
["status"] = Clean(Text(item["status"]), 30),
["note"] = Clean(Text(item["note"]), 300),
["updatedAt"] = now,
["source"] = "manual"
}).ToArray();
if (updates.Length == 0 || updates.Any(item =>
!available.Contains(Text(item["placementId"])) ||
Text(item["status"]) is not ("pending" or "reported" or "not_reported")))
return Error(400, "报到暂存数据无效");
var merged = ReportingRowMap(resolved.Record!);
foreach (var update in updates) merged[Text(update["placementId"])] = update;
var updated = UpdateReportingRecord(
resolved.Record!, "draft", merged.Values, resolved.User!.DisplayName, now,
("savedAt", JsonValue.Create(now)), ("savedBy", JsonValue.Create(resolved.User.DisplayName)));
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, "暂存考生报到状态", $"{resolved.School!.Name} · {updates.Length} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> PreviewAdmissionSchoolReportingScanAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var target = await ResolveScanTargetAsync(sessionToken, Text(body["code"]), cancellationToken);
if (target.Error is not null) return target.Error;
if (Text(body["examId"]) is { Length: > 0 } examId && Clean(examId, 64) != target.Placement!.ExamId)
return Error(400, "二维码不属于当前考试报到批次");
var row = ReportingRows(target.Data!, target.Plan!, target.Record!)
.First(item => Text(item["placementId"]) == target.Placement!.Id);
return Success(new JsonObject
{
["ok"] = true,
["code"] = target.Code,
["examId"] = target.Placement!.ExamId,
["row"] = row
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingScanAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var target = await ResolveScanTargetAsync(sessionToken, Text(body["code"]), cancellationToken);
if (target.Error is not null) return target.Error;
if (Text(body["examId"]) is { Length: > 0 } examId && Clean(examId, 64) != target.Placement!.ExamId)
return Error(400, "二维码不属于当前考试报到批次");
var status = Clean(Text(body["status"]), 30);
if (status is not ("reported" or "not_reported" or "pending"))
return Error(400, "请选择有效的报到确认状态");
var now = NowIso();
var fallback = status switch
{
"reported" => "扫描录取通知书二维码确认报到",
"not_reported" => "扫描录取通知书二维码确认未报到",
_ => "扫描录取通知书二维码后暂待确认"
};
var merged = ReportingRowMap(target.Record!);
merged[target.Placement!.Id] = new JsonObject
{
["placementId"] = target.Placement.Id,
["status"] = status,
["note"] = Clean(Text(body["note"]), 300) is { Length: > 0 } note ? note : fallback,
["updatedAt"] = now,
["source"] = "qr_scan"
};
var updated = UpdateReportingRecord(
target.Record!, "draft", merged.Values, target.User!.DisplayName, now,
("savedAt", JsonValue.Create(now)), ("savedBy", JsonValue.Create(target.User.DisplayName)));
await repository.SaveRecordsAsync(
[updated],
Audit(target.User, "扫码确认并暂存考生报到",
$"{target.School!.Name} · {(Text(target.Placement.Payload["noticeNumber"]) is { Length: > 0 } number ? number : target.Placement.Id)} · {ReportingCode(status)}"),
cancellationToken);
var data = ReplaceRecord(target.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["row"] = ReportingRows(data, target.Plan!, updated)
.First(item => Text(item["placementId"]) == target.Placement.Id),
["batch"] = ReportingBatch(data, target.Plan!, updated)
});
}
public async Task<AdminEndpointResult> SubmitAdmissionSchoolReportingAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(sessionToken, Text(body["examId"]), cancellationToken);
if (resolved.Error is not null) return resolved.Error;
var rows = ReportingRows(resolved.Data!, resolved.Plan!, resolved.Record!);
var pending = rows.Count(item => Text(item["status"]) == "pending");
if (pending > 0) return Error(409, $"仍有 {pending} 名考生待确认,请全部标记后提交");
var now = NowIso();
var payload = resolved.Record!.Payload.DeepClone().AsObject();
payload["submittedAt"] = now;
payload["submittedBy"] = resolved.User!.DisplayName;
var updated = resolved.Record with { Status = "submitted", UpdatedAt = now, Payload = payload };
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, "提交考生报到情况", $"{resolved.School!.Name} · {rows.Length} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> SaveAdmissionSchoolReportingDecisionAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var resolved = await ResolveReportingAsync(
sessionToken, Text(body["examId"]), cancellationToken, requiredStatus: "submitted");
if (resolved.Error is not null) return resolved.Error;
var progress = PlanProgress(resolved.Data!, resolved.Plan!);
var gap = Integer(progress["reportingGap"]);
var supplement = ExactlyTrue(body["supplement"]) && gap > 0;
var note = Clean(Text(body["decisionNote"]), 500);
if (supplement && note.Length < 4)
return Error(400, "申请补录时请填写至少 4 个字的补录说明");
var now = NowIso();
var payload = resolved.Record!.Payload.DeepClone().AsObject();
payload["supplementDecision"] = supplement ? "supplement" : "no_supplement";
payload["decisionNote"] = note.Length > 0 ? note :
gap > 0 ? "经学校研究决定,本轮不进行补录。" : "本校招生计划已完成。";
payload["decisionSubmittedAt"] = now;
payload["decisionSubmittedBy"] = resolved.User!.DisplayName;
payload["statistics"] = progress.DeepClone();
var updated = resolved.Record with { Status = "pending_approval", UpdatedAt = now, Payload = payload };
await repository.SaveRecordsAsync(
[updated],
Audit(resolved.User, supplement ? "提交补录申请" : "提交不补录决定",
$"{resolved.School!.Name} · 缺额 {gap} 人"),
cancellationToken);
var data = ReplaceRecord(resolved.Data!, updated);
return Success(new JsonObject
{
["ok"] = true,
["batch"] = ReportingBatch(data, resolved.Plan!, updated)
});
}
public async Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementsAsync(
string sessionToken,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, user);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var ids = (body["ids"] as JsonArray ?? []).Select(Text).Select(item => Clean(item, 64))
.Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
var decision = Clean(Text(body["decision"]), 30);
var note = Clean(Text(body["note"]), 500);
if (ids.Length == 0) return Error(400, "请至少选择一名待审核考生");
if (decision is not ("accept" or "withdraw")) return Error(400, "请选择接收或申请退档");
if (decision == "withdraw" && note.Length < 8)
return Error(400, "批量申请退档必须填写至少 8 个字的特殊理由");
var placements = Records(data, "placement").Where(item =>
ids.Contains(item.Id, StringComparer.Ordinal) && item.SchoolId == school.Id && item.Status == "school_review").ToArray();
if (placements.Length != ids.Length)
return Error(409, "所选记录中包含已处理或不属于本校的投档记录,请刷新后重试");
var now = NowIso();
var updated = placements.Select(item => ReviewedPlacement(item, decision, note, now)).ToArray();
await repository.SaveRecordsAsync(
updated,
Audit(user, decision == "accept" ? "批量接收投档考生" : "批量申请退档",
$"{school.Name} · {updated.Length} 人"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["count"] = updated.Length, ["decision"] = decision });
}
public async Task<AdminEndpointResult> ReviewAdmissionSchoolPlacementAsync(
string sessionToken,
string placementId,
JsonObject body,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return context.Error;
var user = context.User!;
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, user);
if (school is null) return Error(403, "招生学校账号未绑定有效学校");
var placement = Records(data, "placement").FirstOrDefault(item =>
item.Id == placementId && item.SchoolId == school.Id && item.Status == "school_review");
if (placement is null) return Error(404, "待审核投档记录不存在");
var decision = Clean(Text(body["decision"]), 30);
var note = Clean(Text(body["note"]), 500);
if (decision is not ("accept" or "withdraw")) return Error(400, "请选择接收或申请退档");
if (decision == "withdraw" && note.Length < 8)
return Error(400, "申请退档必须填写至少 8 个字的特殊理由");
var updated = ReviewedPlacement(placement, decision, note, NowIso());
await repository.SaveRecordsAsync(
[updated],
Audit(user, decision == "accept" ? "接收投档考生" : "申请退档", $"{school.Name} · {placement.Id}"),
cancellationToken);
return Success(new JsonObject { ["ok"] = true, ["placement"] = RecordJson(updated) });
}
private async Task<ResolvedReporting> ResolveReportingAsync(
string sessionToken,
string examId,
CancellationToken cancellationToken,
string? requiredStatus = null)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return ResolvedReporting.Failed(context.Error);
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return ResolvedReporting.Failed(Error(403, "招生学校账号未绑定有效学校"));
var cleanExamId = Clean(examId, 64);
var plan = Records(data, "plan", cleanExamId)
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "approved");
var record = plan is null ? null : EditableReportingRecord(data, cleanExamId, school.Id);
var valid = requiredStatus is null
? record?.Status is "draft" or "rejected"
: record?.Status == requiredStatus;
if (plan is null || record is null || !valid)
{
var message = requiredStatus == "submitted"
? "请先提交本轮考生报到情况"
: "当前报到批次不能修改暂存状态";
return ResolvedReporting.Failed(Error(409, message));
}
return new(context.User, school, data, plan, record, null);
}
private async Task<ResolvedScan> ResolveScanTargetAsync(
string sessionToken,
string rawCode,
CancellationToken cancellationToken)
{
var context = await ResolveAdmissionSchoolAsync(sessionToken, cancellationToken);
if (context.Error is not null) return ResolvedScan.Failed(context.Error);
var data = await LoadAsync(cancellationToken);
var school = AdmissionSchool(data, context.User!);
if (school is null) return ResolvedScan.Failed(Error(403, "招生学校账号未绑定有效学校"));
var match = AdmissionNoticePattern.Match(rawCode.ToUpperInvariant());
if (!match.Success) return ResolvedScan.Failed(Error(400, "未识别到有效的录取通知书防伪码"));
var code = match.Value.ToUpperInvariant();
var placement = Records(data, "placement").FirstOrDefault(item =>
item.SchoolId == school.Id && item.Status == "final" &&
DocumentVerificationCodeService.SafeEquals(code, NoticeCode(item)));
if (placement is null) return ResolvedScan.Failed(Error(404, "该二维码不属于本校有效录取通知书"));
var plan = Records(data, "plan", placement.ExamId)
.FirstOrDefault(item => item.SchoolId == school.Id && item.Status == "approved");
var record = plan is null ? null : EditableReportingRecord(data, placement.ExamId, school.Id);
if (plan is null || record is null || record.Status is not ("draft" or "rejected") ||
!(record.Payload["rows"] as JsonArray ?? []).Any(item => Text(item?["placementId"]) == placement.Id))
return ResolvedScan.Failed(Error(409, "该考生不在当前可维护的报到批次"));
return new(context.User, school, data, plan, record, placement, code, null);
}
private string NoticeCode(CandidateAdmissionRecord placement) =>
documentCodes.AdmissionNoticeCode(
placement.Id,
placement.UserId ?? "",
placement.SchoolId ?? "",
placement.ExamId,
Text(placement.Payload["categoryCode"]),
Text(placement.Payload["noticeNumber"]),
placement.UpdatedAt);
private static Dictionary<string, JsonObject> ReportingRowMap(CandidateAdmissionRecord record) =>
(record.Payload["rows"] as JsonArray ?? []).OfType<JsonObject>()
.ToDictionary(item => Text(item["placementId"]), item => item.DeepClone().AsObject(), StringComparer.Ordinal);
private static CandidateAdmissionRecord UpdateReportingRecord(
CandidateAdmissionRecord record,
string status,
IEnumerable<JsonObject> rows,
string actor,
string now,
params (string Key, JsonNode? Value)[] additions)
{
var payload = record.Payload.DeepClone().AsObject();
payload["rows"] = new JsonArray(rows.Select(item => (JsonNode)item).ToArray());
foreach (var addition in additions) payload[addition.Key] = addition.Value;
return record with { Status = status, UpdatedAt = now, Payload = payload };
}
private static CandidateAdmissionRecord ReviewedPlacement(
CandidateAdmissionRecord placement,
string decision,
string note,
string now)
{
var payload = placement.Payload.DeepClone().AsObject();
payload["schoolDecisionNote"] = note;
if (decision == "withdraw") payload["withdrawalReason"] = note;
return placement with
{
Status = decision == "accept" ? "admitted" : "withdrawal_pending",
Payload = payload,
UpdatedAt = now
};
}
private static string ReportingCode(string status) =>
status switch { "reported" => "Y", "not_reported" => "N", _ => "P" };
private sealed record ResolvedReporting(
AuthenticationUser? User,
AdminSchool? School,
AdmissionData? Data,
CandidateAdmissionRecord? Plan,
CandidateAdmissionRecord? Record,
AdminEndpointResult? Error)
{
public static ResolvedReporting Failed(AdminEndpointResult error) =>
new(null, null, null, null, null, error);
}
private sealed record ResolvedScan(
AuthenticationUser? User,
AdminSchool? School,
AdmissionData? Data,
CandidateAdmissionRecord? Plan,
CandidateAdmissionRecord? Record,
CandidateAdmissionRecord? Placement,
string Code,
AdminEndpointResult? Error)
{
public static ResolvedScan Failed(AdminEndpointResult error) =>
new(null, null, null, null, null, null, "", error);
}
}
@@ -2,8 +2,10 @@ using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using Eis.Application.Administration;
using Eis.Application.Public;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Security;
namespace Eis.Infrastructure.Administration;
@@ -15,7 +17,9 @@ internal sealed partial class AdminAdmissionService(
AdminOperationalSnapshotLoader operationalLoader,
AdminAccountBatchSnapshotLoader directoryLoader,
AdminWorkflowResultSnapshotLoader resultLoader,
AdminAdmissionRepository repository) : IAdminAdmissionService
AdminAdmissionRepository repository,
DocumentVerificationCodeService documentCodes,
IPublicQueryService publicQueries) : IAdminAdmissionService
{
private static readonly HashSet<string> Phases =
["draft", "filling", "closed", "matching", "school_review", "reporting", "supplementary", "completed"];
@@ -34,49 +34,36 @@ public sealed record AdminMigrationOptions(
bool configuredNativeResultsEnabled = false,
bool configuredNativeAdmissionsEnabled = false)
{
var readsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
configuredNativeReadsEnabled);
var organizationWritesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"),
configuredNativeOrganizationWritesEnabled);
var accountBatchesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"),
configuredNativeAccountBatchesEnabled);
var configurationEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"),
configuredNativeConfigurationEnabled);
var noticeManagementEnabled = ParseBoolean(
var readsEnabled = configuredNativeReadsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"), false);
var organizationWritesEnabled = configuredNativeOrganizationWritesEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED"), false);
var accountBatchesEnabled = configuredNativeAccountBatchesEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED"), false);
var configurationEnabled = configuredNativeConfigurationEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_CONFIGURATION_ENABLED"), false);
var noticeManagementEnabled = configuredNativeNoticeManagementEnabled || ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED") ??
Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED"),
configuredNativeNoticeManagementEnabled);
var centersEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"),
configuredNativeCentersEnabled);
var operationalReadsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"),
configuredNativeOperationalReadsEnabled);
var candidateManagementEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED"),
configuredNativeCandidateManagementEnabled);
var registrationPaymentWritesEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"),
configuredNativeRegistrationPaymentWritesEnabled);
var workflowOperationsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"),
configuredNativeWorkflowOperationsEnabled);
var examManagementEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"),
configuredNativeExamManagementEnabled);
var arrangementsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"),
configuredNativeArrangementsEnabled);
var resultsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED"),
configuredNativeResultsEnabled);
var admissionsEnabled = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ADMISSIONS_ENABLED"),
configuredNativeAdmissionsEnabled);
false);
var centersEnabled = configuredNativeCentersEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"), false);
var operationalReadsEnabled = configuredNativeOperationalReadsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"), false);
var candidateManagementEnabled = configuredNativeCandidateManagementEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_CANDIDATE_MANAGEMENT_ENABLED"), false);
var registrationPaymentWritesEnabled = configuredNativeRegistrationPaymentWritesEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_REGISTRATION_PAYMENT_WRITES_ENABLED"), false);
var workflowOperationsEnabled = configuredNativeWorkflowOperationsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_WORKFLOW_OPERATIONS_ENABLED"), false);
var examManagementEnabled = configuredNativeExamManagementEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_EXAM_MANAGEMENT_ENABLED"), false);
var arrangementsEnabled = configuredNativeArrangementsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_ARRANGEMENTS_ENABLED"), false);
var resultsEnabled = configuredNativeResultsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_RESULTS_ENABLED"), false);
var admissionsEnabled = configuredNativeAdmissionsEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("ADMIN_NATIVE_ADMISSIONS_ENABLED"), false);
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled || candidateManagementEnabled || registrationPaymentWritesEnabled || workflowOperationsEnabled || examManagementEnabled || arrangementsEnabled || resultsEnabled || admissionsEnabled) && !readsEnabled)
{
throw new InvalidOperationException(
@@ -94,15 +81,6 @@ public sealed record AdminMigrationOptions(
"启用原生管理端接口前必须同时设置 AUTH_NATIVE_ENABLED=true");
}
var allowMemoryForIsolatedTesting = ParseBoolean(
Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY"),
fallback: false);
if (anyNativeAdminEndpointEnabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{
throw new InvalidOperationException(
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
}
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled, candidateManagementEnabled, registrationPaymentWritesEnabled, workflowOperationsEnabled, examManagementEnabled, arrangementsEnabled, resultsEnabled, admissionsEnabled);
}
@@ -58,7 +58,8 @@ public sealed class AuthenticationOptions
public static AuthenticationOptions FromEnvironment(bool production, bool configuredNativeEnabled = false)
{
var nativeEnabled = ParseBoolean(Environment.GetEnvironmentVariable("AUTH_NATIVE_ENABLED"), configuredNativeEnabled);
var nativeEnabled = configuredNativeEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("AUTH_NATIVE_ENABLED"), false);
var cacheUrl = Clean(Environment.GetEnvironmentVariable("REDIS_URL"));
var explicitSessionUrl = Clean(Environment.GetEnvironmentVariable("REDIS_SESSION_URL"));
var sessionUrl = explicitSessionUrl ?? cacheUrl;
@@ -78,12 +79,6 @@ public sealed class AuthenticationOptions
"Redis 认证状态必须使用与普通缓存不同的逻辑数据库;请配置 REDIS_SESSION_DB 或 REDIS_SESSION_URL");
}
if (nativeEnabled && production && sessionUrl is null)
{
throw new InvalidOperationException(
"渐进迁移期间在生产环境启用原生认证必须配置 REDIS_URL 或 REDIS_SESSION_URL,以便 Node 与 ASP.NET Core 共享会话");
}
var configuredTotpKey = Environment.GetEnvironmentVariable("TOTP_ENCRYPTION_KEY") ?? string.Empty;
if (nativeEnabled && production && configuredTotpKey.Length < 32)
{
@@ -7,24 +7,14 @@ public sealed record CandidateMigrationOptions(bool NativeEnabled)
bool authenticationNativeEnabled,
bool sharesLegacySessions)
{
var enabled = ParseBoolean(
Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"),
configuredNativeEnabled);
var enabled = configuredNativeEnabled ||
ParseBoolean(Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"), false);
if (enabled && !authenticationNativeEnabled)
{
throw new InvalidOperationException(
"启用原生考生接口前必须同时设置 AUTH_NATIVE_ENABLED=true,以确保 ASP.NET Core 能识别登录会话");
}
var allowMemoryForIsolatedTesting = ParseBoolean(
Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ALLOW_MEMORY"),
fallback: false);
if (enabled && !sharesLegacySessions && !allowMemoryForIsolatedTesting)
{
throw new InvalidOperationException(
"应用仍有受保护接口需要转发给 Node;启用原生考生接口必须配置共享 Redis 会话");
}
return new CandidateMigrationOptions(enabled);
}
@@ -0,0 +1,306 @@
using System.Data.Common;
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Eis.Infrastructure.Authentication;
using Microsoft.Extensions.DependencyInjection;
namespace Eis.Infrastructure.Data;
internal sealed partial class DatabaseInitializer(
IRelationalConnectionFactory connectionFactory,
DatabaseOptions options,
PasswordCompatibilityService passwords)
{
private const int SchemaVersion = 20;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var source = await ReadSchemaSourceAsync(cancellationToken);
if (options.Client == "sqlite")
{
await ExecuteAsync(connection, ExtractSqliteSchema(source), cancellationToken);
}
else
{
foreach (Match match in MySqlStatementPattern().Matches(ExtractMySqlSection(source)))
{
await ExecuteAsync(connection, match.Groups["sql"].Value, cancellationToken);
}
}
await SeedBaseStateAsync(connection, cancellationToken);
}
private async Task SeedBaseStateAsync(DbConnection connection, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
if (!await ExistsAsync(connection, "schema_metadata", cancellationToken))
{
await ExecuteAsync(connection,
"""
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, @version, @version, 0, @createdAt)
""",
[("@version", SchemaVersion), ("@createdAt", now)], cancellationToken);
}
else
{
var version = await ScalarLongAsync(connection,
"SELECT schema_version FROM schema_metadata WHERE id = 1", cancellationToken);
if (version < SchemaVersion)
{
throw new InvalidOperationException(
$"数据库结构版本为 {version},低于 ASP.NET Core 要求的 {SchemaVersion};请先备份并执行旧版本升级流程");
}
}
if (!await ExistsAsync(connection, "organization", cancellationToken))
{
await ExecuteAsync(connection,
"INSERT INTO organization (id, name, code, phone, address) VALUES (1, @name, @code, '', '')",
[("@name", "考试服务平台"), ("@code", "EXAM-SERVICE")], cancellationToken);
}
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", cancellationToken) == 0)
{
var username = Environment.GetEnvironmentVariable("INITIAL_ADMIN_USERNAME")?.Trim();
var password = Environment.GetEnvironmentVariable("INITIAL_ADMIN_PASSWORD") ?? "Admin123!";
var displayName = Environment.GetEnvironmentVariable("INITIAL_ADMIN_DISPLAY_NAME")?.Trim();
await ExecuteAsync(connection,
"""
INSERT INTO users (
id, username, password_hash, role, admin_level, active, must_change_password,
totp_enabled, totp_recovery_codes, display_name, created_at
) VALUES (
'usr_admin', @username, @passwordHash, 'admin', 'super', 1, 0, 0, '[]', @displayName, @createdAt
)
""",
[
("@username", string.IsNullOrWhiteSpace(username) ? "admin" : username),
("@passwordHash", passwords.Hash(password)),
("@displayName", string.IsNullOrWhiteSpace(displayName) ? "系统管理员" : displayName),
("@createdAt", now)
], cancellationToken);
}
await SeedNumberRuleAsync(connection, now, cancellationToken);
await SeedAdmissionNumberRulesAsync(connection, now, cancellationToken);
await SeedWorkflowsAsync(connection, now, cancellationToken);
}
private static async Task SeedNumberRuleAsync(
DbConnection connection,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM number_rules", cancellationToken) > 0) return;
await ExecuteAsync(connection,
"""
INSERT INTO number_rules (id, name, separator, active, created_by, updated_at)
VALUES ('rule_default', @name, '-', 1, 'usr_admin', @updatedAt)
""",
[("@name", "年度学校性别流水号"), ("@updatedAt", now)], cancellationToken);
var segments = new[]
{
("segment_year", 1, "year", "", 4),
("segment_school", 2, "school_code", "", 0),
("segment_gender", 3, "gender", "", 0),
("segment_sequence", 4, "sequence", "", 4)
};
foreach (var segment in segments)
{
await ExecuteAsync(connection,
"""
INSERT INTO number_rule_segments (id, rule_id, position, type, value, width)
VALUES (@id, 'rule_default', @position, @type, @value, @width)
""",
[
("@id", segment.Item1), ("@position", segment.Item2), ("@type", segment.Item3),
("@value", segment.Item4), ("@width", segment.Item5)
], cancellationToken);
}
}
private static async Task SeedAdmissionNumberRulesAsync(
DbConnection connection,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM admission_number_rules", cancellationToken) > 0) return;
var rules = new[]
{
new AdmissionRule("admit_rule_district_room_seat", "district_room_seat", "县区编号 + 考场号 + 座位号",
"适合县区统一组织,号码直接反映县区、考试考场与座位。", "32070603108",
[new("district_code", "县区编号", 6), new("exam_room_code", "考场号", 3), new("seat", "座位号", 2)]),
new AdmissionRule("admit_rule_district_room_sequence", "district_room_sequence", "县区号 + 考场号 + 流水号",
"以县区为流水边界,适合不希望座位号直接出现在号码中的场景。", "3207060310028",
[new("district_code", "县区号", 6), new("exam_room_code", "考场号", 3), new("sequence", "流水号", 4)]),
new AdmissionRule("admit_rule_center_school_room_seat", "center_school_room_seat", "考点学校代码 + 考场号 + 座位号",
"号码前缀取考点所属学校代码,便于考点现场快速识别。", "HZ0303108",
[new("center_school_code", "考点学校代码", null), new("exam_room_code", "考场号", 3), new("seat", "座位号", 2)]),
new AdmissionRule("admit_rule_candidate_school_room_seat", "candidate_school_room_seat", "考生学校代码 + 考场号 + 座位号",
"号码前缀保留考生学籍学校代码,适合按生源学校归档。", "HZ0103108",
[new("candidate_school_code", "考生学校代码", null), new("exam_room_code", "考场号", 3), new("seat", "座位号", 2)])
};
foreach (var rule in rules)
{
var segments = JsonSerializer.Serialize(rule.Segments.Select(item => new
{
source = item.Source, label = item.Label, width = item.Width
}));
await ExecuteAsync(connection,
"""
INSERT INTO admission_number_rules (
id, code, name, description, separator, segments_json, example, active, created_at
) VALUES (
@id, @code, @name, @description, '', @segments, @example, 1, @createdAt
)
""",
[
("@id", rule.Id), ("@code", rule.Code), ("@name", rule.Name),
("@description", rule.Description), ("@segments", segments),
("@example", rule.Example), ("@createdAt", now)
], cancellationToken);
}
}
private static async Task SeedWorkflowsAsync(
DbConnection connection,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM workflow_definitions", cancellationToken) > 0) return;
var workflows = new[]
{
new Workflow("workflow_profile", "profile_change", "考生信息修改审批",
[new("workflow_profile_step_1", "学校学籍复核", "school"), new("workflow_profile_step_2", "考试中心终审", "super")]),
new Workflow("workflow_registration", "registration_review", "考试报名审核",
[new("workflow_registration_step_1", "学校报名初审", "school"), new("workflow_registration_step_2", "考试中心终审", "super")]),
new Workflow("workflow_center", "center_change", "考点考场变更审批",
[new("workflow_center_step_1", "考试中心考务终审", "super")]),
new Workflow("workflow_account_batch", "candidate_account_batch", "批量报名号申领审批",
[new("workflow_account_batch_step_1", "考试中心账号终审", "super")]),
new Workflow("workflow_score_appeal", "score_appeal", "考生成绩复议",
[
new("workflow_score_appeal_step_1", "班级情况核验", "class"),
new("workflow_score_appeal_step_2", "学校成绩复核", "school"),
new("workflow_score_appeal_step_3", "考试中心终审", "super")
])
};
foreach (var workflow in workflows)
{
await ExecuteAsync(connection,
"""
INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at)
VALUES (@id, @type, @name, 1, 'usr_admin', @updatedAt)
""",
[
("@id", workflow.Id), ("@type", workflow.Type),
("@name", workflow.Name), ("@updatedAt", now)
], cancellationToken);
for (var index = 0; index < workflow.Steps.Length; index++)
{
var step = workflow.Steps[index];
await ExecuteAsync(connection,
"""
INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level)
VALUES (@id, @workflowId, @position, @name, @level)
""",
[
("@id", step.Id), ("@workflowId", workflow.Id), ("@position", index + 1),
("@name", step.Name), ("@level", step.Level)
], cancellationToken);
}
}
}
private static async Task<bool> ExistsAsync(
DbConnection connection,
string table,
CancellationToken cancellationToken) =>
await ScalarLongAsync(connection, $"SELECT COUNT(*) FROM {table}", cancellationToken) > 0;
private static async Task<long> ScalarLongAsync(
DbConnection connection,
string sql,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
}
private static Task ExecuteAsync(
DbConnection connection,
string sql,
CancellationToken cancellationToken) =>
ExecuteAsync(connection, sql, [], cancellationToken);
private static async Task ExecuteAsync(
DbConnection connection,
string sql,
IReadOnlyList<(string Name, object Value)> values,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var value in values)
{
var parameter = command.CreateParameter();
parameter.ParameterName = value.Name;
parameter.Value = value.Value;
command.Parameters.Add(parameter);
}
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<string> ReadSchemaSourceAsync(CancellationToken cancellationToken)
{
var assembly = typeof(DatabaseInitializer).Assembly;
var name = assembly.GetManifestResourceNames()
.Single(item => item.EndsWith("database-schema.mjs", StringComparison.Ordinal));
await using var stream = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException("内置数据库结构资源不存在");
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync(cancellationToken);
}
private static string ExtractSqliteSchema(string source)
{
const string startToken = "export const sqliteSchema = `";
var start = source.IndexOf(startToken, StringComparison.Ordinal);
var mysqlStart = source.IndexOf("export const mysqlSchema", StringComparison.Ordinal);
var end = mysqlStart < 0 ? -1 : source.LastIndexOf('`', mysqlStart);
if (start < 0 || end <= start) throw new InvalidOperationException("无法读取 SQLite 数据库结构");
return source[(start + startToken.Length)..end];
}
private static string ExtractMySqlSection(string source)
{
const string startToken = "export const mysqlSchema = [";
var start = source.IndexOf(startToken, StringComparison.Ordinal);
var end = source.IndexOf("];", start, StringComparison.Ordinal);
if (start < 0 || end <= start) throw new InvalidOperationException("无法读取 MySQL 数据库结构");
return source[(start + startToken.Length)..end];
}
[GeneratedRegex(@"`(?<sql>[\s\S]*?)`\s*,?", RegexOptions.CultureInvariant)]
private static partial Regex MySqlStatementPattern();
private sealed record AdmissionRule(
string Id, string Code, string Name, string Description, string Example, AdmissionSegment[] Segments);
private sealed record AdmissionSegment(string Source, string Label, int? Width);
private sealed record Workflow(string Id, string Type, string Name, WorkflowStep[] Steps);
private sealed record WorkflowStep(string Id, string Name, string Level);
}
public static class DatabaseInitializationExtensions
{
public static async Task InitializeEisDatabaseAsync(
this IServiceProvider serviceProvider,
CancellationToken cancellationToken = default)
{
await using var scope = serviceProvider.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
.InitializeAsync(cancellationToken);
}
}
@@ -17,6 +17,9 @@ public sealed class DatabaseOptions
public string? MySqlConnectionString { get; }
internal static DatabaseOptions CreateSqliteForTests(string path) =>
new("sqlite", Path.GetFullPath(path), null);
public static DatabaseOptions FromEnvironment(string applicationRoot, bool production)
{
var client = (Environment.GetEnvironmentVariable("DATABASE_CLIENT")
@@ -8,15 +8,21 @@ public sealed class RelationalConnectionFactory(DatabaseOptions options) : IRela
{
public async ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken)
{
if (options.Client == "sqlite" && options.SqlitePath is { } sqlitePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(sqlitePath)
?? throw new InvalidOperationException("SQLite 数据库路径无效"));
}
DbConnection connection = options.Client switch
{
"sqlite" => new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = options.SqlitePath,
Mode = SqliteOpenMode.ReadWrite,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
ForeignKeys = true,
DefaultTimeout = 5
DefaultTimeout = 5,
Pooling = false
}.ConnectionString),
"mysql" => new MySqlConnection(options.MySqlConnectionString),
_ => throw new InvalidOperationException($"不支持的数据库类型:{options.Client}")
@@ -35,6 +35,7 @@ public static class DependencyInjection
{
services.AddSingleton(databaseOptions);
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
services.AddScoped<DatabaseInitializer>();
services.AddSingleton<IApplicationCache, ApplicationCache>();
services.AddSingleton(documentVerificationOptions);
services.AddSingleton<DocumentVerificationCodeService>();
@@ -18,5 +18,6 @@
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\data\china-regions.mjs" Link="Data\china-regions.mjs" />
<EmbeddedResource Include="..\..\src\database\schema.mjs" Link="Data\database-schema.mjs" />
</ItemGroup>
</Project>
@@ -10,10 +10,10 @@ public static class MigrationFeatureCatalog
new(FeatureArea.Public, true, "/api/public"),
new(FeatureArea.Authentication, authenticationNative, "/api/auth"),
new(FeatureArea.Candidate, candidateNative, "/api/candidate"),
new(FeatureArea.Administration, false, "/api/admin"),
new(FeatureArea.Admission, false, "/api/admission"),
new(FeatureArea.Documents, false, "/api"),
new(FeatureArea.Excel, false, "/api"),
new(FeatureArea.Caching, false, "/api")
new(FeatureArea.Administration, true, "/api/admin"),
new(FeatureArea.Admission, true, "/api/admission"),
new(FeatureArea.Documents, true, "/api"),
new(FeatureArea.Excel, true, "/api"),
new(FeatureArea.Caching, true, "/api")
];
}
@@ -14,11 +14,7 @@ public sealed class DocumentVerificationOptions
public static DocumentVerificationOptions FromEnvironment(bool production)
{
var configured = Environment.GetEnvironmentVariable("DOCUMENT_VERIFICATION_SECRET") ?? string.Empty;
var nodeProduction = string.Equals(
Environment.GetEnvironmentVariable("NODE_ENV"),
"production",
StringComparison.OrdinalIgnoreCase);
if ((production || nodeProduction) && configured.Length < 32)
if (production && configured.Length < 32)
{
throw new InvalidOperationException("生产环境必须设置至少 32 个字符的 DOCUMENT_VERIFICATION_SECRET");
}
@@ -222,6 +222,15 @@ public static class NativeAdminReadEndpoints
if (options.NativeAdmissionsEnabled)
{
endpoints.MapGet("/api/admission/context",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolContextAsync(Token(context), cancellationToken)));
endpoints.MapGet("/api/admission/plans",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolPlansAsync(Token(context), cancellationToken)));
endpoints.MapPost("/api/admission/plans",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolPlanAsync(Token(context), body, cancellationToken)));
endpoints.MapGet("/api/admission/notice-template",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolNoticeTemplateAsync(Token(context), cancellationToken)));
@@ -248,6 +257,21 @@ public static class NativeAdminReadEndpoints
context.Request.Query["examId"].ToString(),
await ReadWorkbookAsync(context.Request, cancellationToken),
cancellationToken)));
endpoints.MapPut("/api/admission/reporting/draft",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingDraftAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/scan-preview",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.PreviewAdmissionSchoolReportingScanAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/scan",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingScanAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/submit",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SubmitAdmissionSchoolReportingAsync(Token(context), body, cancellationToken)));
endpoints.MapPost("/api/admission/reporting/decision",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.SaveAdmissionSchoolReportingDecisionAsync(Token(context), body, cancellationToken)));
endpoints.MapGet("/api/admission/placements",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAdmissionSchoolPlacementsAsync(Token(context), cancellationToken)));
@@ -259,6 +283,12 @@ public static class NativeAdminReadEndpoints
Token(context),
context.Request.Query["examId"].ToString(),
cancellationToken)));
endpoints.MapPost("/api/admission/placements/bulk",
(HttpContext context, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.ReviewAdmissionSchoolPlacementsAsync(Token(context), body, cancellationToken)));
endpoints.MapPatch("/api/admission/placements/{placementId}",
(HttpContext context, string placementId, JsonObject body, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.ReviewAdmissionSchoolPlacementAsync(Token(context), placementId, body, cancellationToken)));
endpoints.MapGet("/api/admin/admissions",
(HttpContext context, IAdminAdmissionService service, CancellationToken cancellationToken) =>
Execute(context, service.GetAsync(Token(context), cancellationToken)));
-3
View File
@@ -15,8 +15,5 @@
<Content Include="..\..\src\client\**\*.mjs" Link="wwwroot\src\client\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\china-regions.mjs" Link="wwwroot\src\data\china-regions.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\specialty-types.mjs" Link="wwwroot\src\data\specialty-types.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\node_modules\ckeditor5\dist\browser\ckeditor5.js" Link="wwwroot\vendor\ckeditor5\ckeditor5.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\browser\ckeditor5.js')" />
<Content Include="..\..\node_modules\ckeditor5\dist\browser\ckeditor5.css" Link="wwwroot\vendor\ckeditor5\ckeditor5.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\browser\ckeditor5.css')" />
<Content Include="..\..\node_modules\ckeditor5\dist\translations\zh-cn.js" Link="wwwroot\vendor\ckeditor5\translations\zh-cn.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\..\node_modules\ckeditor5\dist\translations\zh-cn.js')" />
</ItemGroup>
</Project>
+3 -20
View File
@@ -1,4 +1,3 @@
using Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.StaticFiles;
namespace Eis.Web.Frontend;
@@ -22,11 +21,12 @@ public static class FrontendAssets
public static void MapFrontendAssets(this WebApplication app)
{
app.UseDefaultFiles();
app.UseStaticFiles(CreateStaticOptions());
var repositoryRoot = FindRepositoryRoot(app.Environment.ContentRootPath);
if (repositoryRoot is null)
{
app.UseDefaultFiles();
app.UseStaticFiles(CreateStaticOptions());
app.MapFallbackToFile("index.html");
return;
}
@@ -42,23 +42,6 @@ public static class FrontendAssets
MapFile(app, "/styles.css", Path.Combine(repositoryRoot, "styles.css"), "text/css; charset=utf-8");
MapFile(app, "/app.js", Path.Combine(repositoryRoot, "app.js"), "text/javascript; charset=utf-8");
var ckeditorRoot = Path.Combine(repositoryRoot, "node_modules", "ckeditor5", "dist");
if (Directory.Exists(ckeditorRoot))
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(ckeditorRoot, "browser")),
RequestPath = "/vendor/ckeditor5",
OnPrepareResponse = SetNoCache
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(ckeditorRoot, "translations")),
RequestPath = "/vendor/ckeditor5/translations",
OnPrepareResponse = SetNoCache
});
}
var indexPath = Path.Combine(repositoryRoot, "index.html");
MapFile(app, "/", indexPath, "text/html; charset=utf-8");
MapFile(app, "/index.html", indexPath, "text/html; charset=utf-8");
-135
View File
@@ -1,135 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using Microsoft.Extensions.Options;
namespace Eis.Web.Legacy;
public sealed class LegacyApiProxy(
HttpClient httpClient,
IOptions<LegacyNodeOptions> options,
ILogger<LegacyApiProxy> logger)
{
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade"
};
private readonly LegacyNodeOptions _options = options.Value;
public async Task ForwardAsync(HttpContext context)
{
if (!_options.Enabled)
{
context.Response.StatusCode = StatusCodes.Status501NotImplemented;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
message = "该接口尚未迁移到 ASP.NET Core",
migration = new { native = false, legacyProxyEnabled = false }
}, context.RequestAborted);
return;
}
var target = new Uri(_options.BaseUrl, $"{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}");
using var outbound = CreateRequest(context, target);
try
{
using var upstream = await httpClient.SendAsync(
outbound,
HttpCompletionOption.ResponseHeadersRead,
context.RequestAborted);
context.Response.StatusCode = (int)upstream.StatusCode;
CopyResponseHeaders(upstream, context.Response);
if (context.Request.Method != HttpMethods.Head && upstream.StatusCode != HttpStatusCode.NoContent)
{
await upstream.Content.CopyToAsync(context.Response.Body, context.RequestAborted);
}
}
catch (HttpRequestException exception)
{
logger.LogWarning(exception, "Legacy Node API at {Target} is unavailable", target);
if (context.Response.HasStarted)
{
context.Abort();
return;
}
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
message = "迁移期间的旧版 API 服务暂时不可用",
migration = new { native = false, legacyProxyEnabled = true }
}, context.RequestAborted);
}
}
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
return true;
}
try
{
using var response = await httpClient.GetAsync("api/public/home", cancellationToken);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
return false;
}
}
private static HttpRequestMessage CreateRequest(HttpContext context, Uri target)
{
var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), target);
var hasBody = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0;
if (hasBody)
{
request.Content = new StreamContent(context.Request.Body);
}
foreach (var (name, values) in context.Request.Headers)
{
if (name.Equals("Host", StringComparison.OrdinalIgnoreCase) || HopByHopHeaders.Contains(name))
{
continue;
}
var valueArray = values.ToArray();
if (!request.Headers.TryAddWithoutValidation(name, valueArray) && request.Content is not null)
{
request.Content.Headers.TryAddWithoutValidation(name, valueArray);
}
}
request.Headers.TryAddWithoutValidation("X-Forwarded-Host", context.Request.Host.Value);
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", context.Request.Scheme);
return request;
}
private static void CopyResponseHeaders(HttpResponseMessage upstream, HttpResponse response)
{
foreach (var header in upstream.Headers.Concat(upstream.Content.Headers))
{
if (!HopByHopHeaders.Contains(header.Key))
{
response.Headers.Append(header.Key, header.Value.ToArray());
}
}
response.Headers.Remove("transfer-encoding");
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace Eis.Web.Legacy;
public sealed class LegacyNodeOptions
{
public const string SectionName = "LegacyNode";
public bool Enabled { get; init; } = true;
public Uri BaseUrl { get; init; } = new("http://127.0.0.1:4174");
}
+14 -25
View File
@@ -1,4 +1,3 @@
using System.Net;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate;
@@ -13,7 +12,6 @@ using Eis.Web.Authentication;
using Eis.Web.Administration;
using Eis.Web.Candidate;
using Eis.Web.Frontend;
using Eis.Web.Legacy;
using Eis.Web.Public;
var applicationRoot = ApplicationPaths.FindApplicationRoot();
@@ -21,19 +19,6 @@ EnvironmentFile.Load(applicationRoot);
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
builder.Services.Configure<LegacyNodeOptions>(builder.Configuration.GetSection(LegacyNodeOptions.SectionName));
builder.Services.AddHttpClient<LegacyApiProxy>((services, client) =>
{
var options = services.GetRequiredService<Microsoft.Extensions.Options.IOptions<LegacyNodeOptions>>().Value;
client.BaseAddress = options.BaseUrl;
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("Eis.AspNetCore.Migration/1.0");
}).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None,
UseCookies = false
});
builder.Services.AddProblemDetails();
builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>();
var authenticationOptions = AuthenticationOptions.FromEnvironment(
@@ -68,6 +53,7 @@ builder.Services.AddEisInfrastructure(
adminMigrationOptions);
var app = builder.Build();
await app.Services.InitializeEisDatabaseAsync();
app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
app.UseExceptionHandler();
@@ -86,17 +72,13 @@ app.MapGet("/health/live", () => Results.Json(new
framework = ".NET 10"
}));
app.MapGet("/health/migration", async (
LegacyApiProxy proxy,
IApplicationCache cache,
CancellationToken cancellationToken) =>
app.MapGet("/health/migration", (IApplicationCache cache) =>
{
var legacyAvailable = await proxy.IsAvailableAsync(cancellationToken);
var statusCode = legacyAvailable ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
return Results.Json(new
{
status = legacyAvailable ? "healthy" : "degraded",
legacyApiAvailable = legacyAvailable,
status = "healthy",
legacyApiAvailable = false,
legacyApiRemoved = true,
cache = new
{
status = cache.Status,
@@ -202,7 +184,7 @@ app.MapGet("/health/migration", async (
.ToArray()
},
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
}, statusCode: statusCode);
}, statusCode: StatusCodes.Status200OK);
});
app.MapNativePublicEndpoints();
@@ -220,7 +202,14 @@ string[] methods =
HttpMethods.Delete,
HttpMethods.Options
];
app.MapMethods("/api/{**path}", methods, (HttpContext context, LegacyApiProxy proxy) => proxy.ForwardAsync(context));
app.MapMethods("/api/{**path}", methods, (HttpContext context) =>
{
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-EIS-Implementation"] = "aspnet-core";
return Results.Json(
new { ok = false, message = "API 接口不存在" },
statusCode: StatusCodes.Status404NotFound);
});
app.MapFrontendAssets();
+16 -20
View File
@@ -1,29 +1,25 @@
{
"LegacyNode": {
"Enabled": true,
"BaseUrl": "http://127.0.0.1:4174"
},
"AuthenticationMigration": {
"NativeEnabled": false
"NativeEnabled": true
},
"CandidateMigration": {
"NativeEnabled": false
"NativeEnabled": true
},
"AdminMigration": {
"NativeReadsEnabled": false,
"NativeOrganizationWritesEnabled": false,
"NativeAccountBatchesEnabled": false,
"NativeConfigurationEnabled": false,
"NativeNoticeManagementEnabled": false,
"NativeCentersEnabled": false,
"NativeOperationalReadsEnabled": false,
"NativeCandidateManagementEnabled": false,
"NativeRegistrationPaymentWritesEnabled": false,
"NativeWorkflowOperationsEnabled": false,
"NativeExamManagementEnabled": false,
"NativeArrangementsEnabled": false,
"NativeResultsEnabled": false,
"NativeAdmissionsEnabled": false
"NativeReadsEnabled": true,
"NativeOrganizationWritesEnabled": true,
"NativeAccountBatchesEnabled": true,
"NativeConfigurationEnabled": true,
"NativeNoticeManagementEnabled": true,
"NativeCentersEnabled": true,
"NativeOperationalReadsEnabled": true,
"NativeCandidateManagementEnabled": true,
"NativeRegistrationPaymentWritesEnabled": true,
"NativeWorkflowOperationsEnabled": true,
"NativeExamManagementEnabled": true,
"NativeArrangementsEnabled": true,
"NativeResultsEnabled": true,
"NativeAdmissionsEnabled": true
},
"Logging": {
"LogLevel": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -34,17 +34,16 @@ public sealed class AdminMigrationOptionsTests
}
[Fact]
public void RequiresSharedSessionsOutsideIsolatedTests()
public void AllowsStandaloneMemorySessionsAfterLegacyBackendRemoval()
{
WithEnvironment("true", null, null, () =>
{
var exception = Assert.Throws<InvalidOperationException>(() =>
AdminMigrationOptions.FromEnvironment(
var options = AdminMigrationOptions.FromEnvironment(
configuredNativeReadsEnabled: false,
authenticationNativeEnabled: true,
sharesLegacySessions: false));
sharesLegacySessions: false);
Assert.Contains("Redis", exception.Message);
Assert.True(options.NativeReadsEnabled);
});
}
@@ -0,0 +1,52 @@
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Data;
using Microsoft.Data.Sqlite;
namespace Eis.Infrastructure.Tests.Data;
public sealed class DatabaseInitializerTests
{
[Fact]
public async Task InitializeAsync_CreatesFreshDatabaseAndIsIdempotent()
{
var root = Path.Combine(Path.GetTempPath(), $"eis-fresh-init-{Guid.NewGuid():N}");
var path = Path.Combine(root, "eis.sqlite");
Directory.CreateDirectory(root);
try
{
var options = DatabaseOptions.CreateSqliteForTests(path);
var factory = new RelationalConnectionFactory(options);
var passwords = new PasswordCompatibilityService();
var initializer = new DatabaseInitializer(factory, options, passwords);
await initializer.InitializeAsync(CancellationToken.None);
await initializer.InitializeAsync(CancellationToken.None);
await using var connection = new SqliteConnection($"Data Source={path}");
await connection.OpenAsync();
Assert.True(await ScalarAsync(connection,
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") >= 32);
Assert.Equal(20, await ScalarAsync(connection,
"SELECT schema_version FROM schema_metadata WHERE id = 1"));
Assert.Equal(1, await ScalarAsync(connection,
"SELECT COUNT(*) FROM users WHERE id = 'usr_admin' AND role = 'admin' AND admin_level = 'super'"));
Assert.Equal(5, await ScalarAsync(connection,
"SELECT COUNT(*) FROM workflow_definitions WHERE active = 1"));
Assert.Equal(4, await ScalarAsync(connection,
"SELECT COUNT(*) FROM admission_number_rules WHERE active = 1"));
await connection.DisposeAsync();
SqliteConnection.ClearAllPools();
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static async Task<long> ScalarAsync(SqliteConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt64(await command.ExecuteScalarAsync());
}
}
@@ -6,12 +6,16 @@ namespace Eis.Infrastructure.Tests.Migration;
public sealed class MigrationFeatureCatalogTests
{
[Fact]
public void ReportsCandidateAreaFromItsIndependentMigrationFlag()
public void ReportsCompletedNativeFeatureAreas()
{
var features = MigrationFeatureCatalog.Current(authenticationNative: true, candidateNative: true);
Assert.True(features.Single(item => item.Area == FeatureArea.Authentication).Native);
Assert.True(features.Single(item => item.Area == FeatureArea.Candidate).Native);
Assert.False(features.Single(item => item.Area == FeatureArea.Administration).Native);
Assert.True(features.Single(item => item.Area == FeatureArea.Administration).Native);
Assert.True(features.Single(item => item.Area == FeatureArea.Admission).Native);
Assert.True(features.Single(item => item.Area == FeatureArea.Documents).Native);
Assert.True(features.Single(item => item.Area == FeatureArea.Excel).Native);
Assert.True(features.Single(item => item.Area == FeatureArea.Caching).Native);
}
}