本轮迁移已完成,生产运行链路现已切换为纯 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:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 行为对照工具,不属于生产启动链路。
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
+188
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user