PUT /api/candidate/profile
行政区划、学校班级、特长类别校验 证件号码唯一性 自动创建资料审核工作流 资料和用户显示名称原子更新 POST /api/candidate/registrations 资料审核状态、报名时间和科目校验 重复报名防护 自动选择负载最低的审批管理员 报名、科目和审批流原子写入
This commit is contained in:
+1
-1
@@ -47,7 +47,7 @@ $env:AUTH_NATIVE_ENABLED = 'true'
|
|||||||
|
|
||||||
开发环境未配置 Redis 时可以使用进程内状态独立验证原生认证。生产环境以及仍需访问 Node 受保护接口的联调环境必须配置 `REDIS_URL` 或 `REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力。
|
开发环境未配置 Redis 时可以使用进程内状态独立验证原生认证。生产环境以及仍需访问 Node 受保护接口的联调环境必须配置 `REDIS_URL` 或 `REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力。
|
||||||
|
|
||||||
考生域第一批只读端点(首页、通知、个人资料、可报名考试、我的报名)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用。资料更新、报名提交、成绩、准考证、复议和志愿填报仍会继续转发给 Node:
|
考生域核心端点(首页、通知、个人资料读取与提交、可报名考试、我的报名读取与提交)可通过以下开关原生运行;该开关必须与原生认证及共享 Redis 同时启用。成绩、准考证、复议和志愿填报仍会继续转发给 Node:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:AUTH_NATIVE_ENABLED = 'true'
|
$env:AUTH_NATIVE_ENABLED = 'true'
|
||||||
|
|||||||
@@ -440,6 +440,108 @@ try {
|
|||||||
throw 'Native candidate profile route was not available during onboarding'
|
throw 'Native candidate profile route was not available during onboarding'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$invalidSpecialtyBody = @{
|
||||||
|
name = '原生迁移注册考生'
|
||||||
|
gender = '女'
|
||||||
|
idNumber = 'SMOKE-INVALID-SPECIALTY'
|
||||||
|
phone = '13800000000'
|
||||||
|
email = 'candidate@example.test'
|
||||||
|
nativePlace = '江苏连云港'
|
||||||
|
address = '迁移测试路 1 号'
|
||||||
|
schoolId = $registrationSchool.id
|
||||||
|
classId = $registrationClass.id
|
||||||
|
provinceCode = '320000'
|
||||||
|
cityCode = '320700'
|
||||||
|
districtCode = '320706'
|
||||||
|
specialtyCategory = 'arts'
|
||||||
|
specialtyType = 'track_field'
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$invalidSpecialty = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -Method Put -ContentType 'application/json' -Body $invalidSpecialtyBody -WebSession $registeredSession -SkipHttpErrorCheck
|
||||||
|
if ($invalidSpecialty.StatusCode -ne 400) {
|
||||||
|
throw 'Native candidate profile API accepted a mismatched specialty category and type'
|
||||||
|
}
|
||||||
|
|
||||||
|
$profileIdNumber = "SMOKE-NATIVE-$([guid]::NewGuid().ToString('N'))"
|
||||||
|
$profileBody = @{
|
||||||
|
name = '原生迁移注册考生'
|
||||||
|
gender = '女'
|
||||||
|
idNumber = $profileIdNumber
|
||||||
|
phone = '13800000000'
|
||||||
|
email = 'candidate@example.test'
|
||||||
|
address = '迁移测试路 1 号'
|
||||||
|
emergencyContact = '测试联系人'
|
||||||
|
emergencyPhone = '13900000000'
|
||||||
|
nativePlace = '江苏连云港'
|
||||||
|
birthDate = '2010-01-02'
|
||||||
|
ethnicity = '汉族'
|
||||||
|
postalCode = '222000'
|
||||||
|
guardianName = '测试监护人'
|
||||||
|
guardianPhone = '13700000000'
|
||||||
|
schoolId = $registrationSchool.id
|
||||||
|
classId = $registrationClass.id
|
||||||
|
provinceCode = '320000'
|
||||||
|
cityCode = '320700'
|
||||||
|
districtCode = '320706'
|
||||||
|
specialtyCategory = ''
|
||||||
|
specialtyType = ''
|
||||||
|
specialtyCertificate = ''
|
||||||
|
policyEligibility = ''
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$profileUpdateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/profile" -Method Put -ContentType 'application/json' -Body $profileBody -WebSession $registeredSession
|
||||||
|
if ($profileUpdateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Candidate profile update did not use the native ASP.NET Core endpoint'
|
||||||
|
}
|
||||||
|
$profileUpdate = $profileUpdateResponse.Content | ConvertFrom-Json
|
||||||
|
if ($profileUpdate.profile.profileCompleted -ne $true -or $profileUpdate.profile.districtName -ne '海州区' -or $profileUpdate.profile.status -ne 'pending') {
|
||||||
|
throw 'Native candidate profile update did not resolve the region or persist onboarding state'
|
||||||
|
}
|
||||||
|
|
||||||
|
$prematureRegistrationBody = @{ examId = 'not-approved-yet'; subjectIds = @('none') } | ConvertTo-Json -Compress
|
||||||
|
$prematureRegistration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $prematureRegistrationBody -WebSession $registeredSession -SkipHttpErrorCheck
|
||||||
|
if ($prematureRegistration.StatusCode -ne 403) {
|
||||||
|
throw 'Native registration API did not require an approved candidate profile'
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateWriteSetup = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
|
||||||
|
'tests/helpers/prepare-candidate-write-smoke.mjs', $smokeDatabasePath, $registration.registrationNumber
|
||||||
|
) -Environment $nodeEnvironment
|
||||||
|
if (-not $candidateWriteSetup.WaitForExit(10000)) {
|
||||||
|
$candidateWriteSetup.Kill($true)
|
||||||
|
throw 'Timed out while preparing the native candidate write smoke test'
|
||||||
|
}
|
||||||
|
$candidateWriteSetupOutput = $candidateWriteSetup.StandardOutput.ReadToEnd().Trim()
|
||||||
|
$candidateWriteSetupError = $candidateWriteSetup.StandardError.ReadToEnd()
|
||||||
|
if ($candidateWriteSetup.ExitCode -ne 0) {
|
||||||
|
throw "Could not prepare the native candidate write smoke test`n$candidateWriteSetupError"
|
||||||
|
}
|
||||||
|
$candidateWriteSetup.Dispose()
|
||||||
|
$candidateWriteTarget = $candidateWriteSetupOutput | ConvertFrom-Json
|
||||||
|
|
||||||
|
$nativeRegistrationBody = @{
|
||||||
|
examId = $candidateWriteTarget.examId
|
||||||
|
subjectIds = @($candidateWriteTarget.subjectId)
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$nativeRegistrationResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $nativeRegistrationBody -WebSession $registeredSession
|
||||||
|
if ($nativeRegistrationResponse.StatusCode -ne 201 -or $nativeRegistrationResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
|
throw 'Exam registration submission did not use the native ASP.NET Core endpoint'
|
||||||
|
}
|
||||||
|
$nativeRegistration = $nativeRegistrationResponse.Content | ConvertFrom-Json
|
||||||
|
if ($nativeRegistration.registration.status -ne 'pending' -or $nativeRegistration.registration.subjectIds.Count -ne 1) {
|
||||||
|
throw 'Native exam registration did not persist the selected subject and pending state'
|
||||||
|
}
|
||||||
|
$duplicateRegistration = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations" -Method Post -ContentType 'application/json' -Body $nativeRegistrationBody -WebSession $registeredSession -SkipHttpErrorCheck
|
||||||
|
if ($duplicateRegistration.StatusCode -ne 409) {
|
||||||
|
throw 'Native exam registration did not reject a duplicate submission'
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyRegisteredSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
|
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $registeredLoginBody -WebSession $legacyRegisteredSession | Out-Null
|
||||||
|
foreach ($candidateWriteReadRoute in @('profile', 'registrations')) {
|
||||||
|
$legacyWriteRead = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateWriteReadRoute" -WebSession $legacyRegisteredSession
|
||||||
|
$nativeWriteRead = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/$candidateWriteReadRoute" -WebSession $registeredSession
|
||||||
|
Assert-JsonEquivalent -Expected $legacyWriteRead.Content -Actual $nativeWriteRead.Content -Label "Candidate write follow-up '$candidateWriteReadRoute'"
|
||||||
|
}
|
||||||
|
|
||||||
$nativeSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
$nativeSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||||
$nativeLoginResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $nativeSession
|
$nativeLoginResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $nativeSession
|
||||||
if ($nativeLoginResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
if ($nativeLoginResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||||
@@ -528,6 +630,7 @@ try {
|
|||||||
DocumentCodes = 'passed'
|
DocumentCodes = 'passed'
|
||||||
NativeAuthentication = 'passed'
|
NativeAuthentication = 'passed'
|
||||||
NativeCandidateReads = 'passed'
|
NativeCandidateReads = 'passed'
|
||||||
|
NativeCandidateWrites = 'passed'
|
||||||
} | Format-List
|
} | Format-List
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
+11
-1
@@ -4,7 +4,7 @@ namespace Eis.Application.Candidate;
|
|||||||
|
|
||||||
public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body);
|
public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body);
|
||||||
|
|
||||||
public interface ICandidateQueryService
|
public interface ICandidateService
|
||||||
{
|
{
|
||||||
Task<CandidateEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
|
Task<CandidateEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
|
||||||
@@ -15,4 +15,14 @@ public interface ICandidateQueryService
|
|||||||
Task<CandidateEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken);
|
Task<CandidateEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<CandidateEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
|
Task<CandidateEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<CandidateEndpointResult> UpdateProfileAsync(
|
||||||
|
string sessionToken,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<CandidateEndpointResult> CreateRegistrationAsync(
|
||||||
|
string sessionToken,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
namespace Eis.Infrastructure.Candidate;
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
public sealed record CandidateMigrationOptions(bool NativeReadEnabled)
|
public sealed record CandidateMigrationOptions(bool NativeEnabled)
|
||||||
{
|
{
|
||||||
public static CandidateMigrationOptions FromEnvironment(
|
public static CandidateMigrationOptions FromEnvironment(
|
||||||
bool configuredNativeReadEnabled,
|
bool configuredNativeEnabled,
|
||||||
bool authenticationNativeEnabled,
|
bool authenticationNativeEnabled,
|
||||||
bool sharesLegacySessions)
|
bool sharesLegacySessions)
|
||||||
{
|
{
|
||||||
var enabled = ParseBoolean(
|
var enabled = ParseBoolean(
|
||||||
Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"),
|
Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"),
|
||||||
configuredNativeReadEnabled);
|
configuredNativeEnabled);
|
||||||
if (enabled && !authenticationNativeEnabled)
|
if (enabled && !authenticationNativeEnabled)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|||||||
@@ -141,9 +141,11 @@ internal sealed record CandidateUserSummary(
|
|||||||
string? ClassId,
|
string? ClassId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? CandidateNumber,
|
string? CandidateNumber,
|
||||||
|
bool Active,
|
||||||
bool MustChangePassword,
|
bool MustChangePassword,
|
||||||
bool TotpEnabled,
|
bool TotpEnabled,
|
||||||
string? ArchivedAt);
|
string? ArchivedAt,
|
||||||
|
string CreatedAt);
|
||||||
|
|
||||||
internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
||||||
{
|
{
|
||||||
@@ -262,7 +264,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
|
|||||||
ReadWorkflowAction,
|
ReadWorkflowAction,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var users = await QueryAsync(connection,
|
var users = await QueryAsync(connection,
|
||||||
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, must_change_password, totp_enabled, archived_at FROM users ORDER BY created_at, id",
|
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id",
|
||||||
ReadUserSummary,
|
ReadUserSummary,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
@@ -435,18 +437,24 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
|
|||||||
ReadOptionalString(reader, "to_assignee_id"),
|
ReadOptionalString(reader, "to_assignee_id"),
|
||||||
ReadString(reader, "created_at"));
|
ReadString(reader, "created_at"));
|
||||||
|
|
||||||
private static CandidateUserSummary ReadUserSummary(DbDataReader reader) => new(
|
private static CandidateUserSummary ReadUserSummary(DbDataReader reader)
|
||||||
ReadString(reader, "id"),
|
{
|
||||||
ReadString(reader, "username"),
|
var role = ReadString(reader, "role");
|
||||||
ReadString(reader, "role"),
|
return new CandidateUserSummary(
|
||||||
ReadOptionalString(reader, "admin_level"),
|
ReadString(reader, "id"),
|
||||||
ReadOptionalString(reader, "school_id"),
|
ReadString(reader, "username"),
|
||||||
ReadOptionalString(reader, "class_id"),
|
role,
|
||||||
ReadString(reader, "display_name"),
|
ReadOptionalString(reader, "admin_level") ?? (role == "admin" ? "super" : null),
|
||||||
ReadOptionalString(reader, "candidate_number"),
|
ReadOptionalString(reader, "school_id"),
|
||||||
ReadBoolean(reader, "must_change_password"),
|
ReadOptionalString(reader, "class_id"),
|
||||||
ReadBoolean(reader, "totp_enabled"),
|
ReadString(reader, "display_name"),
|
||||||
ReadOptionalString(reader, "archived_at"));
|
ReadOptionalString(reader, "candidate_number"),
|
||||||
|
ReadBoolean(reader, "active"),
|
||||||
|
ReadBoolean(reader, "must_change_password"),
|
||||||
|
ReadBoolean(reader, "totp_enabled"),
|
||||||
|
ReadOptionalString(reader, "archived_at"),
|
||||||
|
ReadString(reader, "created_at"));
|
||||||
|
}
|
||||||
|
|
||||||
private static string ReadString(DbDataReader reader, string name) =>
|
private static string ReadString(DbDataReader reader, string name) =>
|
||||||
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
|
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Application.Candidate;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
|
internal sealed partial class CandidateService
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> AdminLevelNames =
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["super"] = "超级管理员",
|
||||||
|
["school"] = "校级管理员",
|
||||||
|
["class"] = "班级管理员"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string[]> SpecialtyTypes =
|
||||||
|
new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["sports"] =
|
||||||
|
[
|
||||||
|
"track_field", "basketball", "football", "volleyball", "table_tennis", "badminton",
|
||||||
|
"swimming", "martial_arts", "aerobics_cheer"
|
||||||
|
],
|
||||||
|
["arts"] =
|
||||||
|
[
|
||||||
|
"vocal_music", "instrumental_music", "dance", "fine_arts", "calligraphy", "drama_broadcasting"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<CandidateEndpointResult> UpdateProfileAsync(
|
||||||
|
string sessionToken,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, profileRoute: true, cancellationToken);
|
||||||
|
if (context.Error is not null)
|
||||||
|
{
|
||||||
|
return context.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = context.User!;
|
||||||
|
var profile = context.Profile!;
|
||||||
|
foreach (var field in new[]
|
||||||
|
{
|
||||||
|
"name", "gender", "idNumber", "phone", "email", "address", "emergencyContact",
|
||||||
|
"emergencyPhone", "nativePlace", "birthDate", "ethnicity", "postalCode", "guardianName",
|
||||||
|
"guardianPhone", "specialtyCertificate", "policyEligibility"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
profile[field] = Clean(body, field, field == "address" ? 160 : 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
var specialtyCategory = Clean(body, "specialtyCategory", 30);
|
||||||
|
var specialtyType = Clean(body, "specialtyType", 40);
|
||||||
|
if (!ValidSpecialty(specialtyCategory, specialtyType))
|
||||||
|
{
|
||||||
|
return Error(400, "请选择对应的特长生大类和小类");
|
||||||
|
}
|
||||||
|
|
||||||
|
profile["specialtyCategory"] = specialtyCategory;
|
||||||
|
profile["specialtyType"] = specialtyType;
|
||||||
|
profile["specialtyTypes"] = specialtyType.Length == 0
|
||||||
|
? new JsonArray()
|
||||||
|
: new JsonArray(JsonValue.Create(specialtyType));
|
||||||
|
|
||||||
|
var region = regionCatalog.Resolve(
|
||||||
|
Text(body, "provinceCode"),
|
||||||
|
Text(body, "cityCode"),
|
||||||
|
Text(body, "districtCode"));
|
||||||
|
if (region is null)
|
||||||
|
{
|
||||||
|
return Error(400, "请选择有效的省、市和区县");
|
||||||
|
}
|
||||||
|
|
||||||
|
profile["provinceCode"] = region.ProvinceCode;
|
||||||
|
profile["provinceName"] = region.ProvinceName;
|
||||||
|
profile["cityCode"] = region.CityCode;
|
||||||
|
profile["cityName"] = region.CityName;
|
||||||
|
profile["districtCode"] = region.DistrictCode;
|
||||||
|
profile["districtName"] = region.DistrictName;
|
||||||
|
|
||||||
|
var school = await writeRepository.FindSchoolClassAsync(
|
||||||
|
Clean(body, "schoolId", 64),
|
||||||
|
Clean(body, "classId", 64),
|
||||||
|
cancellationToken);
|
||||||
|
if (school is null)
|
||||||
|
{
|
||||||
|
return Error(400, "请选择有效的学校和班级");
|
||||||
|
}
|
||||||
|
|
||||||
|
profile["schoolId"] = school.SchoolId;
|
||||||
|
profile["classId"] = school.ClassId;
|
||||||
|
profile["school"] = school.SchoolName;
|
||||||
|
profile["grade"] = school.ClassName;
|
||||||
|
var name = Text(profile, "name");
|
||||||
|
var gender = Text(profile, "gender");
|
||||||
|
var idNumber = Text(profile, "idNumber");
|
||||||
|
if (name.Length == 0 || gender is not ("男" or "女") || idNumber.Length == 0 ||
|
||||||
|
idNumber.StartsWith("PENDING-", StringComparison.Ordinal) ||
|
||||||
|
Text(profile, "nativePlace").Length == 0 || Text(profile, "address").Length == 0 ||
|
||||||
|
Text(profile, "phone").Length == 0 || Text(profile, "email").Length == 0 ||
|
||||||
|
Text(profile, "school").Length == 0 || Text(profile, "classId").Length == 0)
|
||||||
|
{
|
||||||
|
return Error(400, "请完整填写姓名、性别、证件号码、籍贯、省市区县、家庭住址、手机号、邮箱、学校和班级");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await writeRepository.IdNumberExistsAsync(idNumber, ProfileId(profile), cancellationToken))
|
||||||
|
{
|
||||||
|
return Error(409, "证件号码已被其他考生使用");
|
||||||
|
}
|
||||||
|
|
||||||
|
profile["status"] = "pending";
|
||||||
|
profile["profileCompleted"] = true;
|
||||||
|
profile["reviewNote"] = string.Empty;
|
||||||
|
profile["updatedAt"] = NowIso();
|
||||||
|
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
|
||||||
|
WorkflowSubmission? submission = null;
|
||||||
|
if (FindInstance(snapshot, "profile_change", ProfileId(profile)) is not { Status: "pending" })
|
||||||
|
{
|
||||||
|
var workflowResult = CreateWorkflowSubmission(
|
||||||
|
snapshot,
|
||||||
|
"profile_change",
|
||||||
|
ProfileId(profile),
|
||||||
|
profile,
|
||||||
|
user.Id);
|
||||||
|
if (workflowResult.Error is not null)
|
||||||
|
{
|
||||||
|
return workflowResult.Error;
|
||||||
|
}
|
||||||
|
submission = workflowResult.Submission;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeRepository.UpdateProfileAsync(
|
||||||
|
profile,
|
||||||
|
name,
|
||||||
|
submission?.Instance,
|
||||||
|
submission?.Action,
|
||||||
|
cancellationToken);
|
||||||
|
return Success(new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["profile"] = profile,
|
||||||
|
["message"] = "资料已提交,等待管理员复核"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CandidateEndpointResult> CreateRegistrationAsync(
|
||||||
|
string sessionToken,
|
||||||
|
JsonObject body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken);
|
||||||
|
if (context.Error is not null)
|
||||||
|
{
|
||||||
|
return context.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = context.User!;
|
||||||
|
var profile = context.Profile!;
|
||||||
|
if (Text(profile, "status") != "approved")
|
||||||
|
{
|
||||||
|
return Error(403, "个人资料审核通过后才能报名考试");
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
|
||||||
|
var examId = Text(body, "examId");
|
||||||
|
var exam = snapshot.Exams.FirstOrDefault(item =>
|
||||||
|
item.Id == examId && item.Status == "published" && item.ArchivedAt is null);
|
||||||
|
if (exam is null)
|
||||||
|
{
|
||||||
|
return Error(404, "考试不存在或尚未发布");
|
||||||
|
}
|
||||||
|
|
||||||
|
var registrationState = RegistrationState(exam);
|
||||||
|
if (registrationState != "open")
|
||||||
|
{
|
||||||
|
return Error(400, registrationState == "upcoming" ? "报名尚未开始" : "报名已经截止");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.Registrations.Any(item => item.ExamId == exam.Id) ||
|
||||||
|
await writeRepository.RegistrationExistsAsync(user.Id, exam.Id, cancellationToken))
|
||||||
|
{
|
||||||
|
return Error(409, "你已经报名该考试");
|
||||||
|
}
|
||||||
|
|
||||||
|
var subjectIds = body["subjectIds"] is JsonArray array
|
||||||
|
? array.Select(item => item?.ToString() ?? string.Empty)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray()
|
||||||
|
: [];
|
||||||
|
if (subjectIds.Length == 0 || subjectIds.Any(id => exam.Subjects.All(subject => subject.Id != id)))
|
||||||
|
{
|
||||||
|
return Error(400, "请选择有效的报考科目");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = NowIso();
|
||||||
|
var registration = new CandidateRegistration(
|
||||||
|
Uid("reg"),
|
||||||
|
user.Id,
|
||||||
|
exam.Id,
|
||||||
|
subjectIds,
|
||||||
|
"pending",
|
||||||
|
"unpaid",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
null,
|
||||||
|
string.Empty,
|
||||||
|
user.CandidateNumber ?? string.Empty,
|
||||||
|
await writeRepository.GetActiveNumberRuleIdAsync(cancellationToken),
|
||||||
|
0,
|
||||||
|
null);
|
||||||
|
var workflowResult = CreateWorkflowSubmission(
|
||||||
|
snapshot,
|
||||||
|
"registration_review",
|
||||||
|
registration.Id,
|
||||||
|
profile,
|
||||||
|
user.Id);
|
||||||
|
if (workflowResult.Error is not null)
|
||||||
|
{
|
||||||
|
return workflowResult.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var submission = workflowResult.Submission!;
|
||||||
|
await writeRepository.CreateRegistrationAsync(
|
||||||
|
registration,
|
||||||
|
submission.Instance,
|
||||||
|
submission.Action,
|
||||||
|
cancellationToken);
|
||||||
|
return new CandidateEndpointResult(
|
||||||
|
201,
|
||||||
|
new JsonObject
|
||||||
|
{
|
||||||
|
["ok"] = true,
|
||||||
|
["registration"] = RegistrationView(snapshot, registration),
|
||||||
|
["message"] = "考试报名已提交"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowSubmissionResult CreateWorkflowSubmission(
|
||||||
|
CandidateReadSnapshot snapshot,
|
||||||
|
string businessType,
|
||||||
|
string businessId,
|
||||||
|
JsonObject profile,
|
||||||
|
string actorId)
|
||||||
|
{
|
||||||
|
var workflow = snapshot.Workflows.FirstOrDefault(item => item.BusinessType == businessType && item.Active);
|
||||||
|
if (workflow is null || workflow.Steps.Count == 0)
|
||||||
|
{
|
||||||
|
return WorkflowSubmissionResult.Failed(Error(409, "该业务尚未配置审批流程"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstStep = workflow.Steps[0];
|
||||||
|
var schoolId = Text(profile, "schoolId");
|
||||||
|
var classId = Text(profile, "classId");
|
||||||
|
var pendingByAdmin = snapshot.WorkflowInstances
|
||||||
|
.Where(item => item.Status == "pending" && item.AssigneeId is not null)
|
||||||
|
.GroupBy(item => item.AssigneeId!, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||||
|
var assignedByAdmin = snapshot.WorkflowActions
|
||||||
|
.Where(item => item.ToAssigneeId is not null)
|
||||||
|
.GroupBy(item => item.ToAssigneeId!, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||||
|
var assignee = snapshot.Users.Values
|
||||||
|
.Where(item => item.Role == "admin" && item.Active && (item.AdminLevel ?? "super") == firstStep.AdminLevel)
|
||||||
|
.Where(item => firstStep.AdminLevel switch
|
||||||
|
{
|
||||||
|
"super" => true,
|
||||||
|
"school" => schoolId.Length > 0 && item.SchoolId == schoolId,
|
||||||
|
"class" => schoolId.Length > 0 && classId.Length > 0 && item.SchoolId == schoolId && item.ClassId == classId,
|
||||||
|
_ => false
|
||||||
|
})
|
||||||
|
.OrderBy(item => pendingByAdmin.GetValueOrDefault(item.Id))
|
||||||
|
.ThenBy(item => assignedByAdmin.GetValueOrDefault(item.Id))
|
||||||
|
.ThenBy(item => item.CreatedAt, StringComparer.Ordinal)
|
||||||
|
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (assignee is null)
|
||||||
|
{
|
||||||
|
var levelName = AdminLevelNames.GetValueOrDefault(firstStep.AdminLevel) ?? firstStep.AdminLevel;
|
||||||
|
return WorkflowSubmissionResult.Failed(Error(409, $"没有可承接“{firstStep.Name}”的{levelName}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance = new CandidateWorkflowInstance(
|
||||||
|
Uid("flow"),
|
||||||
|
workflow.Id,
|
||||||
|
businessType,
|
||||||
|
businessId,
|
||||||
|
"pending",
|
||||||
|
1,
|
||||||
|
assignee.Id,
|
||||||
|
NowIso(),
|
||||||
|
null);
|
||||||
|
var action = new CandidateWorkflowAction(
|
||||||
|
Uid("flow_action"),
|
||||||
|
instance.Id,
|
||||||
|
actorId,
|
||||||
|
"submit",
|
||||||
|
"提交审批",
|
||||||
|
null,
|
||||||
|
assignee.Id,
|
||||||
|
NowIso());
|
||||||
|
return new WorkflowSubmissionResult(new WorkflowSubmission(instance, action), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValidSpecialty(string category, string type)
|
||||||
|
{
|
||||||
|
if (category.Length == 0 && type.Length == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpecialtyTypes.TryGetValue(category, out var types) && types.Contains(type, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RegistrationState(CandidateExam exam)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (DateTimeOffset.TryParse(exam.RegistrationStart, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var start) && now < start)
|
||||||
|
{
|
||||||
|
return "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTimeOffset.TryParse(exam.RegistrationEnd, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var end) && now > end)
|
||||||
|
{
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Clean(JsonObject value, string property, int maximum)
|
||||||
|
{
|
||||||
|
var normalized = Text(value, property).Trim();
|
||||||
|
return normalized[..Math.Min(normalized.Length, maximum)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(JsonObject value, string property) => value[property]?.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
private static string NowIso() => DateTimeOffset.UtcNow.ToString(
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
|
||||||
|
CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static string Uid(string prefix) =>
|
||||||
|
$"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}";
|
||||||
|
|
||||||
|
private static string ToBase36(long value)
|
||||||
|
{
|
||||||
|
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||||
|
Span<char> buffer = stackalloc char[16];
|
||||||
|
var position = buffer.Length;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
buffer[--position] = alphabet[(int)(value % 36)];
|
||||||
|
value /= 36;
|
||||||
|
}
|
||||||
|
while (value > 0);
|
||||||
|
return new string(buffer[position..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record WorkflowSubmission(
|
||||||
|
CandidateWorkflowInstance Instance,
|
||||||
|
CandidateWorkflowAction Action);
|
||||||
|
|
||||||
|
private sealed record WorkflowSubmissionResult(
|
||||||
|
WorkflowSubmission? Submission,
|
||||||
|
CandidateEndpointResult? Error)
|
||||||
|
{
|
||||||
|
public static WorkflowSubmissionResult Failed(CandidateEndpointResult error) => new(null, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -6,11 +6,13 @@ using Eis.Infrastructure.Authentication;
|
|||||||
|
|
||||||
namespace Eis.Infrastructure.Candidate;
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
internal sealed class CandidateQueryService(
|
internal sealed partial class CandidateService(
|
||||||
IAuthenticationStateStore authenticationState,
|
IAuthenticationStateStore authenticationState,
|
||||||
AuthenticationRepository authenticationRepository,
|
AuthenticationRepository authenticationRepository,
|
||||||
CandidateReadSnapshotLoader snapshotLoader,
|
CandidateReadSnapshotLoader snapshotLoader,
|
||||||
IPublicQueryService publicQueries) : ICandidateQueryService
|
IPublicQueryService publicQueries,
|
||||||
|
CandidateWriteRepository writeRepository,
|
||||||
|
RegionCatalog regionCatalog) : ICandidateService
|
||||||
{
|
{
|
||||||
public async Task<CandidateEndpointResult> GetDashboardAsync(
|
public async Task<CandidateEndpointResult> GetDashboardAsync(
|
||||||
string sessionToken,
|
string sessionToken,
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Eis.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
|
internal sealed record SchoolClassSelection(
|
||||||
|
string SchoolId,
|
||||||
|
string SchoolName,
|
||||||
|
string ClassId,
|
||||||
|
string ClassName);
|
||||||
|
|
||||||
|
internal sealed class CandidateWriteRepository(IRelationalConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
public async Task<SchoolClassSelection?> FindSchoolClassAsync(
|
||||||
|
string schoolId,
|
||||||
|
string classId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT school.id AS school_id, school.name AS school_name,
|
||||||
|
class.id AS class_id, class.name AS class_name
|
||||||
|
FROM schools school
|
||||||
|
JOIN school_classes class ON class.school_id = school.id
|
||||||
|
WHERE school.id = @schoolId AND class.id = @classId
|
||||||
|
AND school.active = 1 AND school.is_source_school = 1 AND class.active = 1
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var command = CreateCommand(connection, sql,
|
||||||
|
[
|
||||||
|
new("@schoolId", schoolId),
|
||||||
|
new("@classId", classId)
|
||||||
|
]);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
return await reader.ReadAsync(cancellationToken)
|
||||||
|
? new SchoolClassSelection(
|
||||||
|
ReadString(reader, "school_id"),
|
||||||
|
ReadString(reader, "school_name"),
|
||||||
|
ReadString(reader, "class_id"),
|
||||||
|
ReadString(reader, "class_name"))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IdNumberExistsAsync(
|
||||||
|
string idNumber,
|
||||||
|
string excludedProfileId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var command = CreateCommand(connection,
|
||||||
|
"SELECT COUNT(*) FROM candidate_profiles WHERE id <> @profileId AND id_number = @idNumber",
|
||||||
|
[new("@profileId", excludedProfileId), new("@idNumber", idNumber)]);
|
||||||
|
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RegistrationExistsAsync(
|
||||||
|
string userId,
|
||||||
|
string examId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var command = CreateCommand(connection,
|
||||||
|
"SELECT COUNT(*) FROM registrations WHERE user_id = @userId AND exam_id = @examId",
|
||||||
|
[new("@userId", userId), new("@examId", examId)]);
|
||||||
|
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetActiveNumberRuleIdAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var command = CreateCommand(connection,
|
||||||
|
"SELECT id FROM number_rules WHERE active = 1 ORDER BY updated_at DESC, id LIMIT 1",
|
||||||
|
[]);
|
||||||
|
var value = await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
return value is null or DBNull ? null : Convert.ToString(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateProfileAsync(
|
||||||
|
JsonObject profile,
|
||||||
|
string displayName,
|
||||||
|
CandidateWorkflowInstance? instance,
|
||||||
|
CandidateWorkflowAction? action,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE candidate_profiles SET
|
||||||
|
name = @name, gender = @gender, id_number = @idNumber, phone = @phone, email = @email,
|
||||||
|
school = @school, grade = @grade, province_code = @provinceCode, province_name = @provinceName,
|
||||||
|
city_code = @cityCode, city_name = @cityName, district_code = @districtCode,
|
||||||
|
district_name = @districtName, address = @address, school_id = @schoolId, class_id = @classId,
|
||||||
|
emergency_contact = @emergencyContact, emergency_phone = @emergencyPhone, status = @status,
|
||||||
|
review_note = @reviewNote, native_place = @nativePlace, birth_date = @birthDate,
|
||||||
|
ethnicity = @ethnicity, postal_code = @postalCode, guardian_name = @guardianName,
|
||||||
|
guardian_phone = @guardianPhone, specialty_category = @specialtyCategory,
|
||||||
|
specialty_type = @specialtyType, specialty_types = @specialtyTypes,
|
||||||
|
specialty_certificate = @specialtyCertificate, policy_eligibility = @policyEligibility,
|
||||||
|
profile_completed = @profileCompleted, reviewed_at = @reviewedAt, reviewer_id = @reviewerId,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
WHERE id = @id
|
||||||
|
""";
|
||||||
|
var parameters = new QueryParameter[]
|
||||||
|
{
|
||||||
|
new("@name", Text(profile, "name")),
|
||||||
|
new("@gender", Optional(Text(profile, "gender"))),
|
||||||
|
new("@idNumber", Text(profile, "idNumber")),
|
||||||
|
new("@phone", Text(profile, "phone")),
|
||||||
|
new("@email", Optional(Text(profile, "email"))),
|
||||||
|
new("@school", Optional(Text(profile, "school"))),
|
||||||
|
new("@grade", Optional(Text(profile, "grade"))),
|
||||||
|
new("@provinceCode", Optional(Text(profile, "provinceCode"))),
|
||||||
|
new("@provinceName", Optional(Text(profile, "provinceName"))),
|
||||||
|
new("@cityCode", Optional(Text(profile, "cityCode"))),
|
||||||
|
new("@cityName", Optional(Text(profile, "cityName"))),
|
||||||
|
new("@districtCode", Optional(Text(profile, "districtCode"))),
|
||||||
|
new("@districtName", Optional(Text(profile, "districtName"))),
|
||||||
|
new("@address", Optional(Text(profile, "address"))),
|
||||||
|
new("@schoolId", Optional(Text(profile, "schoolId"))),
|
||||||
|
new("@classId", Optional(Text(profile, "classId"))),
|
||||||
|
new("@emergencyContact", Optional(Text(profile, "emergencyContact"))),
|
||||||
|
new("@emergencyPhone", Optional(Text(profile, "emergencyPhone"))),
|
||||||
|
new("@status", Text(profile, "status")),
|
||||||
|
new("@reviewNote", Optional(Text(profile, "reviewNote"))),
|
||||||
|
new("@nativePlace", Optional(Text(profile, "nativePlace"))),
|
||||||
|
new("@birthDate", Optional(Text(profile, "birthDate"))),
|
||||||
|
new("@ethnicity", Optional(Text(profile, "ethnicity"))),
|
||||||
|
new("@postalCode", Optional(Text(profile, "postalCode"))),
|
||||||
|
new("@guardianName", Optional(Text(profile, "guardianName"))),
|
||||||
|
new("@guardianPhone", Optional(Text(profile, "guardianPhone"))),
|
||||||
|
new("@specialtyCategory", Optional(Text(profile, "specialtyCategory"))),
|
||||||
|
new("@specialtyType", Optional(Text(profile, "specialtyType"))),
|
||||||
|
new("@specialtyTypes", profile["specialtyTypes"]?.ToJsonString() ?? "[]"),
|
||||||
|
new("@specialtyCertificate", Optional(Text(profile, "specialtyCertificate"))),
|
||||||
|
new("@policyEligibility", Optional(Text(profile, "policyEligibility"))),
|
||||||
|
new("@profileCompleted", Boolean(profile, "profileCompleted") ? 1 : 0),
|
||||||
|
new("@reviewedAt", Optional(Text(profile, "reviewedAt"))),
|
||||||
|
new("@reviewerId", Optional(Text(profile, "reviewerId"))),
|
||||||
|
new("@updatedAt", Text(profile, "updatedAt")),
|
||||||
|
new("@id", Text(profile, "id"))
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteAsync(connection, transaction, sql, parameters, cancellationToken);
|
||||||
|
await ExecuteAsync(connection, transaction,
|
||||||
|
"UPDATE users SET display_name = @displayName WHERE id = @userId",
|
||||||
|
[new("@displayName", displayName), new("@userId", Text(profile, "userId"))],
|
||||||
|
cancellationToken);
|
||||||
|
if (instance is not null && action is not null)
|
||||||
|
{
|
||||||
|
await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreateRegistrationAsync(
|
||||||
|
CandidateRegistration registration,
|
||||||
|
CandidateWorkflowInstance? instance,
|
||||||
|
CandidateWorkflowAction? action,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO registrations (
|
||||||
|
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
|
||||||
|
) VALUES (
|
||||||
|
@id, @userId, @examId, @status, @paymentStatus, @paidAt, @paidBy, @createdAt,
|
||||||
|
@reviewedAt, @reviewNote, @registrationNumber, @numberRuleId, @featureScore
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteAsync(connection, transaction, sql,
|
||||||
|
[
|
||||||
|
new("@id", registration.Id),
|
||||||
|
new("@userId", registration.UserId),
|
||||||
|
new("@examId", registration.ExamId),
|
||||||
|
new("@status", registration.Status),
|
||||||
|
new("@paymentStatus", registration.PaymentStatus),
|
||||||
|
new("@paidAt", registration.PaidAt),
|
||||||
|
new("@paidBy", registration.PaidBy),
|
||||||
|
new("@createdAt", registration.CreatedAt),
|
||||||
|
new("@reviewedAt", registration.ReviewedAt),
|
||||||
|
new("@reviewNote", Optional(registration.ReviewNote)),
|
||||||
|
new("@registrationNumber", Optional(registration.RegistrationNumber)),
|
||||||
|
new("@numberRuleId", registration.NumberRuleId),
|
||||||
|
new("@featureScore", registration.FeatureScore)
|
||||||
|
], cancellationToken);
|
||||||
|
foreach (var subjectId in registration.SubjectIds)
|
||||||
|
{
|
||||||
|
await ExecuteAsync(connection, transaction,
|
||||||
|
"INSERT INTO registration_subjects (registration_id, subject_id) VALUES (@registrationId, @subjectId)",
|
||||||
|
[new("@registrationId", registration.Id), new("@subjectId", subjectId)],
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance is not null && action is not null)
|
||||||
|
{
|
||||||
|
await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task InsertWorkflowAsync(
|
||||||
|
DbConnection connection,
|
||||||
|
DbTransaction transaction,
|
||||||
|
CandidateWorkflowInstance instance,
|
||||||
|
CandidateWorkflowAction action,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string instanceSql = """
|
||||||
|
INSERT INTO workflow_instances (
|
||||||
|
id, workflow_id, business_type, business_id, status, current_step,
|
||||||
|
assignee_id, created_at, completed_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @workflowId, @businessType, @businessId, @status, @currentStep,
|
||||||
|
@assigneeId, @createdAt, @completedAt
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
await ExecuteAsync(connection, transaction, instanceSql,
|
||||||
|
[
|
||||||
|
new("@id", instance.Id),
|
||||||
|
new("@workflowId", instance.WorkflowId),
|
||||||
|
new("@businessType", instance.BusinessType),
|
||||||
|
new("@businessId", instance.BusinessId),
|
||||||
|
new("@status", instance.Status),
|
||||||
|
new("@currentStep", instance.CurrentStep),
|
||||||
|
new("@assigneeId", instance.AssigneeId),
|
||||||
|
new("@createdAt", instance.CreatedAt),
|
||||||
|
new("@completedAt", instance.CompletedAt)
|
||||||
|
], cancellationToken);
|
||||||
|
const string actionSql = """
|
||||||
|
INSERT INTO workflow_actions (
|
||||||
|
id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @instanceId, @actorId, @action, @note, @fromAssigneeId, @toAssigneeId, @createdAt
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
await ExecuteAsync(connection, transaction, actionSql,
|
||||||
|
[
|
||||||
|
new("@id", action.Id),
|
||||||
|
new("@instanceId", action.InstanceId),
|
||||||
|
new("@actorId", action.ActorId),
|
||||||
|
new("@action", action.Action),
|
||||||
|
new("@note", Optional(action.Note)),
|
||||||
|
new("@fromAssigneeId", action.FromAssigneeId),
|
||||||
|
new("@toAssigneeId", action.ToAssigneeId),
|
||||||
|
new("@createdAt", action.CreatedAt)
|
||||||
|
], cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ExecuteAsync(
|
||||||
|
DbConnection connection,
|
||||||
|
DbTransaction transaction,
|
||||||
|
string sql,
|
||||||
|
IReadOnlyList<QueryParameter> parameters,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = CreateCommand(connection, sql, parameters, transaction);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DbCommand CreateCommand(
|
||||||
|
DbConnection connection,
|
||||||
|
string sql,
|
||||||
|
IReadOnlyList<QueryParameter> parameters,
|
||||||
|
DbTransaction? transaction = null)
|
||||||
|
{
|
||||||
|
var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
command.Transaction = transaction;
|
||||||
|
foreach (var item in parameters)
|
||||||
|
{
|
||||||
|
var parameter = command.CreateParameter();
|
||||||
|
parameter.ParameterName = item.Name;
|
||||||
|
parameter.Value = item.Value ?? DBNull.Value;
|
||||||
|
command.Parameters.Add(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(JsonObject value, string property) => value[property]?.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
private static bool Boolean(JsonObject value, string property) => value[property]?.GetValue<bool>() == true;
|
||||||
|
|
||||||
|
private static object? Optional(string? value) => string.IsNullOrEmpty(value) ? null : value;
|
||||||
|
|
||||||
|
private static string ReadString(DbDataReader reader, string name) =>
|
||||||
|
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
private sealed record QueryParameter(string Name, object? Value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
|
internal sealed record ResolvedRegion(
|
||||||
|
string ProvinceCode,
|
||||||
|
string ProvinceName,
|
||||||
|
string CityCode,
|
||||||
|
string CityName,
|
||||||
|
string DistrictCode,
|
||||||
|
string DistrictName);
|
||||||
|
|
||||||
|
internal sealed class RegionCatalog
|
||||||
|
{
|
||||||
|
private const string Marker = "export const chinaRegions = ";
|
||||||
|
private readonly IReadOnlyDictionary<string, Province> _provinces;
|
||||||
|
|
||||||
|
public RegionCatalog()
|
||||||
|
{
|
||||||
|
var assembly = typeof(RegionCatalog).Assembly;
|
||||||
|
var resourceName = assembly.GetManifestResourceNames()
|
||||||
|
.Single(name => name.EndsWith("china-regions.mjs", StringComparison.Ordinal));
|
||||||
|
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||||
|
?? throw new InvalidOperationException("无法读取内嵌行政区划数据");
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var source = reader.ReadToEnd();
|
||||||
|
var markerIndex = source.IndexOf(Marker, StringComparison.Ordinal);
|
||||||
|
if (markerIndex < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("行政区划数据格式无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = source[(markerIndex + Marker.Length)..].Trim();
|
||||||
|
if (json.EndsWith(';'))
|
||||||
|
{
|
||||||
|
json = json[..^1];
|
||||||
|
}
|
||||||
|
|
||||||
|
var provinces = JsonSerializer.Deserialize<Province[]>(json, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
}) ?? throw new InvalidOperationException("行政区划数据为空");
|
||||||
|
_provinces = provinces.ToDictionary(item => item.Code, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResolvedRegion? Resolve(string? provinceCode, string? cityCode, string? districtCode)
|
||||||
|
{
|
||||||
|
var provinceKey = (provinceCode ?? string.Empty).Trim();
|
||||||
|
var cityKey = (cityCode ?? string.Empty).Trim();
|
||||||
|
var districtKey = (districtCode ?? string.Empty).Trim();
|
||||||
|
if (!_provinces.TryGetValue(provinceKey, out var province))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var city = province.Cities.FirstOrDefault(item => item.Code == cityKey);
|
||||||
|
var district = city?.Districts.FirstOrDefault(item => item.Code == districtKey);
|
||||||
|
return city is null || district is null
|
||||||
|
? null
|
||||||
|
: new ResolvedRegion(province.Code, province.Name, city.Code, city.Name, district.Code, district.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Province
|
||||||
|
{
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
public required City[] Cities { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class City
|
||||||
|
{
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
public required District[] Districts { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class District
|
||||||
|
{
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
public required string Name { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,8 +43,10 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<AuthenticationRepository>();
|
services.AddScoped<AuthenticationRepository>();
|
||||||
services.AddScoped<IAuthenticationService, AuthenticationService>();
|
services.AddScoped<IAuthenticationService, AuthenticationService>();
|
||||||
services.AddSingleton(candidateMigrationOptions);
|
services.AddSingleton(candidateMigrationOptions);
|
||||||
|
services.AddSingleton<RegionCatalog>();
|
||||||
services.AddScoped<CandidateReadSnapshotLoader>();
|
services.AddScoped<CandidateReadSnapshotLoader>();
|
||||||
services.AddScoped<ICandidateQueryService, CandidateQueryService>();
|
services.AddScoped<CandidateWriteRepository>();
|
||||||
|
services.AddScoped<ICandidateService, CandidateService>();
|
||||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,7 @@
|
|||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
|
||||||
<PackageReference Include="StackExchange.Redis" />
|
<PackageReference Include="StackExchange.Redis" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="..\data\china-regions.mjs" Link="Data\china-regions.mjs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+22
-8
@@ -3,47 +3,61 @@ using Eis.Infrastructure.Candidate;
|
|||||||
|
|
||||||
namespace Eis.Web.Candidate;
|
namespace Eis.Web.Candidate;
|
||||||
|
|
||||||
public static class NativeCandidateReadEndpoints
|
public static class NativeCandidateEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapNativeCandidateReadEndpoints(
|
public static IEndpointRouteBuilder MapNativeCandidateEndpoints(
|
||||||
this IEndpointRouteBuilder endpoints,
|
this IEndpointRouteBuilder endpoints,
|
||||||
CandidateMigrationOptions options)
|
CandidateMigrationOptions options)
|
||||||
{
|
{
|
||||||
if (!options.NativeReadEnabled)
|
if (!options.NativeEnabled)
|
||||||
{
|
{
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints.MapGet("/api/candidate/dashboard", async (
|
endpoints.MapGet("/api/candidate/dashboard", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ICandidateQueryService service,
|
ICandidateService service,
|
||||||
CancellationToken cancellationToken) => ToResult(
|
CancellationToken cancellationToken) => ToResult(
|
||||||
context,
|
context,
|
||||||
await service.GetDashboardAsync(SessionToken(context), cancellationToken)));
|
await service.GetDashboardAsync(SessionToken(context), cancellationToken)));
|
||||||
endpoints.MapGet("/api/candidate/notices", async (
|
endpoints.MapGet("/api/candidate/notices", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ICandidateQueryService service,
|
ICandidateService service,
|
||||||
CancellationToken cancellationToken) => ToResult(
|
CancellationToken cancellationToken) => ToResult(
|
||||||
context,
|
context,
|
||||||
await service.GetNoticesAsync(SessionToken(context), cancellationToken)));
|
await service.GetNoticesAsync(SessionToken(context), cancellationToken)));
|
||||||
endpoints.MapGet("/api/candidate/profile", async (
|
endpoints.MapGet("/api/candidate/profile", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ICandidateQueryService service,
|
ICandidateService service,
|
||||||
CancellationToken cancellationToken) => ToResult(
|
CancellationToken cancellationToken) => ToResult(
|
||||||
context,
|
context,
|
||||||
await service.GetProfileAsync(SessionToken(context), cancellationToken)));
|
await service.GetProfileAsync(SessionToken(context), cancellationToken)));
|
||||||
|
endpoints.MapPut("/api/candidate/profile", async (
|
||||||
|
HttpContext context,
|
||||||
|
System.Text.Json.Nodes.JsonObject request,
|
||||||
|
ICandidateService service,
|
||||||
|
CancellationToken cancellationToken) => ToResult(
|
||||||
|
context,
|
||||||
|
await service.UpdateProfileAsync(SessionToken(context), request, cancellationToken)));
|
||||||
endpoints.MapGet("/api/candidate/exams", async (
|
endpoints.MapGet("/api/candidate/exams", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ICandidateQueryService service,
|
ICandidateService service,
|
||||||
CancellationToken cancellationToken) => ToResult(
|
CancellationToken cancellationToken) => ToResult(
|
||||||
context,
|
context,
|
||||||
await service.GetExamsAsync(SessionToken(context), cancellationToken)));
|
await service.GetExamsAsync(SessionToken(context), cancellationToken)));
|
||||||
endpoints.MapGet("/api/candidate/registrations", async (
|
endpoints.MapGet("/api/candidate/registrations", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ICandidateQueryService service,
|
ICandidateService service,
|
||||||
CancellationToken cancellationToken) => ToResult(
|
CancellationToken cancellationToken) => ToResult(
|
||||||
context,
|
context,
|
||||||
await service.GetRegistrationsAsync(SessionToken(context), cancellationToken)));
|
await service.GetRegistrationsAsync(SessionToken(context), cancellationToken)));
|
||||||
|
endpoints.MapPost("/api/candidate/registrations", async (
|
||||||
|
HttpContext context,
|
||||||
|
System.Text.Json.Nodes.JsonObject request,
|
||||||
|
ICandidateService service,
|
||||||
|
CancellationToken cancellationToken) => ToResult(
|
||||||
|
context,
|
||||||
|
await service.CreateRegistrationAsync(SessionToken(context), request, cancellationToken)));
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ var authenticationOptions = AuthenticationOptions.FromEnvironment(
|
|||||||
builder.Environment.IsProduction(),
|
builder.Environment.IsProduction(),
|
||||||
builder.Configuration.GetValue<bool>("AuthenticationMigration:NativeEnabled"));
|
builder.Configuration.GetValue<bool>("AuthenticationMigration:NativeEnabled"));
|
||||||
var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
|
var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
|
||||||
builder.Configuration.GetValue<bool>("CandidateMigration:NativeReadEnabled"),
|
builder.Configuration.GetValue<bool>("CandidateMigration:NativeEnabled"),
|
||||||
authenticationOptions.NativeEnabled,
|
authenticationOptions.NativeEnabled,
|
||||||
authenticationOptions.SharesLegacySessions);
|
authenticationOptions.SharesLegacySessions);
|
||||||
builder.Services.AddEisInfrastructure(
|
builder.Services.AddEisInfrastructure(
|
||||||
@@ -81,9 +81,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
},
|
},
|
||||||
candidate = new
|
candidate = new
|
||||||
{
|
{
|
||||||
nativeReadEnabled = candidateMigrationOptions.NativeReadEnabled,
|
nativeEnabled = candidateMigrationOptions.NativeEnabled,
|
||||||
nativeRoutes = candidateMigrationOptions.NativeReadEnabled
|
nativeRoutes = candidateMigrationOptions.NativeEnabled
|
||||||
? new[] { "dashboard", "notices", "profile", "exams", "registrations" }
|
? new[] { "GET dashboard", "GET notices", "GET/PUT profile", "GET exams", "GET/POST registrations" }
|
||||||
: []
|
: []
|
||||||
},
|
},
|
||||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled)
|
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled)
|
||||||
@@ -92,7 +92,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
|||||||
|
|
||||||
app.MapNativePublicEndpoints();
|
app.MapNativePublicEndpoints();
|
||||||
app.MapNativeAuthenticationEndpoints(authenticationOptions);
|
app.MapNativeAuthenticationEndpoints(authenticationOptions);
|
||||||
app.MapNativeCandidateReadEndpoints(candidateMigrationOptions);
|
app.MapNativeCandidateEndpoints(candidateMigrationOptions);
|
||||||
|
|
||||||
string[] methods =
|
string[] methods =
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"NativeEnabled": false
|
"NativeEnabled": false
|
||||||
},
|
},
|
||||||
"CandidateMigration": {
|
"CandidateMigration": {
|
||||||
"NativeReadEnabled": false
|
"NativeEnabled": false
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Eis.Infrastructure.Candidate;
|
||||||
|
|
||||||
|
namespace Eis.Infrastructure.Tests.Candidate;
|
||||||
|
|
||||||
|
public sealed class RegionCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesCodesFromEmbeddedAuthoritativeSnapshot()
|
||||||
|
{
|
||||||
|
var catalog = new RegionCatalog();
|
||||||
|
|
||||||
|
var region = catalog.Resolve("320000", "320700", "320706");
|
||||||
|
|
||||||
|
Assert.NotNull(region);
|
||||||
|
Assert.Equal("江苏省", region.ProvinceName);
|
||||||
|
Assert.Equal("连云港市", region.CityName);
|
||||||
|
Assert.Equal("海州区", region.DistrictName);
|
||||||
|
Assert.Null(catalog.Resolve("320000", "320700", "invalid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
const databasePath = process.argv[2];
|
||||||
|
const candidateNumber = process.argv[3];
|
||||||
|
if (!databasePath || !candidateNumber) throw new Error('缺少 SQLite 路径或报名号');
|
||||||
|
|
||||||
|
const database = new DatabaseSync(databasePath);
|
||||||
|
const user = database.prepare("SELECT id FROM users WHERE candidate_number = ? AND role = 'candidate'").get(candidateNumber);
|
||||||
|
if (!user) throw new Error('未找到冒烟测试考生');
|
||||||
|
const reviewer = database.prepare("SELECT id FROM users WHERE role = 'admin' AND active = 1 ORDER BY created_at, id LIMIT 1").get();
|
||||||
|
database.prepare(
|
||||||
|
"UPDATE candidate_profiles SET status = 'approved', review_note = '', reviewed_at = ?, reviewer_id = ? WHERE user_id = ?"
|
||||||
|
).run('2026-07-22T00:00:00.000Z', reviewer?.id || null, user.id);
|
||||||
|
|
||||||
|
const exam = database.prepare("SELECT id FROM exams WHERE status = 'published' AND archived_at IS NULL ORDER BY created_at, id LIMIT 1").get();
|
||||||
|
if (!exam) throw new Error('未找到已发布考试');
|
||||||
|
database.prepare(
|
||||||
|
'UPDATE exams SET registration_start = ?, registration_end = ? WHERE id = ?'
|
||||||
|
).run('2000-01-01T00:00:00.000Z', '2099-12-31T23:59:59.999Z', exam.id);
|
||||||
|
const subject = database.prepare('SELECT id FROM exam_subjects WHERE exam_id = ? ORDER BY position, id LIMIT 1').get(exam.id);
|
||||||
|
if (!subject) throw new Error('考试缺少科目');
|
||||||
|
database.close();
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ examId: exam.id, subjectId: subject.id }));
|
||||||
Reference in New Issue
Block a user