本批迁移已完成:
原生化后台只读接口:GET /api/admin/candidates GET /api/admin/registrations GET /api/admin/payments 保持超级、校级、班级管理员的数据范围一致。 保持证件号脱敏、审批流、考试科目、缴费信息及座位号前导零兼容。 新增灰度开关:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=true 归档、审核、缴费更新等写操作暂时继续转发 Node。
This commit is contained in:
@@ -37,6 +37,8 @@ ADMIN_NATIVE_CONFIGURATION_ENABLED=false
|
|||||||
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false
|
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED=false
|
||||||
# 考点考场读取、变更提交及审批;必须同时启用管理端只读接口。
|
# 考点考场读取、变更提交及审批;必须同时启用管理端只读接口。
|
||||||
ADMIN_NATIVE_CENTERS_ENABLED=false
|
ADMIN_NATIVE_CENTERS_ENABLED=false
|
||||||
|
# 考生档案、报名记录与缴费记录读取;必须同时启用管理端只读接口。
|
||||||
|
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED=false
|
||||||
|
|
||||||
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
|||||||
+4
-3
@@ -12,7 +12,7 @@
|
|||||||
- [x] 招生公示与 HMAC 文书验真公开接口
|
- [x] 招生公示与 HMAC 文书验真公开接口
|
||||||
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
|
- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
|
||||||
- [x] 考生业务
|
- [x] 考生业务
|
||||||
- [ ] 管理后台、审批流和考务编排(管理端读取、组织维护、批量报名号审批、流程配置、通知公告及考点变更审批已原生化)
|
- [ ] 管理后台、审批流和考务编排(管理端基础读取、组织维护、批量报名号审批、流程配置、通知公告、考点变更审批及考生/报名/缴费读取已原生化)
|
||||||
- [x] 考生志愿填报与招生录取查询
|
- [x] 考生志愿填报与招生录取查询
|
||||||
- [ ] Excel、文书和缓存
|
- [ ] Excel、文书和缓存
|
||||||
- [ ] 容器入口切换及 Node.js 后端移除
|
- [ ] 容器入口切换及 Node.js 后端移除
|
||||||
@@ -54,7 +54,7 @@ $env:AUTH_NATIVE_ENABLED = 'true'
|
|||||||
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
$env:CANDIDATE_NATIVE_ENABLED = 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。
|
管理后台第一批只读接口(管理上下文、仪表盘、学校、学校组织、管理员和考试列表)已经原生化,并保留超级、校级、班级管理员的权限与数据作用域。第二批覆盖学校、班级和管理员的创建与维护、管理员密码重置及自主注册开关;更新操作与审计日志在同一事务中提交,停用或重置管理员会同步失效其会话。第三批覆盖批量报名号申领的读取、提交和审批,终审会按照当前号码规则原子生成考生账号、初始密码和待补录资料。第四批覆盖报名号规则及审批流程定义的读取与维护,并保留流程层级和批量申领终审约束。第五批覆盖完整通知公告管理:超级管理员可读取手工公告与五类系统公示、创建和编辑手工公告,并控制系统公示是否公开显示;HTML 净化、显隐更新和审计日志均由 ASP.NET Core 原生处理。第六批覆盖考点考场读取、新增与修改申请、重复待审拦截及多级审批;终审会在同一事务中更新流程、审计日志和正式考点考场档案。第七批覆盖考生档案、报名记录和已审核报名的缴费记录读取,并保持三级管理员的数据作用域、证件号脱敏、考试科目及审批流投影与 Node 一致。
|
||||||
|
|
||||||
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
其余审批流和考务编排接口仍转发给 Node,因此管理端开关都要求原生认证和共享 Redis;各写入子功能还必须与只读开关一起启用:
|
||||||
|
|
||||||
@@ -66,9 +66,10 @@ $env:ADMIN_NATIVE_ACCOUNT_BATCHES_ENABLED = 'true'
|
|||||||
$env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
|
$env:ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
|
||||||
$env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
$env:ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
||||||
$env:ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
$env:ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
||||||
|
$env:ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。
|
旧版 `ADMIN_NATIVE_NOTICE_WRITES_ENABLED` 仍可作为兼容别名使用。`GET /health/migration` 的 `administration.nativeReadsEnabled`、`administration.nativeOrganizationWritesEnabled`、`administration.nativeAccountBatchesEnabled`、`administration.nativeConfigurationEnabled`、`administration.nativeNoticeManagementEnabled`、`administration.nativeCentersEnabled`、`administration.nativeOperationalReadsEnabled` 和 `administration.nativeRoutes` 会报告这些端点是否已切换。
|
||||||
|
|
||||||
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ try {
|
|||||||
ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
|
ADMIN_NATIVE_CONFIGURATION_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
ADMIN_NATIVE_CENTERS_ENABLED = 'true'
|
||||||
|
ADMIN_NATIVE_OPERATIONAL_READS_ENABLED = 'true'
|
||||||
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
ADMIN_NATIVE_ALLOW_MEMORY = 'true'
|
||||||
LegacyNode__Enabled = 'true'
|
LegacyNode__Enabled = 'true'
|
||||||
LegacyNode__BaseUrl = $legacyBaseUrl
|
LegacyNode__BaseUrl = $legacyBaseUrl
|
||||||
@@ -802,6 +803,22 @@ try {
|
|||||||
throw 'Native admins route did not preserve the class-admin boundary'
|
throw 'Native admins route did not preserve the class-admin boundary'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$operationalSessions = @(
|
||||||
|
@{ Level = 'super'; Legacy = $session; Native = $nativeSession },
|
||||||
|
@{ Level = 'school'; Legacy = $legacySchoolSession; Native = $nativeSchoolSession },
|
||||||
|
@{ Level = 'class'; Legacy = $legacyClassSession; Native = $nativeClassSession }
|
||||||
|
)
|
||||||
|
foreach ($operationalSession in $operationalSessions) {
|
||||||
|
foreach ($operationalRoute in @('candidates', 'registrations', 'payments')) {
|
||||||
|
$legacyOperationalRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/$operationalRoute" -WebSession $operationalSession.Legacy
|
||||||
|
$nativeOperationalRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/$operationalRoute" -WebSession $operationalSession.Native
|
||||||
|
if ($nativeOperationalRead.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw "Admin operational route '$operationalRoute' for $($operationalSession.Level) admin did not use ASP.NET Core"
|
||||||
|
}
|
||||||
|
Assert-JsonEquivalent -Expected $legacyOperationalRead.Content -Actual $nativeOperationalRead.Content -Label "Admin operational route '$operationalRoute' for $($operationalSession.Level) admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$legacyCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $session
|
$legacyCenters = Invoke-WebRequest -Uri "$legacyBaseUrl/api/admin/centers" -WebSession $session
|
||||||
$nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession
|
$nativeCenters = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/admin/centers" -WebSession $nativeSession
|
||||||
if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
if ($nativeCenters.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
@@ -1270,6 +1287,7 @@ try {
|
|||||||
NativeAdminConfiguration = 'passed'
|
NativeAdminConfiguration = 'passed'
|
||||||
NativeAdminNoticeManagement = 'passed'
|
NativeAdminNoticeManagement = 'passed'
|
||||||
NativeAdminCenters = 'passed'
|
NativeAdminCenters = 'passed'
|
||||||
|
NativeAdminOperationalReads = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Eis.Application.Administration;
|
||||||
|
|
||||||
|
public interface IAdminOperationalReadService
|
||||||
|
{
|
||||||
|
Task<AdminEndpointResult> GetCandidatesAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
Task<AdminEndpointResult> GetPaymentsAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool NativeAccountBatchesEnabled = false,
|
bool NativeAccountBatchesEnabled = false,
|
||||||
bool NativeConfigurationEnabled = false,
|
bool NativeConfigurationEnabled = false,
|
||||||
bool NativeNoticeManagementEnabled = false,
|
bool NativeNoticeManagementEnabled = false,
|
||||||
bool NativeCentersEnabled = false)
|
bool NativeCentersEnabled = false,
|
||||||
|
bool NativeOperationalReadsEnabled = false)
|
||||||
{
|
{
|
||||||
public static AdminMigrationOptions FromEnvironment(
|
public static AdminMigrationOptions FromEnvironment(
|
||||||
bool configuredNativeReadsEnabled,
|
bool configuredNativeReadsEnabled,
|
||||||
@@ -16,7 +17,8 @@ public sealed record AdminMigrationOptions(
|
|||||||
bool configuredNativeAccountBatchesEnabled = false,
|
bool configuredNativeAccountBatchesEnabled = false,
|
||||||
bool configuredNativeConfigurationEnabled = false,
|
bool configuredNativeConfigurationEnabled = false,
|
||||||
bool configuredNativeNoticeManagementEnabled = false,
|
bool configuredNativeNoticeManagementEnabled = false,
|
||||||
bool configuredNativeCentersEnabled = false)
|
bool configuredNativeCentersEnabled = false,
|
||||||
|
bool configuredNativeOperationalReadsEnabled = false)
|
||||||
{
|
{
|
||||||
var readsEnabled = ParseBoolean(
|
var readsEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED"),
|
||||||
@@ -37,12 +39,15 @@ public sealed record AdminMigrationOptions(
|
|||||||
var centersEnabled = ParseBoolean(
|
var centersEnabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"),
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED"),
|
||||||
configuredNativeCentersEnabled);
|
configuredNativeCentersEnabled);
|
||||||
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled) && !readsEnabled)
|
var operationalReadsEnabled = ParseBoolean(
|
||||||
|
Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED"),
|
||||||
|
configuredNativeOperationalReadsEnabled);
|
||||||
|
if ((organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled) && !readsEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
"启用原生组织维护接口前必须同时设置 ADMIN_NATIVE_READS_ENABLED=true");
|
||||||
}
|
}
|
||||||
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled;
|
var anyNativeAdminEndpointEnabled = readsEnabled || organizationWritesEnabled || accountBatchesEnabled || configurationEnabled || noticeManagementEnabled || centersEnabled || operationalReadsEnabled;
|
||||||
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
if (anyNativeAdminEndpointEnabled && !authenticationNativeEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
@@ -58,7 +63,7 @@ public sealed record AdminMigrationOptions(
|
|||||||
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
"管理端仍有接口需要转发给 Node;启用原生管理端接口必须配置共享 Redis 会话");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled);
|
return new AdminMigrationOptions(readsEnabled, organizationWritesEnabled, accountBatchesEnabled, configurationEnabled, noticeManagementEnabled, centersEnabled, operationalReadsEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Application.Administration;
|
||||||
|
using Eis.Infrastructure.Authentication;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed class AdminOperationalReadService(
|
||||||
|
IAuthenticationStateStore authenticationState,
|
||||||
|
AuthenticationRepository authenticationRepository,
|
||||||
|
AdminAccountBatchSnapshotLoader workflowSnapshotLoader,
|
||||||
|
AdminOperationalSnapshotLoader operationalSnapshotLoader) : IAdminOperationalReadService
|
||||||
|
{
|
||||||
|
public Task<AdminEndpointResult> GetCandidatesAsync(string sessionToken, CancellationToken cancellationToken) =>
|
||||||
|
ExecuteAsync(sessionToken, "candidates", cancellationToken);
|
||||||
|
|
||||||
|
public Task<AdminEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken) =>
|
||||||
|
ExecuteAsync(sessionToken, "registrations", cancellationToken);
|
||||||
|
|
||||||
|
public Task<AdminEndpointResult> GetPaymentsAsync(string sessionToken, CancellationToken cancellationToken) =>
|
||||||
|
ExecuteAsync(sessionToken, "payments", cancellationToken);
|
||||||
|
|
||||||
|
private async Task<AdminEndpointResult> ExecuteAsync(
|
||||||
|
string sessionToken,
|
||||||
|
string resource,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, cancellationToken);
|
||||||
|
if (context.Error is not null) return context.Error;
|
||||||
|
var user = context.User!;
|
||||||
|
var workflow = await workflowSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
var operational = await operationalSnapshotLoader.LoadAsync(cancellationToken);
|
||||||
|
return resource switch
|
||||||
|
{
|
||||||
|
"candidates" => Candidates(user, workflow, operational),
|
||||||
|
"registrations" => Registrations(user, workflow, operational),
|
||||||
|
_ => Payments(user, workflow, operational)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminEndpointResult Candidates(
|
||||||
|
AuthenticationUser user,
|
||||||
|
AdminAccountBatchSnapshot workflow,
|
||||||
|
AdminOperationalSnapshot operational)
|
||||||
|
{
|
||||||
|
var candidates = operational.Profiles.Where(profile => InScope(user, profile)).Select(profile =>
|
||||||
|
{
|
||||||
|
var output = profile.Data.DeepClone().AsObject();
|
||||||
|
var account = operational.Users.FirstOrDefault(item => item.Id == profile.UserId);
|
||||||
|
var idNumber = output["idNumber"]?.GetValue<string>() ?? "";
|
||||||
|
output["idNumberMasked"] = idNumber.StartsWith("PENDING-", StringComparison.Ordinal) ? "待考生补充" : MaskId(idNumber);
|
||||||
|
if (account is not null) output["username"] = account.Username;
|
||||||
|
output["candidateNumber"] = account?.CandidateNumber ?? "";
|
||||||
|
output["mustChangePassword"] = account?.MustChangePassword ?? false;
|
||||||
|
output["accountArchived"] = account?.ArchivedAt is not null;
|
||||||
|
output["archivedAt"] = JsonValue.Create(account?.ArchivedAt);
|
||||||
|
output["archivedByName"] = operational.Users.FirstOrDefault(item => item.Id == account?.ArchivedBy)?.DisplayName ?? "";
|
||||||
|
var registrations = operational.Registrations.Where(item => item.UserId == profile.UserId)
|
||||||
|
.OrderByDescending(item => ParseDate(item.CreatedAt))
|
||||||
|
.Select(item => RegistrationJson(workflow, operational, item)).ToArray();
|
||||||
|
output["registrations"] = new JsonArray(registrations);
|
||||||
|
var instance = WorkflowInstance(workflow, "profile_change", profile.Id);
|
||||||
|
output["workflow"] = instance is null ? null : WorkflowJson(workflow, instance);
|
||||||
|
return output;
|
||||||
|
}).ToArray();
|
||||||
|
var schools = workflow.Schools.Where(item => item.Active && (Level(user) == "super" || item.Id == user.SchoolId))
|
||||||
|
.Select(SchoolJson).ToArray();
|
||||||
|
var classes = workflow.Classes.Where(item => item.Active && (Level(user) == "super" || item.SchoolId == user.SchoolId))
|
||||||
|
.Select(ClassJson).ToArray();
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["candidates"] = new JsonArray(candidates),
|
||||||
|
["schools"] = new JsonArray(schools),
|
||||||
|
["classes"] = new JsonArray(classes)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminEndpointResult Registrations(
|
||||||
|
AuthenticationUser user,
|
||||||
|
AdminAccountBatchSnapshot workflow,
|
||||||
|
AdminOperationalSnapshot operational)
|
||||||
|
{
|
||||||
|
var registrations = operational.Registrations.Select(registration =>
|
||||||
|
{
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||||
|
if (profile is null || !InScope(user, profile)) return null;
|
||||||
|
var output = RegistrationJson(workflow, operational, registration);
|
||||||
|
var candidate = profile.Data.DeepClone().AsObject();
|
||||||
|
candidate["idNumber"] = MaskId(candidate["idNumber"]?.GetValue<string>() ?? "");
|
||||||
|
output["candidate"] = candidate;
|
||||||
|
var schoolClass = workflow.Classes.FirstOrDefault(item => item.Id == profile.ClassId);
|
||||||
|
output["schoolName"] = workflow.Schools.FirstOrDefault(item => item.Id == profile.SchoolId)?.Name ??
|
||||||
|
profile.Data["school"]?.GetValue<string>() ?? "";
|
||||||
|
output["gradeName"] = schoolClass?.Grade ?? "";
|
||||||
|
output["className"] = schoolClass?.Name ?? profile.Data["grade"]?.GetValue<string>() ?? "";
|
||||||
|
return output;
|
||||||
|
}).Where(item => item is not null).Cast<JsonObject>().ToArray();
|
||||||
|
return Success(new JsonObject { ["ok"] = true, ["registrations"] = new JsonArray(registrations) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminEndpointResult Payments(
|
||||||
|
AuthenticationUser user,
|
||||||
|
AdminAccountBatchSnapshot workflow,
|
||||||
|
AdminOperationalSnapshot operational)
|
||||||
|
{
|
||||||
|
var registrations = operational.Registrations.Where(item => item.Status == "approved").Select(registration =>
|
||||||
|
{
|
||||||
|
var profile = operational.Profiles.FirstOrDefault(item => item.UserId == registration.UserId);
|
||||||
|
if (profile is null || !InScope(user, profile)) return null;
|
||||||
|
var output = RegistrationJson(workflow, operational, registration);
|
||||||
|
var candidate = profile.Data.DeepClone().AsObject();
|
||||||
|
candidate["idNumber"] = MaskId(candidate["idNumber"]?.GetValue<string>() ?? "");
|
||||||
|
output["candidate"] = candidate;
|
||||||
|
var schoolClass = workflow.Classes.FirstOrDefault(item => item.Id == profile.ClassId);
|
||||||
|
output["schoolName"] = workflow.Schools.FirstOrDefault(item => item.Id == profile.SchoolId)?.Name ??
|
||||||
|
profile.Data["school"]?.GetValue<string>() ?? "";
|
||||||
|
output["gradeName"] = schoolClass?.Grade ?? "";
|
||||||
|
output["className"] = schoolClass?.Name ?? profile.Data["grade"]?.GetValue<string>() ?? "";
|
||||||
|
output["amountDue"] = AmountDue(output["subjects"] as JsonArray);
|
||||||
|
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
|
||||||
|
return output;
|
||||||
|
}).Where(item => item is not null).Cast<JsonObject>().ToArray();
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["scopeLabel"] = ScopeLabel(user, workflow),
|
||||||
|
["canConfirmPayment"] = true,
|
||||||
|
["canUpdatePayment"] = true,
|
||||||
|
["registrations"] = new JsonArray(registrations)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject RegistrationJson(
|
||||||
|
AdminAccountBatchSnapshot workflow,
|
||||||
|
AdminOperationalSnapshot operational,
|
||||||
|
OperationalRegistration registration)
|
||||||
|
{
|
||||||
|
var output = registration.Data.DeepClone().AsObject();
|
||||||
|
var exam = operational.Exams.FirstOrDefault(item => item.Id == registration.ExamId);
|
||||||
|
var subjectIds = output["subjectIds"]?.AsArray().Select(item => item?.GetValue<string>() ?? "").ToHashSet(StringComparer.Ordinal) ?? [];
|
||||||
|
var subjects = (exam?.Subjects ?? []).Where(item => subjectIds.Contains(item["id"]?.GetValue<string>() ?? ""))
|
||||||
|
.Select(item => item.DeepClone()).ToArray();
|
||||||
|
output["exam"] = exam?.Data.DeepClone();
|
||||||
|
output["subjects"] = new JsonArray(subjects);
|
||||||
|
output["amountDue"] = AmountDue(output["subjects"] as JsonArray);
|
||||||
|
output["paidByName"] = operational.Users.FirstOrDefault(item => item.Id == registration.PaidBy)?.DisplayName ?? "";
|
||||||
|
var instance = WorkflowInstance(workflow, "registration_review", registration.Id);
|
||||||
|
output["workflow"] = instance is null ? null : WorkflowJson(workflow, instance);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AccountWorkflowInstance? WorkflowInstance(AdminAccountBatchSnapshot snapshot, string businessType, string businessId) =>
|
||||||
|
snapshot.Instances.FirstOrDefault(item => item.BusinessType == businessType && item.BusinessId == businessId && item.Status == "pending") ??
|
||||||
|
snapshot.Instances.FirstOrDefault(item => item.BusinessType == businessType && item.BusinessId == businessId);
|
||||||
|
|
||||||
|
private static JsonObject WorkflowJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowInstance instance)
|
||||||
|
{
|
||||||
|
var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId);
|
||||||
|
var assignee = snapshot.Users.FirstOrDefault(item => item.Id == instance.AssigneeId);
|
||||||
|
var actions = snapshot.Actions.Where(item => item.InstanceId == instance.Id).Select(item => ActionJson(snapshot, item)).ToArray();
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = instance.Id,
|
||||||
|
["workflowId"] = instance.WorkflowId,
|
||||||
|
["businessType"] = instance.BusinessType,
|
||||||
|
["businessId"] = instance.BusinessId,
|
||||||
|
["status"] = instance.Status,
|
||||||
|
["currentStep"] = instance.CurrentStep,
|
||||||
|
["assigneeId"] = JsonValue.Create(instance.AssigneeId),
|
||||||
|
["createdAt"] = instance.CreatedAt,
|
||||||
|
["completedAt"] = JsonValue.Create(instance.CompletedAt),
|
||||||
|
["workflowName"] = workflow?.Name ?? "未命名流程",
|
||||||
|
["steps"] = new JsonArray((workflow?.Steps ?? []).Select(StepJson).ToArray()),
|
||||||
|
["currentStepDetail"] = workflow?.Steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } step ? StepJson(step) : null,
|
||||||
|
["assignee"] = assignee is null ? null : SafeUser(assignee),
|
||||||
|
["actions"] = new JsonArray(actions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResolvedAdmin> ResolveAsync(string token, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (token.Length == 0) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
var userId = await authenticationState.GetSessionUserIdAsync(token);
|
||||||
|
if (userId is null) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken);
|
||||||
|
if (user is not { Active: true, ArchivedAt: null }) return ResolvedAdmin.Failed(Error(401, "请先登录"));
|
||||||
|
return user.Role == "admin" ? new(user, null) : ResolvedAdmin.Failed(Error(403, "当前账号无权执行此操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool InScope(AuthenticationUser user, OperationalProfile profile) => Level(user) switch
|
||||||
|
{
|
||||||
|
"super" => true,
|
||||||
|
"school" => user.SchoolId is not null && profile.SchoolId == user.SchoolId,
|
||||||
|
_ => user.ClassId is not null && profile.ClassId == user.ClassId
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ScopeLabel(AuthenticationUser user, AdminAccountBatchSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (Level(user) == "super") return "全部学校与班级";
|
||||||
|
var school = snapshot.Schools.FirstOrDefault(item => item.Id == user.SchoolId)?.Name ?? "未绑定学校";
|
||||||
|
if (Level(user) == "school") return school;
|
||||||
|
var schoolClass = snapshot.Classes.FirstOrDefault(item => item.Id == user.ClassId)?.Name ?? "未绑定班级";
|
||||||
|
return $"{school} · {schoolClass}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double AmountDue(JsonArray? subjects) =>
|
||||||
|
Math.Round(subjects?.OfType<JsonObject>().Sum(item => item["fee"]?.GetValue<double>() ?? 0) ?? 0, 2, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
private static string MaskId(string value) => value.Length > 8 ? $"{value[..4]}********{value[^4..]}" : value;
|
||||||
|
private static DateTimeOffset ParseDate(string? value) => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : DateTimeOffset.MinValue;
|
||||||
|
private static string Level(AuthenticationUser user) => user.AdminLevel ?? "super";
|
||||||
|
private static JsonObject SchoolJson(AdminSchool item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["code"] = item.Code, ["address"] = item.Address, ["isSourceSchool"] = item.IsSourceSchool, ["isAdmissionSchool"] = item.IsAdmissionSchool, ["active"] = item.Active };
|
||||||
|
private static JsonObject ClassJson(AdminClass item) => new() { ["id"] = item.Id, ["schoolId"] = item.SchoolId, ["name"] = item.Name, ["grade"] = item.Grade, ["active"] = item.Active };
|
||||||
|
private static JsonObject StepJson(AccountWorkflowStep item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["adminLevel"] = item.AdminLevel, ["position"] = item.Position };
|
||||||
|
private static JsonObject ActionJson(AdminAccountBatchSnapshot snapshot, AccountWorkflowAction item) => new() { ["id"] = item.Id, ["instanceId"] = item.InstanceId, ["actorId"] = JsonValue.Create(item.ActorId), ["action"] = item.Action, ["note"] = item.Note, ["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId), ["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId), ["createdAt"] = item.CreatedAt, ["actorName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ActorId)?.DisplayName ?? "系统", ["fromAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.FromAssigneeId)?.DisplayName ?? "", ["toAssigneeName"] = snapshot.Users.FirstOrDefault(user => user.Id == item.ToAssigneeId)?.DisplayName ?? "" };
|
||||||
|
private static JsonObject SafeUser(AdminUser item) => new() { ["id"] = item.Id, ["username"] = item.Username, ["role"] = item.Role, ["adminLevel"] = item.Role == "admin" ? item.AdminLevel ?? "super" : null, ["schoolId"] = JsonValue.Create(item.SchoolId), ["classId"] = JsonValue.Create(item.ClassId), ["displayName"] = item.DisplayName, ["candidateNumber"] = JsonValue.Create(item.CandidateNumber), ["mustChangePassword"] = item.MustChangePassword, ["totpEnabled"] = item.TotpEnabled, ["archived"] = item.ArchivedAt is not null };
|
||||||
|
private static AdminEndpointResult Success(JsonObject body) => new(200, body);
|
||||||
|
private static AdminEndpointResult Error(int status, string message) => new(status, new JsonObject { ["ok"] = false, ["message"] = message });
|
||||||
|
private sealed record ResolvedAdmin(AuthenticationUser? User, AdminEndpointResult? Error) { public static ResolvedAdmin Failed(AdminEndpointResult error) => new(null, error); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Administration;
|
||||||
|
|
||||||
|
internal sealed record AdminOperationalSnapshot(
|
||||||
|
IReadOnlyList<OperationalUser> Users,
|
||||||
|
IReadOnlyList<OperationalProfile> Profiles,
|
||||||
|
IReadOnlyList<OperationalExam> Exams,
|
||||||
|
IReadOnlyList<OperationalRegistration> Registrations);
|
||||||
|
|
||||||
|
internal sealed record OperationalUser(
|
||||||
|
string Id,
|
||||||
|
string Username,
|
||||||
|
string CandidateNumber,
|
||||||
|
bool MustChangePassword,
|
||||||
|
string? ArchivedAt,
|
||||||
|
string? ArchivedBy,
|
||||||
|
string DisplayName);
|
||||||
|
|
||||||
|
internal sealed record OperationalProfile(
|
||||||
|
string Id,
|
||||||
|
string UserId,
|
||||||
|
string? SchoolId,
|
||||||
|
string? ClassId,
|
||||||
|
JsonObject Data);
|
||||||
|
|
||||||
|
internal sealed record OperationalExam(string Id, JsonObject Data, IReadOnlyList<JsonObject> Subjects);
|
||||||
|
|
||||||
|
internal sealed record OperationalRegistration(
|
||||||
|
string Id,
|
||||||
|
string UserId,
|
||||||
|
string ExamId,
|
||||||
|
string Status,
|
||||||
|
string PaymentStatus,
|
||||||
|
string? PaidBy,
|
||||||
|
string CreatedAt,
|
||||||
|
JsonObject Data);
|
||||||
|
|
||||||
|
internal sealed class AdminOperationalSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
public async Task<AdminOperationalSnapshot> LoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
var users = await QueryAsync(connection,
|
||||||
|
"SELECT id, username, candidate_number, must_change_password, archived_at, archived_by, display_name FROM users ORDER BY created_at, id",
|
||||||
|
reader => new OperationalUser(
|
||||||
|
Text(reader, "id"), Text(reader, "username"), Optional(reader, "candidate_number") ?? "",
|
||||||
|
Boolean(reader, "must_change_password"), Optional(reader, "archived_at"), Optional(reader, "archived_by"), Text(reader, "display_name")),
|
||||||
|
cancellationToken);
|
||||||
|
var profiles = await QueryAsync(connection,
|
||||||
|
"""
|
||||||
|
SELECT id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
||||||
|
province_code, province_name, city_code, city_name, district_code, district_name, address,
|
||||||
|
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code,
|
||||||
|
guardian_name, guardian_phone, specialty_category, specialty_type, specialty_types,
|
||||||
|
specialty_certificate, policy_eligibility, profile_completed, status, review_note,
|
||||||
|
reviewed_at, reviewer_id, updated_at
|
||||||
|
FROM candidate_profiles ORDER BY updated_at, id
|
||||||
|
""",
|
||||||
|
ReadProfile,
|
||||||
|
cancellationToken);
|
||||||
|
var subjectRows = await QueryAsync(connection,
|
||||||
|
"""
|
||||||
|
SELECT id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score,
|
||||||
|
pass_rule, pass_value, position FROM exam_subjects ORDER BY exam_id, position, id
|
||||||
|
""",
|
||||||
|
ReadSubject,
|
||||||
|
cancellationToken);
|
||||||
|
var subjectsByExam = subjectRows.GroupBy(item => item.ExamId)
|
||||||
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<JsonObject>)group.Select(item => item.Data).ToArray(), StringComparer.Ordinal);
|
||||||
|
var exams = await QueryAsync(connection,
|
||||||
|
"""
|
||||||
|
SELECT id, code, name, description, registration_start, registration_end, exam_start, exam_end,
|
||||||
|
admit_download_start, admit_download_end, location, pass_policy, pass_value, status,
|
||||||
|
archived_at, archived_by, created_at FROM exams ORDER BY created_at, id
|
||||||
|
""",
|
||||||
|
reader => ReadExam(reader, subjectsByExam),
|
||||||
|
cancellationToken);
|
||||||
|
var registrationSubjects = await QueryAsync(connection,
|
||||||
|
"SELECT registration_id, subject_id FROM registration_subjects ORDER BY registration_id, subject_id",
|
||||||
|
reader => new RegistrationSubject(Text(reader, "registration_id"), Text(reader, "subject_id")),
|
||||||
|
cancellationToken);
|
||||||
|
var subjectIdsByRegistration = registrationSubjects.GroupBy(item => item.RegistrationId)
|
||||||
|
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(item => item.SubjectId).ToArray(), StringComparer.Ordinal);
|
||||||
|
var subjectOrder = subjectRows.ToDictionary(item => item.Id, item => item.Order, StringComparer.Ordinal);
|
||||||
|
var assignments = await QueryAsync(connection,
|
||||||
|
"""
|
||||||
|
SELECT registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor,
|
||||||
|
seat, subject_signature FROM admit_card_subjects ORDER BY registration_id, subject_id
|
||||||
|
""",
|
||||||
|
ReadAssignment,
|
||||||
|
cancellationToken);
|
||||||
|
var assignmentsByRegistration = assignments.GroupBy(item => item.RegistrationId)
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => (IReadOnlyList<JsonObject>)group.OrderBy(item => subjectOrder.GetValueOrDefault(item.SubjectId)).Select(item => item.Data).ToArray(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
var cards = await QueryAsync(connection,
|
||||||
|
"""
|
||||||
|
SELECT registration_id, plan_id, card_number, center_id, test_center, center_code,
|
||||||
|
center_address, generated_at FROM admit_cards ORDER BY registration_id
|
||||||
|
""",
|
||||||
|
reader => ReadCard(reader, assignmentsByRegistration),
|
||||||
|
cancellationToken);
|
||||||
|
var cardsByRegistration = cards.ToDictionary(item => item.RegistrationId, item => item.Data, StringComparer.Ordinal);
|
||||||
|
var registrations = await QueryAsync(connection,
|
||||||
|
"""
|
||||||
|
SELECT id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at,
|
||||||
|
review_note, registration_number, number_rule_id, feature_score
|
||||||
|
FROM registrations ORDER BY created_at, id
|
||||||
|
""",
|
||||||
|
reader => ReadRegistration(reader, subjectIdsByRegistration, cardsByRegistration),
|
||||||
|
cancellationToken);
|
||||||
|
return new(users, profiles, exams, registrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OperationalProfile ReadProfile(DbDataReader reader)
|
||||||
|
{
|
||||||
|
var id = Text(reader, "id");
|
||||||
|
var userId = Text(reader, "user_id");
|
||||||
|
var schoolId = Optional(reader, "school_id");
|
||||||
|
var classId = Optional(reader, "class_id");
|
||||||
|
return new(id, userId, schoolId, classId, new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = id,
|
||||||
|
["userId"] = userId,
|
||||||
|
["name"] = Text(reader, "name"),
|
||||||
|
["gender"] = Optional(reader, "gender") ?? "",
|
||||||
|
["idNumber"] = Text(reader, "id_number"),
|
||||||
|
["phone"] = Text(reader, "phone"),
|
||||||
|
["email"] = Optional(reader, "email") ?? "",
|
||||||
|
["school"] = Optional(reader, "school") ?? "",
|
||||||
|
["grade"] = Optional(reader, "grade") ?? "",
|
||||||
|
["schoolId"] = JsonValue.Create(schoolId),
|
||||||
|
["classId"] = JsonValue.Create(classId),
|
||||||
|
["provinceCode"] = Optional(reader, "province_code") ?? "",
|
||||||
|
["provinceName"] = Optional(reader, "province_name") ?? "",
|
||||||
|
["cityCode"] = Optional(reader, "city_code") ?? "",
|
||||||
|
["cityName"] = Optional(reader, "city_name") ?? "",
|
||||||
|
["districtCode"] = Optional(reader, "district_code") ?? "",
|
||||||
|
["districtName"] = Optional(reader, "district_name") ?? "",
|
||||||
|
["address"] = Optional(reader, "address") ?? "",
|
||||||
|
["emergencyContact"] = Optional(reader, "emergency_contact") ?? "",
|
||||||
|
["emergencyPhone"] = Optional(reader, "emergency_phone") ?? "",
|
||||||
|
["nativePlace"] = Optional(reader, "native_place") ?? "",
|
||||||
|
["birthDate"] = Optional(reader, "birth_date") ?? "",
|
||||||
|
["ethnicity"] = Optional(reader, "ethnicity") ?? "",
|
||||||
|
["postalCode"] = Optional(reader, "postal_code") ?? "",
|
||||||
|
["guardianName"] = Optional(reader, "guardian_name") ?? "",
|
||||||
|
["guardianPhone"] = Optional(reader, "guardian_phone") ?? "",
|
||||||
|
["specialtyCategory"] = Optional(reader, "specialty_category") ?? "",
|
||||||
|
["specialtyType"] = Optional(reader, "specialty_type") ?? "",
|
||||||
|
["specialtyTypes"] = ParseArray(Optional(reader, "specialty_types")),
|
||||||
|
["specialtyCertificate"] = Optional(reader, "specialty_certificate") ?? "",
|
||||||
|
["policyEligibility"] = Optional(reader, "policy_eligibility") ?? "",
|
||||||
|
["profileCompleted"] = Boolean(reader, "profile_completed"),
|
||||||
|
["status"] = Text(reader, "status"),
|
||||||
|
["reviewNote"] = Optional(reader, "review_note") ?? "",
|
||||||
|
["reviewedAt"] = JsonValue.Create(Optional(reader, "reviewed_at")),
|
||||||
|
["reviewerId"] = JsonValue.Create(Optional(reader, "reviewer_id")),
|
||||||
|
["updatedAt"] = Text(reader, "updated_at")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubjectRow ReadSubject(DbDataReader reader)
|
||||||
|
{
|
||||||
|
var fullScore = Number(reader, "full_score", 150);
|
||||||
|
var rawRule = Optional(reader, "pass_rule") ?? "fixed_score";
|
||||||
|
var passRule = rawRule == "score_ratio" ? "rank_percent" : rawRule;
|
||||||
|
var passValue = NullableNumber(reader, "pass_value") ?? NullableNumber(reader, "pass_score") ?? fullScore * 0.6;
|
||||||
|
var order = Integer(reader, "position");
|
||||||
|
return new(Text(reader, "id"), Text(reader, "exam_id"), order, new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = Text(reader, "id"),
|
||||||
|
["name"] = Text(reader, "name"),
|
||||||
|
["date"] = Text(reader, "subject_date"),
|
||||||
|
["start"] = Text(reader, "start_time"),
|
||||||
|
["end"] = Text(reader, "end_time"),
|
||||||
|
["fee"] = Number(reader, "fee"),
|
||||||
|
["fullScore"] = fullScore,
|
||||||
|
["passRule"] = passRule,
|
||||||
|
["passValue"] = passValue,
|
||||||
|
["passScore"] = passRule == "fixed_score" ? Math.Round(passValue, 2, MidpointRounding.AwayFromZero) : null,
|
||||||
|
["order"] = order
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OperationalExam ReadExam(DbDataReader reader, IReadOnlyDictionary<string, IReadOnlyList<JsonObject>> subjectsByExam)
|
||||||
|
{
|
||||||
|
var id = Text(reader, "id");
|
||||||
|
var subjects = subjectsByExam.GetValueOrDefault(id) ?? [];
|
||||||
|
var rawPolicy = Optional(reader, "pass_policy") ?? "rank_percent";
|
||||||
|
var data = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = id,
|
||||||
|
["code"] = Text(reader, "code"),
|
||||||
|
["name"] = Text(reader, "name"),
|
||||||
|
["description"] = Text(reader, "description"),
|
||||||
|
["registrationStart"] = Text(reader, "registration_start"),
|
||||||
|
["registrationEnd"] = Text(reader, "registration_end"),
|
||||||
|
["examStart"] = Text(reader, "exam_start"),
|
||||||
|
["examEnd"] = Text(reader, "exam_end"),
|
||||||
|
["admitDownloadStart"] = Text(reader, "admit_download_start"),
|
||||||
|
["admitDownloadEnd"] = Text(reader, "admit_download_end"),
|
||||||
|
["location"] = Text(reader, "location"),
|
||||||
|
["passPolicy"] = rawPolicy == "score_ratio" ? "rank_percent" : rawPolicy,
|
||||||
|
["passValue"] = Number(reader, "pass_value", 60),
|
||||||
|
["status"] = Text(reader, "status"),
|
||||||
|
["archivedAt"] = JsonValue.Create(Optional(reader, "archived_at")),
|
||||||
|
["archivedBy"] = JsonValue.Create(Optional(reader, "archived_by")),
|
||||||
|
["createdAt"] = Text(reader, "created_at"),
|
||||||
|
["subjects"] = new JsonArray(subjects.Select(item => item.DeepClone()).ToArray())
|
||||||
|
};
|
||||||
|
return new(id, data, subjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AssignmentRow ReadAssignment(DbDataReader reader)
|
||||||
|
{
|
||||||
|
var subjectId = Text(reader, "subject_id");
|
||||||
|
var room = Text(reader, "room");
|
||||||
|
return new(Text(reader, "registration_id"), subjectId, new JsonObject
|
||||||
|
{
|
||||||
|
["subjectId"] = subjectId,
|
||||||
|
["roomId"] = JsonValue.Create(Optional(reader, "room_id")),
|
||||||
|
["roomName"] = room,
|
||||||
|
["room"] = room,
|
||||||
|
["roomCode"] = Text(reader, "room_code"),
|
||||||
|
["examRoomCode"] = Text(reader, "exam_room_code"),
|
||||||
|
["building"] = Optional(reader, "building") ?? "",
|
||||||
|
["floor"] = Optional(reader, "floor") ?? "",
|
||||||
|
["seat"] = Text(reader, "seat"),
|
||||||
|
["subjectSignature"] = Optional(reader, "subject_signature") ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CardRow ReadCard(DbDataReader reader, IReadOnlyDictionary<string, IReadOnlyList<JsonObject>> assignmentsByRegistration)
|
||||||
|
{
|
||||||
|
var registrationId = Text(reader, "registration_id");
|
||||||
|
var assignments = assignmentsByRegistration.GetValueOrDefault(registrationId) ?? [];
|
||||||
|
var primary = assignments.FirstOrDefault();
|
||||||
|
return new(registrationId, new JsonObject
|
||||||
|
{
|
||||||
|
["planId"] = JsonValue.Create(Optional(reader, "plan_id")),
|
||||||
|
["number"] = Text(reader, "card_number"),
|
||||||
|
["centerId"] = JsonValue.Create(Optional(reader, "center_id")),
|
||||||
|
["testCenter"] = Text(reader, "test_center"),
|
||||||
|
["centerCode"] = Optional(reader, "center_code") ?? "",
|
||||||
|
["centerAddress"] = Optional(reader, "center_address") ?? "",
|
||||||
|
["room"] = primary?["roomName"]?.GetValue<string>() ?? "",
|
||||||
|
["seat"] = primary?["seat"]?.GetValue<string>() ?? "",
|
||||||
|
["assignments"] = new JsonArray(assignments.Select(item => item.DeepClone()).ToArray()),
|
||||||
|
["generatedAt"] = Text(reader, "generated_at")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OperationalRegistration ReadRegistration(
|
||||||
|
DbDataReader reader,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<string>> subjectIdsByRegistration,
|
||||||
|
IReadOnlyDictionary<string, JsonObject> cardsByRegistration)
|
||||||
|
{
|
||||||
|
var id = Text(reader, "id");
|
||||||
|
var userId = Text(reader, "user_id");
|
||||||
|
var examId = Text(reader, "exam_id");
|
||||||
|
var status = Text(reader, "status");
|
||||||
|
var paymentStatus = Text(reader, "payment_status");
|
||||||
|
var paidBy = Optional(reader, "paid_by");
|
||||||
|
var createdAt = Text(reader, "created_at");
|
||||||
|
return new(id, userId, examId, status, paymentStatus, paidBy, createdAt, new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = id,
|
||||||
|
["userId"] = userId,
|
||||||
|
["examId"] = examId,
|
||||||
|
["subjectIds"] = new JsonArray((subjectIdsByRegistration.GetValueOrDefault(id) ?? []).Select(subjectId => JsonValue.Create(subjectId)).ToArray()),
|
||||||
|
["status"] = status,
|
||||||
|
["paymentStatus"] = paymentStatus,
|
||||||
|
["paidAt"] = JsonValue.Create(Optional(reader, "paid_at")),
|
||||||
|
["paidBy"] = JsonValue.Create(paidBy),
|
||||||
|
["createdAt"] = createdAt,
|
||||||
|
["reviewedAt"] = JsonValue.Create(Optional(reader, "reviewed_at")),
|
||||||
|
["reviewNote"] = Optional(reader, "review_note") ?? "",
|
||||||
|
["registrationNumber"] = Optional(reader, "registration_number") ?? "",
|
||||||
|
["numberRuleId"] = JsonValue.Create(Optional(reader, "number_rule_id")),
|
||||||
|
["featureScore"] = Number(reader, "feature_score"),
|
||||||
|
["admitCard"] = cardsByRegistration.TryGetValue(id, out var card) ? card.DeepClone() : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<T>> QueryAsync<T>(DbConnection connection, string sql, Func<DbDataReader, T> map, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
var output = new List<T>();
|
||||||
|
while (await reader.ReadAsync(cancellationToken)) output.Add(map(reader));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonArray ParseArray(string? value)
|
||||||
|
{
|
||||||
|
try { return JsonNode.Parse(value ?? "[]") as JsonArray ?? []; }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(DbDataReader reader, string name) => Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? "";
|
||||||
|
private static string? Optional(DbDataReader reader, string name) { var ordinal = reader.GetOrdinal(name); return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture); }
|
||||||
|
private static bool Boolean(DbDataReader reader, string name) => Convert.ToInt64(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) != 0;
|
||||||
|
private static int Integer(DbDataReader reader, string name) => Convert.ToInt32(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture);
|
||||||
|
private static double Number(DbDataReader reader, string name, double fallback = 0) { var ordinal = reader.GetOrdinal(name); return reader.IsDBNull(ordinal) ? fallback : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture); }
|
||||||
|
private static double? NullableNumber(DbDataReader reader, string name) { var ordinal = reader.GetOrdinal(name); return reader.IsDBNull(ordinal) ? null : Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture); }
|
||||||
|
private sealed record SubjectRow(string Id, string ExamId, int Order, JsonObject Data);
|
||||||
|
private sealed record RegistrationSubject(string RegistrationId, string SubjectId);
|
||||||
|
private sealed record AssignmentRow(string RegistrationId, string SubjectId, JsonObject Data);
|
||||||
|
private sealed record CardRow(string RegistrationId, JsonObject Data);
|
||||||
|
}
|
||||||
@@ -60,12 +60,14 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<AdminNoticeRepository>();
|
services.AddScoped<AdminNoticeRepository>();
|
||||||
services.AddScoped<AdminCenterSnapshotLoader>();
|
services.AddScoped<AdminCenterSnapshotLoader>();
|
||||||
services.AddScoped<AdminCenterRepository>();
|
services.AddScoped<AdminCenterRepository>();
|
||||||
|
services.AddScoped<AdminOperationalSnapshotLoader>();
|
||||||
services.AddScoped<IAdminReadService, AdminReadService>();
|
services.AddScoped<IAdminReadService, AdminReadService>();
|
||||||
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
services.AddScoped<IAdminOrganizationService, AdminOrganizationService>();
|
||||||
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
services.AddScoped<IAdminAccountBatchService, AdminAccountBatchService>();
|
||||||
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
|
services.AddScoped<IAdminConfigurationService, AdminConfigurationService>();
|
||||||
services.AddScoped<IAdminNoticeService, AdminNoticeService>();
|
services.AddScoped<IAdminNoticeService, AdminNoticeService>();
|
||||||
services.AddScoped<IAdminCenterService, AdminCenterService>();
|
services.AddScoped<IAdminCenterService, AdminCenterService>();
|
||||||
|
services.AddScoped<IAdminOperationalReadService, AdminOperationalReadService>();
|
||||||
services.AddSingleton<NoticeContentFormatter>();
|
services.AddSingleton<NoticeContentFormatter>();
|
||||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ public static class NativeAdminReadEndpoints
|
|||||||
endpoints.MapPatch("/api/admin/center-change-requests/{changeRequestId}", (HttpContext context, string changeRequestId, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) =>
|
endpoints.MapPatch("/api/admin/center-change-requests/{changeRequestId}", (HttpContext context, string changeRequestId, JsonObject body, IAdminCenterService service, CancellationToken cancellationToken) =>
|
||||||
Execute(context, service.ReviewAsync(Token(context), changeRequestId, body, cancellationToken)));
|
Execute(context, service.ReviewAsync(Token(context), changeRequestId, body, cancellationToken)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.NativeOperationalReadsEnabled)
|
||||||
|
{
|
||||||
|
endpoints.MapGet("/api/admin/candidates", (HttpContext context, IAdminOperationalReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetCandidatesAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/admin/registrations", (HttpContext context, IAdminOperationalReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetRegistrationsAsync(Token(context), cancellationToken)));
|
||||||
|
endpoints.MapGet("/api/admin/payments", (HttpContext context, IAdminOperationalReadService service, CancellationToken cancellationToken) =>
|
||||||
|
Execute(context, service.GetPaymentsAsync(Token(context), cancellationToken)));
|
||||||
|
}
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ var adminMigrationOptions = AdminMigrationOptions.FromEnvironment(
|
|||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeAccountBatchesEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeConfigurationEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled"),
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeNoticeManagementEnabled"),
|
||||||
builder.Configuration.GetValue<bool>("AdminMigration:NativeCentersEnabled"));
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeCentersEnabled"),
|
||||||
|
builder.Configuration.GetValue<bool>("AdminMigration:NativeOperationalReadsEnabled"));
|
||||||
builder.Services.AddEisInfrastructure(
|
builder.Services.AddEisInfrastructure(
|
||||||
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
|
||||||
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
|
||||||
@@ -110,6 +111,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled,
|
nativeConfigurationEnabled = adminMigrationOptions.NativeConfigurationEnabled,
|
||||||
nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled,
|
nativeNoticeManagementEnabled = adminMigrationOptions.NativeNoticeManagementEnabled,
|
||||||
nativeCentersEnabled = adminMigrationOptions.NativeCentersEnabled,
|
nativeCentersEnabled = adminMigrationOptions.NativeCentersEnabled,
|
||||||
|
nativeOperationalReadsEnabled = adminMigrationOptions.NativeOperationalReadsEnabled,
|
||||||
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
nativeRoutes = (adminMigrationOptions.NativeReadsEnabled
|
||||||
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
? new[] { "GET context", "GET dashboard", "GET schools", "GET school-organization", "GET admins", "GET exams" }
|
||||||
: [])
|
: [])
|
||||||
@@ -132,6 +134,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
.Concat(adminMigrationOptions.NativeCentersEnabled
|
.Concat(adminMigrationOptions.NativeCentersEnabled
|
||||||
? new[] { "GET/POST/PATCH centers", "PATCH center-change-requests" }
|
? new[] { "GET/POST/PATCH centers", "PATCH center-change-requests" }
|
||||||
: [])
|
: [])
|
||||||
|
.Concat(adminMigrationOptions.NativeOperationalReadsEnabled
|
||||||
|
? new[] { "GET candidates", "GET registrations", "GET payments" }
|
||||||
|
: [])
|
||||||
.ToArray()
|
.ToArray()
|
||||||
},
|
},
|
||||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled, candidateMigrationOptions.NativeEnabled)
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"NativeAccountBatchesEnabled": false,
|
"NativeAccountBatchesEnabled": false,
|
||||||
"NativeConfigurationEnabled": false,
|
"NativeConfigurationEnabled": false,
|
||||||
"NativeNoticeManagementEnabled": false,
|
"NativeNoticeManagementEnabled": false,
|
||||||
"NativeCentersEnabled": false
|
"NativeCentersEnabled": false,
|
||||||
|
"NativeOperationalReadsEnabled": false
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|||||||
@@ -166,6 +166,21 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
}, centersEnabled: "true");
|
}, centersEnabled: "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnablesOperationalReadsWithBaseReadsAndSharedSessions()
|
||||||
|
{
|
||||||
|
WithEnvironment("true", null, null, () =>
|
||||||
|
{
|
||||||
|
var options = AdminMigrationOptions.FromEnvironment(
|
||||||
|
configuredNativeReadsEnabled: false,
|
||||||
|
authenticationNativeEnabled: true,
|
||||||
|
sharesLegacySessions: true);
|
||||||
|
|
||||||
|
Assert.True(options.NativeReadsEnabled);
|
||||||
|
Assert.True(options.NativeOperationalReadsEnabled);
|
||||||
|
}, operationalReadsEnabled: "true");
|
||||||
|
}
|
||||||
|
|
||||||
private static void WithEnvironment(
|
private static void WithEnvironment(
|
||||||
string? nativeReadsEnabled,
|
string? nativeReadsEnabled,
|
||||||
string? nativeOrganizationWritesEnabled,
|
string? nativeOrganizationWritesEnabled,
|
||||||
@@ -175,7 +190,8 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
string? configurationEnabled = null,
|
string? configurationEnabled = null,
|
||||||
string? noticeManagementEnabled = null,
|
string? noticeManagementEnabled = null,
|
||||||
string? legacyNoticeWritesEnabled = null,
|
string? legacyNoticeWritesEnabled = null,
|
||||||
string? centersEnabled = null)
|
string? centersEnabled = null,
|
||||||
|
string? operationalReadsEnabled = null)
|
||||||
{
|
{
|
||||||
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
|
var previousNativeReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_READS_ENABLED");
|
||||||
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
|
var previousNativeOrganizationWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ORGANIZATION_WRITES_ENABLED");
|
||||||
@@ -184,6 +200,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
var previousNoticeManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED");
|
var previousNoticeManagementEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED");
|
||||||
var previousLegacyNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED");
|
var previousLegacyNoticeWritesEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED");
|
||||||
var previousCentersEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED");
|
var previousCentersEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED");
|
||||||
|
var previousOperationalReadsEnabled = Environment.GetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED");
|
||||||
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
var previousAllowMemory = Environment.GetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -194,6 +211,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", noticeManagementEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", noticeManagementEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", legacyNoticeWritesEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", centersEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", centersEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", operationalReadsEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", allowMemory);
|
||||||
test();
|
test();
|
||||||
}
|
}
|
||||||
@@ -206,6 +224,7 @@ public sealed class AdminMigrationOptionsTests
|
|||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", previousNoticeManagementEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_MANAGEMENT_ENABLED", previousNoticeManagementEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_NOTICE_WRITES_ENABLED", previousLegacyNoticeWritesEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", previousCentersEnabled);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_CENTERS_ENABLED", previousCentersEnabled);
|
||||||
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_OPERATIONAL_READS_ENABLED", previousOperationalReadsEnabled);
|
||||||
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
Environment.SetEnvironmentVariable("ADMIN_NATIVE_ALLOW_MEMORY", previousAllowMemory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user