新增 [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:
@@ -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")
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user