using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Data.Sqlite; using MiGu.Server.Wms; using MiGu.Server.SimpleFields; namespace MiGu.Server.Persistence; public static class PlatformPersistence { public static IServiceCollection AddPlatformPersistence(this IServiceCollection services, IConfiguration configuration) { services.AddDbContext((sp, options) => { var env = sp.GetRequiredService(); var provider = configuration["Database:Provider"] ?? "sqlite"; var connection = ResolveConnectionString(configuration, env, provider); switch (provider.Trim().ToLowerInvariant()) { case "sqlite": options.UseSqlite(connection); break; case "mysql": options.UseMySql(connection, ServerVersion.AutoDetect(connection)); break; case "postgres": case "postgresql": case "npgsql": options.UseNpgsql(connection); break; case "sqlserver": case "mssql": options.UseSqlServer(connection); break; default: throw new InvalidOperationException($"未知数据库 Provider: {provider}"); } }); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); return services; } public static async Task EnsurePlatformDatabaseAsync(this IServiceProvider services) { using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); // EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。 await EnsureSimpleFieldsTableAsync(db); await EnsureUserDashboardShortcutsTableAsync(db); await EnsureWmsTransportSchemaAsync(db); await EnsureWmsStructureSchemaAsync(db); await EnsureCdmTasksTableAsync(db); await EnsureVehicleAlarmsTableAsync(db); await MigrateWmsLegacyAsync(scope.ServiceProvider); } private static async Task MigrateWmsLegacyAsync(IServiceProvider sp) { try { var wms = sp.GetRequiredService(); await wms.MigrateLegacyAsync("system"); } catch { // 首次建库或缺列时忽略,后续请求可再触发 } } /// 补建仓库/物料类型/库存事件及结构扩展列(幂等,SQLite)。 private static async Task EnsureWmsStructureSchemaAsync(PlatformDbContext db) { if (!db.Database.IsSqlite()) return; await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS wms_warehouses ( Id TEXT NOT NULL CONSTRAINT PK_wms_warehouses PRIMARY KEY, Code TEXT NOT NULL, Name TEXT NOT NULL, Type TEXT NOT NULL, Enabled INTEGER NOT NULL, SortOrder INTEGER NOT NULL, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL, IsDeleted INTEGER NOT NULL, DeletedAt TEXT, DeletedBy TEXT NOT NULL DEFAULT '', Version INTEGER NOT NULL, IsLock INTEGER NOT NULL, CreatedBy TEXT NOT NULL DEFAULT '', UpdatedBy TEXT NOT NULL DEFAULT '', Remark TEXT NOT NULL DEFAULT '', Extend TEXT NOT NULL DEFAULT '{{}}' ); """); await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_warehouses_Code ON wms_warehouses (Code);"); await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS wms_material_types ( Id TEXT NOT NULL CONSTRAINT PK_wms_material_types PRIMARY KEY, Code TEXT NOT NULL, Name TEXT NOT NULL, Spec TEXT NOT NULL DEFAULT '', Unit TEXT NOT NULL DEFAULT 'pcs', Category TEXT NOT NULL DEFAULT '', BarcodePrefix TEXT NOT NULL DEFAULT '', Enabled INTEGER NOT NULL, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL, IsDeleted INTEGER NOT NULL, DeletedAt TEXT, DeletedBy TEXT NOT NULL DEFAULT '', Version INTEGER NOT NULL, IsLock INTEGER NOT NULL, CreatedBy TEXT NOT NULL DEFAULT '', UpdatedBy TEXT NOT NULL DEFAULT '', Remark TEXT NOT NULL DEFAULT '', Extend TEXT NOT NULL DEFAULT '{{}}' ); """); await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_material_types_Code ON wms_material_types (Code);"); await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS wms_stock_events ( Id TEXT NOT NULL CONSTRAINT PK_wms_stock_events PRIMARY KEY, EventType TEXT NOT NULL, MaterialId TEXT, MaterialCode TEXT NOT NULL DEFAULT '', MaterialName TEXT NOT NULL DEFAULT '', MaterialBarcode TEXT NOT NULL DEFAULT '', MaterialTypeCode TEXT NOT NULL DEFAULT '', ContainerId TEXT, ContainerCode TEXT NOT NULL DEFAULT '', ContainerName TEXT NOT NULL DEFAULT '', StorageId TEXT, StorageCode TEXT NOT NULL DEFAULT '', StorageName TEXT NOT NULL DEFAULT '', AreaCode TEXT NOT NULL DEFAULT '', FromStorageId TEXT, FromStorageCode TEXT NOT NULL DEFAULT '', FromStorageName TEXT NOT NULL DEFAULT '', ToStorageId TEXT, ToStorageCode TEXT NOT NULL DEFAULT '', ToStorageName TEXT NOT NULL DEFAULT '', RefType TEXT NOT NULL DEFAULT 'Manual', RefId TEXT, RefCode TEXT NOT NULL DEFAULT '', Operator TEXT NOT NULL DEFAULT '', OperatedAt TEXT NOT NULL, Reason TEXT NOT NULL DEFAULT '' ); """); await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_wms_stock_events_OperatedAt ON wms_stock_events (OperatedAt);"); await EnsureSqliteColumnAsync(db, "wms_areas", "WarehouseId", "TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000'"); await EnsureSqliteColumnAsync(db, "wms_areas", "LayoutMode", "TEXT NOT NULL DEFAULT 'Flat'"); await EnsureSqliteColumnAsync(db, "wms_areas", "State", "TEXT NOT NULL DEFAULT 'Default'"); await EnsureSqliteColumnAsync(db, "wms_storages", "LocationKind", "TEXT NOT NULL DEFAULT 'Station'"); await EnsureSqliteColumnAsync(db, "wms_storages", "ColumnNo", "INTEGER NOT NULL DEFAULT 0"); await EnsureSqliteColumnAsync(db, "wms_storages", "LevelNo", "INTEGER NOT NULL DEFAULT 1"); await EnsureSqliteColumnAsync(db, "wms_storages", "DepthNo", "INTEGER NOT NULL DEFAULT 1"); await EnsureSqliteColumnAsync(db, "wms_storages", "SiteCode", "TEXT NOT NULL DEFAULT ''"); await EnsureSqliteColumnAsync(db, "wms_storages", "Barcode", "TEXT NOT NULL DEFAULT ''"); await EnsureSqliteColumnAsync(db, "wms_containers", "AreaId", "TEXT"); await EnsureSqliteColumnAsync(db, "wms_containers", "Barcode", "TEXT NOT NULL DEFAULT ''"); await EnsureSqliteColumnAsync(db, "wms_containers", "Length", "REAL NOT NULL DEFAULT 0"); await EnsureSqliteColumnAsync(db, "wms_containers", "Width", "REAL NOT NULL DEFAULT 0"); await EnsureSqliteColumnAsync(db, "wms_containers", "Height", "REAL NOT NULL DEFAULT 0"); await EnsureSqliteColumnAsync(db, "wms_materials", "TypeCode", "TEXT NOT NULL DEFAULT ''"); await EnsureSqliteColumnAsync(db, "wms_materials", "Barcode", "TEXT NOT NULL DEFAULT ''"); await EnsureSqliteColumnAsync(db, "wms_materials", "LifecycleStatus", "TEXT NOT NULL DEFAULT 'Active'"); await EnsureSqliteColumnAsync(db, "wms_materials", "UnboundAt", "TEXT"); await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''"); } /// 为已存在的数据库补建 vehicle_alarms 表(车辆报警平台侧记录,幂等)。 private static async Task EnsureVehicleAlarmsTableAsync(PlatformDbContext db) { if (db.Database.IsSqlite()) { await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS vehicle_alarms ( id TEXT NOT NULL CONSTRAINT PK_vehicle_alarms PRIMARY KEY, car_id INTEGER NOT NULL DEFAULT 0, car_name TEXT NOT NULL DEFAULT '', info TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', first_at TEXT NOT NULL, last_at TEXT NOT NULL, resolved_at TEXT, duration_secs INTEGER, acknowledged INTEGER NOT NULL DEFAULT 0, acknowledged_at TEXT, acknowledged_by TEXT ); """); await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_car_status ON vehicle_alarms (car_id, status);"); await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_status ON vehicle_alarms (status);"); await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_first_at ON vehicle_alarms (first_at);"); return; } if (!await TableExistsAsync(db, "vehicle_alarms")) { var creator = db.GetService(); await creator.CreateTablesAsync(); } } /// 为已存在的数据库补建 cdm_tasks 表(CDM 搬运任务平台侧快照,幂等)。 private static async Task EnsureCdmTasksTableAsync(PlatformDbContext db) { if (db.Database.IsSqlite()) { await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS cdm_tasks ( id TEXT NOT NULL CONSTRAINT PK_cdm_tasks PRIMARY KEY, task_id TEXT, mission_id INTEGER NOT NULL DEFAULT 0, mission_name TEXT NOT NULL DEFAULT '', mission_type TEXT NOT NULL DEFAULT '', src_site_id INTEGER NOT NULL DEFAULT 0, src_label TEXT NOT NULL DEFAULT '', dst_site_id INTEGER NOT NULL DEFAULT 0, dst_label TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '', status_code TEXT NOT NULL DEFAULT '', car_id INTEGER, car_name TEXT, priority INTEGER NOT NULL DEFAULT 0, create_time TEXT, start_time TEXT, finish_time TEXT, stuck_reason TEXT, overdue INTEGER NOT NULL DEFAULT 0, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL ); """); await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_status_code ON cdm_tasks (status_code);"); await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_create_time ON cdm_tasks (create_time);"); return; } if (!await TableExistsAsync(db, "cdm_tasks")) { var creator = db.GetService(); await creator.CreateTablesAsync(); } } /// 为已存在的数据库补建 simple_fields 表(幂等)。 private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db) { if (db.Database.IsSqlite()) { await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS simple_fields ( id TEXT NOT NULL CONSTRAINT PK_simple_fields PRIMARY KEY, car_type TEXT NOT NULL DEFAULT '', field_type TEXT NOT NULL, "key" TEXT NOT NULL, value TEXT NOT NULL DEFAULT '', data_type TEXT NOT NULL DEFAULT '', chinese TEXT, english TEXT, other TEXT NOT NULL DEFAULT '', is_default INTEGER NOT NULL, create_time TEXT NOT NULL, update_time TEXT NOT NULL ); """); // 须先删旧索引 (field_type, other, key):把 other 清空为「其他语言」后会与旧唯一约束冲突。 await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_simple_fields_field_type_other_key;"); await db.Database.ExecuteSqlRawAsync(""" UPDATE simple_fields SET car_type = other WHERE (car_type IS NULL OR car_type = '') AND other <> ''; """); await db.Database.ExecuteSqlRawAsync(""" UPDATE simple_fields SET other = '' WHERE other <> '' AND other = car_type; """); await db.Database.ExecuteSqlRawAsync(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_simple_fields_car_type_field_type_key ON simple_fields (car_type, field_type, "key"); """); return; } // 非 SQLite:表不存在时尝试按当前模型创建(已有库不会走 EnsureCreated)。 if (!await TableExistsAsync(db, "simple_fields")) { var creator = db.GetService(); await creator.CreateTablesAsync(); } } /// 为已存在的数据库补建 WMS 搬运规则/任务相关表及库位扩展列(幂等)。 private static async Task EnsureWmsTransportSchemaAsync(PlatformDbContext db) { if (!db.Database.IsSqlite()) return; await EnsureSqliteColumnAsync(db, "wms_storages", "Status", "TEXT NOT NULL DEFAULT 'Available'"); await EnsureSqliteColumnAsync(db, "wms_storages", "Usage", "TEXT NOT NULL DEFAULT ''"); await EnsureSqliteColumnAsync(db, "wms_storages", "Priority", "INTEGER NOT NULL DEFAULT 0"); await EnsureSqliteColumnAsync(db, "wms_storages", "ZoneCode", "TEXT NOT NULL DEFAULT ''"); await EnsureSqliteColumnAsync(db, "wms_storages", "AllowInbound", "INTEGER NOT NULL DEFAULT 1"); await EnsureSqliteColumnAsync(db, "wms_storages", "AllowOutbound", "INTEGER NOT NULL DEFAULT 1"); await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS wms_transport_rules ( Id TEXT NOT NULL CONSTRAINT PK_wms_transport_rules PRIMARY KEY, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL, IsDeleted INTEGER NOT NULL, DeletedAt TEXT, DeletedBy TEXT, Version INTEGER NOT NULL, IsLock INTEGER NOT NULL, CreatedBy TEXT NOT NULL, UpdatedBy TEXT NOT NULL, Remark TEXT NOT NULL, Extend TEXT NOT NULL, Code TEXT NOT NULL, Name TEXT NOT NULL, TriggerType TEXT NOT NULL, Enabled INTEGER NOT NULL, Priority INTEGER NOT NULL, SourceSelectorJson TEXT NOT NULL, TargetSelectorJson TEXT NOT NULL, TaskOptionsJson TEXT NOT NULL ); """); await db.Database.ExecuteSqlRawAsync(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_transport_rules_Code ON wms_transport_rules (Code); """); await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS wms_transport_tasks ( Id TEXT NOT NULL CONSTRAINT PK_wms_transport_tasks PRIMARY KEY, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL, IsDeleted INTEGER NOT NULL, DeletedAt TEXT, DeletedBy TEXT, Version INTEGER NOT NULL, IsLock INTEGER NOT NULL, CreatedBy TEXT NOT NULL, UpdatedBy TEXT NOT NULL, Remark TEXT NOT NULL, Extend TEXT NOT NULL, BusinessType TEXT NOT NULL, RuleId TEXT, SourceStorageId TEXT NOT NULL, TargetStorageId TEXT NOT NULL, ContainerId TEXT NOT NULL, MaterialId TEXT, Quantity REAL, Status TEXT NOT NULL, DispatchMissionId TEXT NOT NULL DEFAULT '', DeliveryId TEXT NOT NULL DEFAULT '', DispatchStatus TEXT NOT NULL DEFAULT '', Reason TEXT NOT NULL DEFAULT '', SnapshotJson TEXT NOT NULL, ErrorMessage TEXT NOT NULL DEFAULT '', TaskPriority INTEGER NOT NULL DEFAULT 0 ); """); await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS wms_transport_reservations ( Id TEXT NOT NULL CONSTRAINT PK_wms_transport_reservations PRIMARY KEY, CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL, IsDeleted INTEGER NOT NULL, DeletedAt TEXT, DeletedBy TEXT, Version INTEGER NOT NULL, IsLock INTEGER NOT NULL, CreatedBy TEXT NOT NULL, UpdatedBy TEXT NOT NULL, Remark TEXT NOT NULL, Extend TEXT NOT NULL, TaskId TEXT NOT NULL, ContainerId TEXT NOT NULL, SourceStorageId TEXT NOT NULL, TargetStorageId TEXT NOT NULL, Status TEXT NOT NULL, ExpiresAt TEXT ); """); await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS wms_transport_task_history ( Id TEXT NOT NULL CONSTRAINT PK_wms_transport_task_history PRIMARY KEY, TaskId TEXT NOT NULL, FromStatus TEXT NOT NULL, ToStatus TEXT NOT NULL, Operator TEXT NOT NULL, OperatedAt TEXT NOT NULL, Reason TEXT NOT NULL, ErrorMessage TEXT NOT NULL, SnapshotJson TEXT NOT NULL ); """); // 库位占用唯一约束(幂等);若库内已有重复占用会创建失败,不阻断启动 try { await db.Database.ExecuteSqlRawAsync(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_container_locations_StorageLocationId ON wms_container_locations (LocationId) WHERE LocationType = 'Storage' AND IsDeleted = 0; """); } catch (Exception ex) { Console.Error.WriteLine( $"[WMS] 无法创建库位占用唯一索引 IX_wms_container_locations_StorageLocationId(可能已有重复占用): {ex.Message}"); } } private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition) { // 仅允许内部迁移调用方传入的标识符;拒绝注入用分隔符/空白。 static bool IsSafeIdent(string s) => !string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_'); if (!IsSafeIdent(table) || !IsSafeIdent(column) || definition.Contains(';') || definition.Contains("--") || !System.Text.RegularExpressions.Regex.IsMatch(definition, @"^[A-Za-z0-9_()'.,\s]+$")) throw new ArgumentException("unsafe sqlite migration identifier"); var conn = db.Database.GetDbConnection(); if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(); await using var cmd = conn.CreateCommand(); cmd.CommandText = $"PRAGMA table_info({table});"; await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var name = reader.GetString(1); if (string.Equals(name, column, StringComparison.OrdinalIgnoreCase)) return; } await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} ADD COLUMN {column} {definition};"); } /// 为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。 private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db) { if (db.Database.IsSqlite()) { await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts ( user_id TEXT NOT NULL, scope TEXT NOT NULL, keys_json TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL, CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope) ); """); return; } if (!await TableExistsAsync(db, "user_dashboard_shortcuts")) { var creator = db.GetService(); await creator.CreateTablesAsync(); } } /// /// 检查表是否存在 /// /// 数据库上下文 /// 表名 /// 表是否存在 private static async Task TableExistsAsync(PlatformDbContext db, string table) { var conn = db.Database.GetDbConnection(); if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(); try { await using var cmd = conn.CreateCommand(); if (db.Database.IsSqlServer()) { cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @t"; var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p); } else if (db.Database.IsNpgsql()) { cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_name = @t"; var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p); } else if (db.Database.IsMySql()) { cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @t"; var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p); } else { return false; } var result = await cmd.ExecuteScalarAsync(); return result != null; } finally { if (conn.State == System.Data.ConnectionState.Open) await conn.CloseAsync(); } } private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider) { var key = provider.Trim().ToLowerInvariant() switch { "postgres" or "postgresql" or "npgsql" => "PostgreSQL", "mssql" => "SqlServer", _ => provider }; var configured = configuration.GetConnectionString(key) ?? configuration.GetConnectionString("Platform"); if (!string.IsNullOrWhiteSpace(configured)) { return IsSqlite(provider) ? NormalizeSqliteConnection(configured, env) : configured; } var dataDir = Path.Combine(env.ContentRootPath, "data"); Directory.CreateDirectory(dataDir); return $"Data Source={Path.Combine(dataDir, "platform.db")}"; } private static bool IsSqlite(string provider) => string.Equals(provider.Trim(), "sqlite", StringComparison.OrdinalIgnoreCase); private static string NormalizeSqliteConnection(string connection, IWebHostEnvironment env) { var builder = new SqliteConnectionStringBuilder(connection); if (string.IsNullOrWhiteSpace(builder.DataSource)) return connection; if (builder.DataSource is ":memory:") return connection; if (!Path.IsPathRooted(builder.DataSource)) { builder.DataSource = Path.Combine(env.ContentRootPath, builder.DataSource); } var dir = Path.GetDirectoryName(builder.DataSource); if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); return builder.ToString(); } }