新增 [Eis.Tools (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Tools/Program.cs:1),支持 database init/reset/seed。

数据库维护、安全校验和事务逻辑位于 [DatabaseMaintenanceService.cs (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/DatabaseMaintenanceService.cs:1)。
内置完整演示数据:1200 名考生、10800 条成绩、2404 条招生记录。
删除旧 import-test-data.mjs 和 reset-dev-database.mjs 运行入口。
Docker 镜像同时包含 Web 和 /app/tools/Eis.Tools.dll。
[package.json (line 11)](C:/Users/BI/Documents/EIS-dotnet/package.json:11) 原数据库命令已改为调用 .NET。
新增统一发布脚本:[publish.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/publish.ps1:1)。
This commit is contained in:
2026-07-23 16:25:40 +08:00 Unverified
parent e3c04dad8a
commit f3ffc18aae
23 changed files with 1299 additions and 281 deletions
@@ -30,7 +30,7 @@ internal sealed partial class AdminAdmissionService
["school"] = SchoolJson(school),
["exams"] = new JsonArray(data.Operational.Exams
.Where(item => item.Data["archivedAt"] is null)
.Select(item => (JsonNode)PublicExam(item)).ToArray()),
.Select(item => item.Data.DeepClone()).ToArray()),
["template"] = template,
["updatedAt"] = JsonValue.Create(record?.UpdatedAt)
});
@@ -265,7 +265,7 @@ internal sealed partial class AdminAdmissionService
var completed = data.Operational.Exams.Where(exam =>
Records(data, "setting", exam.Id).Any(setting => setting.Status == "completed") &&
placements.Any(item => Text(item["examId"]) == exam.Id && Text(item["status"]) == "final"))
.Select(item => (JsonNode)PublicExam(item)).ToArray();
.Select(item => item.Data.DeepClone()).ToArray();
return Success(new JsonObject
{
["ok"] = true,
@@ -32,7 +32,8 @@ internal sealed partial class AdminAdmissionService
}).ToArray();
var home = await publicQueries.GetHomeAsync(cancellationToken);
var notifications = (home["notices"] as JsonArray ?? []).OfType<JsonObject>()
.Where(item => Text(item["schoolId"]).Length == 0 || Text(item["schoolId"]) == school.Id)
.Where(item => Text(item["sourceType"]).Length > 0 &&
(Text(item["schoolId"]).Length == 0 || Text(item["schoolId"]) == school.Id))
.Take(6)
.Select(item =>
{
@@ -43,7 +44,7 @@ internal sealed partial class AdminAdmissionService
var exams = data.Operational.Exams.Where(item =>
item.Data["archivedAt"] is null &&
Records(data, "setting", item.Id).Any(setting => Boolean(setting.Payload["enabled"])))
.Select(item => (JsonNode)PublicExam(item)).ToArray();
.Select(item => item.Data.DeepClone()).ToArray();
return Success(new JsonObject
{
["ok"] = true,
@@ -76,7 +77,7 @@ internal sealed partial class AdminAdmissionService
["school"] = SchoolJson(school),
["plans"] = new JsonArray(plans),
["exams"] = new JsonArray(data.Operational.Exams.Where(item => item.Data["archivedAt"] is null)
.Select(item => (JsonNode)PublicExam(item)).ToArray()),
.Select(item => item.Data.DeepClone()).ToArray()),
["sourceSchools"] = new JsonArray(data.Directory.Schools
.Where(item => item.Active && item.IsSourceSchool)
.Select(item => (JsonNode)SchoolJson(item)).ToArray())
@@ -0,0 +1,47 @@
namespace Eis.Infrastructure.Configuration;
public static class EnvironmentFile
{
public static void Load(string applicationRoot)
{
var path = Path.Combine(applicationRoot, ".env");
if (!File.Exists(path))
{
return;
}
foreach (var rawLine in File.ReadLines(path))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith("export ", StringComparison.Ordinal))
{
line = line[7..].TrimStart();
}
var separator = line.IndexOf('=');
if (separator <= 0)
{
continue;
}
var key = line[..separator].Trim();
if (Environment.GetEnvironmentVariable(key) is not null)
{
continue;
}
var value = line[(separator + 1)..].Trim();
if (value.Length >= 2 &&
((value[0] == '"' && value[^1] == '"') ||
(value[0] == '\'' && value[^1] == '\'')))
{
value = value[1..^1];
}
Environment.SetEnvironmentVariable(key, value);
}
}
}
@@ -30,38 +30,41 @@ internal sealed partial class DatabaseInitializer(
await ExecuteAsync(connection, match.Groups["sql"].Value, cancellationToken);
}
}
await SeedBaseStateAsync(connection, cancellationToken);
await SeedBaseStateAsync(connection, null, cancellationToken);
}
private async Task SeedBaseStateAsync(DbConnection connection, CancellationToken cancellationToken)
internal async Task SeedBaseStateAsync(
DbConnection connection,
DbTransaction? transaction,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
if (!await ExistsAsync(connection, "schema_metadata", cancellationToken))
if (!await ExistsAsync(connection, transaction, "schema_metadata", cancellationToken))
{
await ExecuteAsync(connection,
"""
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, @version, @version, 0, @createdAt)
""",
[("@version", SchemaVersion), ("@createdAt", now)], cancellationToken);
[("@version", SchemaVersion), ("@createdAt", now)], transaction, cancellationToken);
}
else
{
var version = await ScalarLongAsync(connection,
"SELECT schema_version FROM schema_metadata WHERE id = 1", cancellationToken);
"SELECT schema_version FROM schema_metadata WHERE id = 1", transaction, cancellationToken);
if (version < SchemaVersion)
{
throw new InvalidOperationException(
$"数据库结构版本为 {version},低于 ASP.NET Core 要求的 {SchemaVersion};请先备份并执行旧版本升级流程");
}
}
if (!await ExistsAsync(connection, "organization", cancellationToken))
if (!await ExistsAsync(connection, transaction, "organization", cancellationToken))
{
await ExecuteAsync(connection,
"INSERT INTO organization (id, name, code, phone, address) VALUES (1, @name, @code, '', '')",
[("@name", "考试服务平台"), ("@code", "EXAM-SERVICE")], cancellationToken);
[("@name", "考试服务平台"), ("@code", "EXAM-SERVICE")], transaction, cancellationToken);
}
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", cancellationToken) == 0)
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", transaction, cancellationToken) == 0)
{
var username = Environment.GetEnvironmentVariable("INITIAL_ADMIN_USERNAME")?.Trim();
var password = Environment.GetEnvironmentVariable("INITIAL_ADMIN_PASSWORD") ?? "Admin123!";
@@ -80,25 +83,26 @@ internal sealed partial class DatabaseInitializer(
("@passwordHash", passwords.Hash(password)),
("@displayName", string.IsNullOrWhiteSpace(displayName) ? "系统管理员" : displayName),
("@createdAt", now)
], cancellationToken);
], transaction, cancellationToken);
}
await SeedNumberRuleAsync(connection, now, cancellationToken);
await SeedAdmissionNumberRulesAsync(connection, now, cancellationToken);
await SeedWorkflowsAsync(connection, now, cancellationToken);
await SeedNumberRuleAsync(connection, transaction, now, cancellationToken);
await SeedAdmissionNumberRulesAsync(connection, transaction, now, cancellationToken);
await SeedWorkflowsAsync(connection, transaction, now, cancellationToken);
}
private static async Task SeedNumberRuleAsync(
DbConnection connection,
DbTransaction? transaction,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM number_rules", cancellationToken) > 0) return;
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM number_rules", transaction, cancellationToken) > 0) return;
await ExecuteAsync(connection,
"""
INSERT INTO number_rules (id, name, separator, active, created_by, updated_at)
VALUES ('rule_default', @name, '-', 1, 'usr_admin', @updatedAt)
""",
[("@name", "年度学校性别流水号"), ("@updatedAt", now)], cancellationToken);
[("@name", "年度学校性别流水号"), ("@updatedAt", now)], transaction, cancellationToken);
var segments = new[]
{
("segment_year", 1, "year", "", 4),
@@ -116,16 +120,17 @@ internal sealed partial class DatabaseInitializer(
[
("@id", segment.Item1), ("@position", segment.Item2), ("@type", segment.Item3),
("@value", segment.Item4), ("@width", segment.Item5)
], cancellationToken);
], transaction, cancellationToken);
}
}
private static async Task SeedAdmissionNumberRulesAsync(
DbConnection connection,
DbTransaction? transaction,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM admission_number_rules", cancellationToken) > 0) return;
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM admission_number_rules", transaction, cancellationToken) > 0) return;
var rules = new[]
{
new AdmissionRule("admit_rule_district_room_seat", "district_room_seat", "县区编号 + 考场号 + 座位号",
@@ -159,16 +164,17 @@ internal sealed partial class DatabaseInitializer(
("@id", rule.Id), ("@code", rule.Code), ("@name", rule.Name),
("@description", rule.Description), ("@segments", segments),
("@example", rule.Example), ("@createdAt", now)
], cancellationToken);
], transaction, cancellationToken);
}
}
private static async Task SeedWorkflowsAsync(
DbConnection connection,
DbTransaction? transaction,
string now,
CancellationToken cancellationToken)
{
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM workflow_definitions", cancellationToken) > 0) return;
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM workflow_definitions", transaction, cancellationToken) > 0) return;
var workflows = new[]
{
new Workflow("workflow_profile", "profile_change", "考生信息修改审批",
@@ -196,7 +202,7 @@ internal sealed partial class DatabaseInitializer(
[
("@id", workflow.Id), ("@type", workflow.Type),
("@name", workflow.Name), ("@updatedAt", now)
], cancellationToken);
], transaction, cancellationToken);
for (var index = 0; index < workflow.Steps.Length; index++)
{
var step = workflow.Steps[index];
@@ -208,24 +214,27 @@ internal sealed partial class DatabaseInitializer(
[
("@id", step.Id), ("@workflowId", workflow.Id), ("@position", index + 1),
("@name", step.Name), ("@level", step.Level)
], cancellationToken);
], transaction, cancellationToken);
}
}
}
private static async Task<bool> ExistsAsync(
DbConnection connection,
DbTransaction? transaction,
string table,
CancellationToken cancellationToken) =>
await ScalarLongAsync(connection, $"SELECT COUNT(*) FROM {table}", cancellationToken) > 0;
await ScalarLongAsync(connection, $"SELECT COUNT(*) FROM {table}", transaction, cancellationToken) > 0;
private static async Task<long> ScalarLongAsync(
DbConnection connection,
string sql,
DbTransaction? transaction,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.Transaction = transaction;
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
}
@@ -233,16 +242,18 @@ internal sealed partial class DatabaseInitializer(
DbConnection connection,
string sql,
CancellationToken cancellationToken) =>
ExecuteAsync(connection, sql, [], cancellationToken);
ExecuteAsync(connection, sql, [], null, cancellationToken);
private static async Task ExecuteAsync(
DbConnection connection,
string sql,
IReadOnlyList<(string Name, object Value)> values,
DbTransaction? transaction,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.Transaction = transaction;
foreach (var value in values)
{
var parameter = command.CreateParameter();
@@ -0,0 +1,647 @@
using System.Data.Common;
using System.Globalization;
using System.IO.Compression;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Eis.Infrastructure.Authentication;
namespace Eis.Infrastructure.Data;
public enum DatabaseMaintenanceMode
{
Init,
Reset,
Seed
}
public sealed record DatabaseMaintenanceResult(
DatabaseMaintenanceMode Mode,
string Target,
bool DryRun,
string? BackupPath,
DemoSeedSummary? Summary);
public sealed record DemoSeedSummary(
int Schools,
int Users,
int Candidates,
int Registrations,
int Results,
int AdmissionRecords,
int FirstRoundPreferences,
int Operations);
public sealed partial class DatabaseMaintenanceService(DatabaseOptions options)
{
private const int SchemaVersion = 20;
private static readonly string[] RelationalTables =
[
"schema_metadata",
"organization",
"schools",
"school_classes",
"school_student_partitions",
"users",
"candidate_profiles",
"notices",
"exams",
"exam_data_partitions",
"exam_subjects",
"registrations",
"registration_subjects",
"admission_number_rules",
"exam_arrangement_plans",
"admit_cards",
"admit_card_subjects",
"results",
"test_centers",
"test_rooms",
"center_change_requests",
"center_change_rooms",
"number_rules",
"number_rule_segments",
"candidate_account_batches",
"candidate_account_batch_items",
"workflow_definitions",
"workflow_steps",
"workflow_instances",
"workflow_actions",
"admission_records",
"audit_logs"
];
private static readonly string[] BusinessTables =
[
"schools",
"school_classes",
"candidate_profiles",
"notices",
"exams",
"exam_subjects",
"registrations",
"registration_subjects",
"exam_arrangement_plans",
"admit_cards",
"admit_card_subjects",
"results",
"test_centers",
"test_rooms",
"center_change_requests",
"center_change_rooms",
"candidate_account_batches",
"candidate_account_batch_items",
"workflow_instances",
"workflow_actions",
"admission_records",
"audit_logs"
];
public async Task<DatabaseMaintenanceResult> ExecuteAsync(
DatabaseMaintenanceMode mode,
string? confirmedTarget,
bool force,
bool dryRun,
CancellationToken cancellationToken)
{
ValidateTarget();
if (mode is (DatabaseMaintenanceMode.Reset or DatabaseMaintenanceMode.Seed) && !dryRun)
{
RequireConfirmedTarget(confirmedTarget);
}
if (dryRun)
{
if (options.Client == "mysql")
{
await ValidateMySqlDryRunAsync(force, cancellationToken);
}
return new DatabaseMaintenanceResult(
mode,
options.TargetDescription,
true,
null,
mode == DatabaseMaintenanceMode.Seed ? (await LoadDemoSeedAsync(cancellationToken)).Summary : null);
}
if (mode == DatabaseMaintenanceMode.Init)
{
await CreateInitializer(options).InitializeAsync(cancellationToken);
return new DatabaseMaintenanceResult(mode, options.TargetDescription, false, null, null);
}
return options.Client == "sqlite"
? await ReplaceSqliteAsync(mode, cancellationToken)
: await ReplaceMySqlAsync(mode, force, cancellationToken);
}
private async Task<DatabaseMaintenanceResult> ReplaceSqliteAsync(
DatabaseMaintenanceMode mode,
CancellationToken cancellationToken)
{
var targetPath = options.SqlitePath
?? throw new InvalidOperationException("SQLite 数据库路径不存在");
var directory = Path.GetDirectoryName(targetPath)
?? throw new InvalidOperationException("SQLite 数据库目录无效");
Directory.CreateDirectory(directory);
var stagingPath = Path.Combine(
directory,
$".{Path.GetFileName(targetPath)}.staging-{Guid.NewGuid():N}");
var stagingOptions = DatabaseOptions.CreateSqlite(stagingPath);
DemoSeedSummary? summary = null;
try
{
await CreateInitializer(stagingOptions).InitializeAsync(cancellationToken);
if (mode == DatabaseMaintenanceMode.Seed)
{
summary = await ImportDemoSeedAsync(stagingOptions, cancellationToken);
}
await ValidateSqliteOutputAsync(stagingOptions, mode, cancellationToken);
string? backupPath = null;
if (File.Exists(targetPath))
{
await CheckpointSqliteAsync(targetPath, cancellationToken);
backupPath = $"{targetPath}.backup-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}";
File.Copy(targetPath, backupPath, overwrite: false);
}
DeleteSqliteSidecars(targetPath);
File.Move(stagingPath, targetPath, overwrite: true);
return new DatabaseMaintenanceResult(
mode,
targetPath,
false,
backupPath,
summary);
}
finally
{
DeleteSqliteSidecars(stagingPath);
if (File.Exists(stagingPath))
{
File.Delete(stagingPath);
}
}
}
private async Task<DatabaseMaintenanceResult> ReplaceMySqlAsync(
DatabaseMaintenanceMode mode,
bool force,
CancellationToken cancellationToken)
{
var initializer = CreateInitializer(options);
await initializer.InitializeAsync(cancellationToken);
var factory = new RelationalConnectionFactory(options);
await using var connection = await factory.OpenAsync(cancellationToken);
var nonEmpty = await FindNonEmptyBusinessTablesAsync(connection, cancellationToken);
var recognizedDemo = nonEmpty.Count > 0 &&
await IsRecognizedDemoDatabaseAsync(connection, cancellationToken);
if (nonEmpty.Count > 0 && !force && !recognizedDemo)
{
throw new InvalidOperationException(
$"MySQL 数据库已有业务数据({string.Join(", ", nonEmpty.Take(8))}" +
$"{(nonEmpty.Count > 8 ? ", ..." : "")})。仅确认是可覆盖测试库时才可追加 --force");
}
var partitionTables = await ReadPartitionTablesAsync(connection, cancellationToken);
await ExecuteNonQueryAsync(connection, null, "SET FOREIGN_KEY_CHECKS = 0", [], cancellationToken);
DemoSeedSummary? summary = null;
try
{
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await ClearApplicationDataAsync(connection, transaction, cancellationToken);
if (mode == DatabaseMaintenanceMode.Seed)
{
summary = await ImportDemoSeedAsync(connection, transaction, cancellationToken);
}
else
{
await initializer.SeedBaseStateAsync(connection, transaction, cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
finally
{
await ExecuteNonQueryAsync(connection, null, "SET FOREIGN_KEY_CHECKS = 1", [], cancellationToken);
}
foreach (var table in partitionTables)
{
await ExecuteNonQueryAsync(
connection,
null,
$"DROP TABLE IF EXISTS `{table}`",
[],
cancellationToken);
}
return new DatabaseMaintenanceResult(
mode,
options.TargetDescription,
false,
null,
summary);
}
private async Task ValidateMySqlDryRunAsync(
bool force,
CancellationToken cancellationToken)
{
var factory = new RelationalConnectionFactory(options);
await using var connection = await factory.OpenAsync(cancellationToken);
var existingTables = new HashSet<string>(StringComparer.Ordinal);
await using (var command = connection.CreateCommand())
{
command.CommandText =
"""
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
existingTables.Add(reader.GetString(0));
}
}
var applicationTables = RelationalTables.Where(existingTables.Contains).ToArray();
if (applicationTables.Length == 0)
{
return;
}
if (applicationTables.Length != RelationalTables.Length)
{
var missing = RelationalTables.Where(item => !existingTables.Contains(item));
throw new InvalidOperationException(
$"MySQL 数据库结构不完整,缺少:{string.Join(", ", missing)}");
}
var version = await ScalarLongAsync(
connection,
"SELECT schema_version FROM schema_metadata WHERE id = 1",
cancellationToken);
if (version != SchemaVersion)
{
throw new InvalidOperationException(
$"MySQL 数据库结构版本为 v{version},当前工具要求 v{SchemaVersion}");
}
var nonEmpty = await FindNonEmptyBusinessTablesAsync(connection, cancellationToken);
var recognizedDemo = nonEmpty.Count > 0 &&
await IsRecognizedDemoDatabaseAsync(connection, cancellationToken);
if (nonEmpty.Count > 0 && !force && !recognizedDemo)
{
throw new InvalidOperationException(
$"MySQL 数据库已有业务数据({string.Join(", ", nonEmpty.Take(8))}" +
$"{(nonEmpty.Count > 8 ? ", ..." : "")})。预检拒绝覆盖;确认是测试库时追加 --force");
}
}
private async Task<DemoSeedSummary> ImportDemoSeedAsync(
DatabaseOptions targetOptions,
CancellationToken cancellationToken)
{
var factory = new RelationalConnectionFactory(targetOptions);
await using var connection = await factory.OpenAsync(cancellationToken);
await ExecuteNonQueryAsync(connection, null, "PRAGMA foreign_keys = OFF", [], cancellationToken);
try
{
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await ClearApplicationDataAsync(connection, transaction, cancellationToken);
var summary = await ImportDemoSeedAsync(connection, transaction, cancellationToken);
await transaction.CommitAsync(cancellationToken);
return summary;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
finally
{
await ExecuteNonQueryAsync(connection, null, "PRAGMA foreign_keys = ON", [], cancellationToken);
}
}
private static async Task<DemoSeedSummary> ImportDemoSeedAsync(
DbConnection connection,
DbTransaction transaction,
CancellationToken cancellationToken)
{
var payload = await LoadDemoSeedAsync(cancellationToken);
if (payload.SchemaVersion != SchemaVersion)
{
throw new InvalidOperationException(
$"演示数据结构版本为 v{payload.SchemaVersion},当前程序要求 v{SchemaVersion}");
}
foreach (var operation in payload.Operations)
{
var values = operation.Parameters
.Select(JsonValue)
.ToArray();
await ExecuteNonQueryAsync(
connection,
transaction,
RewriteParameters(operation.Sql, values.Length),
values,
cancellationToken);
}
return payload.Summary;
}
private static async Task ClearApplicationDataAsync(
DbConnection connection,
DbTransaction transaction,
CancellationToken cancellationToken)
{
await ExecuteNonQueryAsync(connection, transaction, "DELETE FROM exams", [], cancellationToken);
foreach (var table in RelationalTables.Reverse()
.Where(item => item is not ("schema_metadata" or "exams")))
{
await ExecuteNonQueryAsync(
connection,
transaction,
$"DELETE FROM `{table}`",
[],
cancellationToken);
}
}
private static async Task ValidateSqliteOutputAsync(
DatabaseOptions targetOptions,
DatabaseMaintenanceMode mode,
CancellationToken cancellationToken)
{
var factory = new RelationalConnectionFactory(targetOptions);
await using var connection = await factory.OpenAsync(cancellationToken);
var version = await ScalarLongAsync(
connection,
"SELECT schema_version FROM schema_metadata WHERE id = 1",
cancellationToken);
var users = await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", cancellationToken);
if (version != SchemaVersion || users == 0)
{
throw new InvalidOperationException("生成的 SQLite 数据库未通过结构和账号校验");
}
if (mode == DatabaseMaintenanceMode.Seed)
{
var candidates = await ScalarLongAsync(
connection,
"SELECT COUNT(*) FROM candidate_profiles",
cancellationToken);
var results = await ScalarLongAsync(connection, "SELECT COUNT(*) FROM results", cancellationToken);
if (candidates != 1200 || results != 10800)
{
throw new InvalidOperationException(
$"演示数据校验失败:考生 {candidates},成绩 {results}");
}
}
}
private static async Task CheckpointSqliteAsync(
string targetPath,
CancellationToken cancellationToken)
{
var factory = new RelationalConnectionFactory(DatabaseOptions.CreateSqlite(targetPath));
await using var connection = await factory.OpenAsync(cancellationToken);
await ExecuteNonQueryAsync(
connection,
null,
"PRAGMA wal_checkpoint(TRUNCATE)",
[],
cancellationToken);
}
private static void DeleteSqliteSidecars(string path)
{
foreach (var suffix in new[] { "-wal", "-shm" })
{
var sidecar = path + suffix;
if (File.Exists(sidecar))
{
File.Delete(sidecar);
}
}
}
private async Task<List<string>> FindNonEmptyBusinessTablesAsync(
DbConnection connection,
CancellationToken cancellationToken)
{
var result = new List<string>();
foreach (var table in BusinessTables)
{
var count = await ScalarLongAsync(
connection,
$"SELECT COUNT(*) FROM `{table}`",
cancellationToken);
if (count > 0)
{
result.Add($"{table}={count}");
}
}
var users = await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", cancellationToken);
if (users > 1)
{
result.Add($"users={users}");
}
return result;
}
private static async Task<bool> IsRecognizedDemoDatabaseAsync(
DbConnection connection,
CancellationToken cancellationToken)
{
var users = await ScalarLongAsync(
connection,
"SELECT COUNT(*) FROM users WHERE id LIKE 'usr_bulk_%'",
cancellationToken);
var schools = await ScalarLongAsync(
connection,
"""
SELECT COUNT(*) FROM schools
WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')
""",
cancellationToken);
return users >= 1100 && schools == 5;
}
private static async Task<string[]> ReadPartitionTablesAsync(
DbConnection connection,
CancellationToken cancellationToken)
{
var tables = new List<string>();
await using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT candidates_table, admissions_table, results_table, centers_table FROM exam_data_partitions";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
for (var index = 0; index < 4; index++)
{
tables.Add(reader.GetString(index));
}
}
}
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT students_table FROM school_student_partitions";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
tables.Add(reader.GetString(0));
}
}
if (tables.Any(item => !PartitionTablePattern().IsMatch(item)))
{
throw new InvalidOperationException("分表登记中存在非法表名,拒绝替换数据库");
}
return tables.Distinct(StringComparer.Ordinal).ToArray();
}
private void ValidateTarget()
{
if (options.Client == "sqlite" && string.IsNullOrWhiteSpace(options.SqlitePath))
{
throw new InvalidOperationException("未指定 SQLite 数据库路径");
}
if (options.Client == "mysql")
{
var name = options.MySqlDatabaseName?.Trim() ?? "";
var normalized = name.ToLowerInvariant();
if (normalized.Length == 0 ||
normalized is "mysql" or "information_schema" or "performance_schema" or "sys")
{
throw new InvalidOperationException($"拒绝操作 MySQL 系统数据库:{name}");
}
}
}
private void RequireConfirmedTarget(string? confirmedTarget)
{
var expected = options.Client == "sqlite"
? options.SqlitePath
: options.MySqlDatabaseName;
var comparison = options.Client == "sqlite" && OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (string.IsNullOrWhiteSpace(confirmedTarget) ||
!string.Equals(expected, confirmedTarget, comparison))
{
throw new InvalidOperationException(
$"这是破坏性操作。请使用 --confirm-target \"{expected}\" 明确确认目标");
}
}
private static DatabaseInitializer CreateInitializer(DatabaseOptions targetOptions) =>
new(
new RelationalConnectionFactory(targetOptions),
targetOptions,
new PasswordCompatibilityService());
private static async Task<DemoSeedPayload> LoadDemoSeedAsync(CancellationToken cancellationToken)
{
var assembly = typeof(DatabaseMaintenanceService).Assembly;
var name = assembly.GetManifestResourceNames()
.Single(item => item.EndsWith("demo-seed-operations.json.gz", StringComparison.Ordinal));
await using var source = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException("内置演示数据包不存在");
await using var gzip = new GZipStream(source, CompressionMode.Decompress);
return await JsonSerializer.DeserializeAsync<DemoSeedPayload>(
gzip,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
cancellationToken)
?? throw new InvalidOperationException("内置演示数据包格式无效");
}
private static string RewriteParameters(string sql, int count)
{
var index = 0;
var rewritten = ParameterPattern().Replace(sql, _ => $"@p{index++}");
if (index != count)
{
throw new InvalidOperationException(
$"演示数据 SQL 参数数量不匹配:预期 {index},实际 {count}");
}
return rewritten;
}
private static object JsonValue(JsonElement value) =>
value.ValueKind switch
{
JsonValueKind.Null => DBNull.Value,
JsonValueKind.String => value.GetString() ?? "",
JsonValueKind.Number when value.TryGetInt64(out var integer) => integer,
JsonValueKind.Number => value.GetDouble(),
JsonValueKind.True => 1,
JsonValueKind.False => 0,
_ => value.GetRawText()
};
private static async Task ExecuteNonQueryAsync(
DbConnection connection,
DbTransaction? transaction,
string sql,
IReadOnlyList<object> values,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.Transaction = transaction;
for (var index = 0; index < values.Count; index++)
{
var parameter = command.CreateParameter();
parameter.ParameterName = $"@p{index}";
parameter.Value = values[index];
command.Parameters.Add(parameter);
}
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<long> ScalarLongAsync(
DbConnection connection,
string sql,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
}
[GeneratedRegex(@"\?", RegexOptions.CultureInvariant)]
private static partial Regex ParameterPattern();
[GeneratedRegex(@"^[a-z][a-z0-9_]{0,63}$", RegexOptions.CultureInvariant)]
private static partial Regex PartitionTablePattern();
private sealed record DemoSeedPayload(
int Version,
int SchemaVersion,
string GeneratedAt,
DemoSeedSummary Summary,
DemoSeedOperation[] Operations);
private sealed record DemoSeedOperation(
string Sql,
[property: JsonPropertyName("params")] JsonElement[] Parameters);
}
@@ -17,9 +17,27 @@ public sealed class DatabaseOptions
public string? MySqlConnectionString { get; }
public string TargetDescription => Client == "sqlite"
? SqlitePath ?? "SQLite"
: $"MySQL database {MySqlDatabaseName}";
public string? MySqlDatabaseName =>
Client == "mysql" && MySqlConnectionString is { Length: > 0 } connectionString
? new MySqlConnectionStringBuilder(connectionString).Database
: null;
internal static DatabaseOptions CreateSqliteForTests(string path) =>
new("sqlite", Path.GetFullPath(path), null);
public static DatabaseOptions CreateSqlite(string path) =>
new("sqlite", Path.GetFullPath(path), null);
public static DatabaseOptions CreateMySqlFromEnvironment() =>
new("mysql", null, BuildMySqlConnectionString());
public static DatabaseOptions CreateMySql(string connectionString) =>
new("mysql", null, new MySqlConnectionStringBuilder(connectionString).ConnectionString);
public static DatabaseOptions FromEnvironment(string applicationRoot, bool production)
{
var client = (Environment.GetEnvironmentVariable("DATABASE_CLIENT")
@@ -19,5 +19,6 @@
<ItemGroup>
<EmbeddedResource Include="..\data\china-regions.mjs" Link="Data\china-regions.mjs" />
<EmbeddedResource Include="..\..\src\database\schema.mjs" Link="Data\database-schema.mjs" />
<EmbeddedResource Include="Data\demo-seed-operations.json.gz" />
</ItemGroup>
</Project>