新增 [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())
@@ -1,4 +1,4 @@
namespace Eis.Web.Configuration;
namespace Eis.Infrastructure.Configuration;
public static class EnvironmentFile
{
@@ -35,7 +35,9 @@ public static class EnvironmentFile
}
var value = line[(separator + 1)..].Trim();
if (value.Length >= 2 && ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\'')))
if (value.Length >= 2 &&
((value[0] == '"' && value[^1] == '"') ||
(value[0] == '\'' && value[^1] == '\'')))
{
value = value[1..^1];
}
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Eis.Tools</RootNamespace>
<AssemblyName>Eis.Tools</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Eis.Infrastructure\Eis.Infrastructure.csproj" />
</ItemGroup>
</Project>
+263
View File
@@ -0,0 +1,263 @@
using Eis.Infrastructure.Configuration;
using Eis.Infrastructure.Data;
namespace Eis.Tools;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
try
{
var command = DatabaseToolCommand.Parse(args);
if (command.ShowHelp)
{
Console.WriteLine(DatabaseToolCommand.Usage);
return 0;
}
var applicationRoot = command.ApplicationRoot ?? FindApplicationRoot();
EnvironmentFile.Load(applicationRoot);
var options = BuildDatabaseOptions(command, applicationRoot);
if (command.Force && options.Client != "mysql")
{
throw new DatabaseToolUsageException("--force 只能用于 MySQL 测试数据库。");
}
var confirmedTarget = NormalizeConfirmation(command.ConfirmedTarget, options, applicationRoot);
Console.WriteLine($"命令:database {command.Mode.ToString().ToLowerInvariant()}");
Console.WriteLine($"目标:{options.TargetDescription}");
if (command.DryRun)
{
Console.WriteLine("模式:只预检,不写入数据库");
}
var service = new DatabaseMaintenanceService(options);
var result = await service.ExecuteAsync(
command.Mode,
confirmedTarget,
command.Force,
command.DryRun,
CancellationToken.None);
PrintResult(result);
return 0;
}
catch (DatabaseToolUsageException exception)
{
Console.Error.WriteLine(exception.Message);
Console.Error.WriteLine();
Console.Error.WriteLine(DatabaseToolCommand.Usage);
return 2;
}
catch (Exception exception)
{
Console.Error.WriteLine($"数据库工具执行失败:{exception.Message}");
return 1;
}
}
private static DatabaseOptions BuildDatabaseOptions(
DatabaseToolCommand command,
string applicationRoot)
{
var client = command.Client ??
Environment.GetEnvironmentVariable("DATABASE_CLIENT")?.Trim().ToLowerInvariant() ??
"sqlite";
return client switch
{
"sqlite" => DatabaseOptions.CreateSqlite(ResolveSqlitePath(command.SqlitePath, applicationRoot)),
"mysql" when command.SqlitePath is not null =>
throw new DatabaseToolUsageException("--path 只能和 --sqlite 一起使用"),
"mysql" => DatabaseOptions.CreateMySqlFromEnvironment(),
_ => throw new DatabaseToolUsageException($"不支持的数据库类型:{client}")
};
}
private static string ResolveSqlitePath(string? configuredPath, string applicationRoot)
{
var path = configuredPath ??
Environment.GetEnvironmentVariable("SQLITE_PATH") ??
Path.Combine("data", "exam.sqlite");
return Path.GetFullPath(Path.IsPathRooted(path)
? path
: Path.Combine(applicationRoot, path));
}
private static string? NormalizeConfirmation(
string? confirmation,
DatabaseOptions options,
string applicationRoot)
{
if (confirmation is null || options.Client != "sqlite")
{
return confirmation;
}
return Path.GetFullPath(Path.IsPathRooted(confirmation)
? confirmation
: Path.Combine(applicationRoot, confirmation));
}
private static string FindApplicationRoot()
{
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
{
var directory = new DirectoryInfo(Path.GetFullPath(start));
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Eis.slnx")) ||
File.Exists(Path.Combine(directory.FullName, ".env")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
return Path.GetFullPath(Directory.GetCurrentDirectory());
}
private static void PrintResult(DatabaseMaintenanceResult result)
{
if (result.DryRun)
{
Console.WriteLine("预检完成:未修改数据库。");
}
else
{
Console.WriteLine(result.Mode switch
{
DatabaseMaintenanceMode.Init => "数据库结构和基础配置初始化完成。",
DatabaseMaintenanceMode.Reset => "数据库已重建为空业务系统。",
_ => "演示数据导入完成。"
});
}
if (result.BackupPath is not null)
{
Console.WriteLine($"原数据库备份:{result.BackupPath}");
}
if (result.Summary is { } summary)
{
Console.WriteLine(
$"演示数据:学校 {summary.Schools},用户 {summary.Users},考生 {summary.Candidates}" +
$"报名 {summary.Registrations},成绩 {summary.Results},招生记录 {summary.AdmissionRecords}。");
Console.WriteLine("预置测试账号密码:12345678");
}
}
}
internal sealed record DatabaseToolCommand(
DatabaseMaintenanceMode Mode,
string? Client,
string? SqlitePath,
string? ConfirmedTarget,
string? ApplicationRoot,
bool Force,
bool DryRun,
bool ShowHelp)
{
public const string Usage =
"""
dotnet Eis.Tools.dll database init [--sqlite|--mysql] [--path <sqlite文件>] [--dry-run]
dotnet Eis.Tools.dll database reset [--sqlite|--mysql] [--path <sqlite文件>] [--confirm-target <>] [--force] [--dry-run]
dotnet Eis.Tools.dll database seed [--sqlite|--mysql] [--path <sqlite文件>] [--confirm-target <>] [--force] [--dry-run]
init
reset SQLite
seed 1200 SQLite
--confirm-target SQLite MySQL
--force MySQL 使
--root .env SQLite
""";
public static DatabaseToolCommand Parse(string[] args)
{
if (args.Length == 0 || args.Any(item => item is "-h" or "--help"))
{
return new(
DatabaseMaintenanceMode.Init,
null,
null,
null,
null,
false,
false,
true);
}
if (args.Length < 2 || !args[0].Equals("database", StringComparison.OrdinalIgnoreCase))
{
throw new DatabaseToolUsageException("第一个参数必须是 database。");
}
var mode = args[1].ToLowerInvariant() switch
{
"init" => DatabaseMaintenanceMode.Init,
"reset" => DatabaseMaintenanceMode.Reset,
"seed" => DatabaseMaintenanceMode.Seed,
_ => throw new DatabaseToolUsageException($"不支持的 database 命令:{args[1]}")
};
string? client = null;
string? path = null;
string? confirmation = null;
string? root = null;
var force = false;
var dryRun = false;
for (var index = 2; index < args.Length; index++)
{
switch (args[index])
{
case "--sqlite":
SetClient(ref client, "sqlite");
break;
case "--mysql":
SetClient(ref client, "mysql");
break;
case "--path":
path = NextValue(args, ref index, "--path");
break;
case "--confirm-target":
confirmation = NextValue(args, ref index, "--confirm-target");
break;
case "--root":
root = Path.GetFullPath(NextValue(args, ref index, "--root"));
break;
case "--force":
force = true;
break;
case "--dry-run":
dryRun = true;
break;
default:
throw new DatabaseToolUsageException($"无法识别的参数:{args[index]}");
}
}
if (mode == DatabaseMaintenanceMode.Init && force)
{
throw new DatabaseToolUsageException("database init 不接受 --force。");
}
return new(mode, client, path, confirmation, root, force, dryRun, false);
}
private static void SetClient(ref string? current, string value)
{
if (current is not null && current != value)
{
throw new DatabaseToolUsageException("--sqlite 和 --mysql 不能同时使用。");
}
current = value;
}
private static string NextValue(string[] args, ref int index, string option)
{
if (++index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal))
{
throw new DatabaseToolUsageException($"{option} 缺少参数值。");
}
return args[index];
}
}
internal sealed class DatabaseToolUsageException(string message) : Exception(message);
+1
View File
@@ -2,6 +2,7 @@ using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Administration;
using Eis.Infrastructure.Candidate;
using Eis.Infrastructure.Caching;
using Eis.Infrastructure.Configuration;
using Eis.Application.Public;
using Eis.Infrastructure;
using Eis.Infrastructure.Data;