wenxiangchao
3 天以前 b544d5cccd76735e034c8055918a50927b437474
Plugin.DeviceConfig/Model/DeviceConfigModel.cs
@@ -49,6 +49,11 @@
                        DriverType TEXT NOT NULL,
                        Transport TEXT NOT NULL,
                        Endpoint TEXT NOT NULL,
                        ExecutionMode TEXT NOT NULL DEFAULT 'Local',
                        AuthorityId TEXT NOT NULL DEFAULT '',
                        BrokerId TEXT NOT NULL DEFAULT '',
                        OwnerProcessId TEXT NOT NULL DEFAULT '',
                        PhysicalSessionKey TEXT NOT NULL DEFAULT '',
                        EffectiveVersion TEXT NOT NULL DEFAULT '',
                        CreatedAt TEXT NOT NULL,
                        UpdatedAt TEXT NOT NULL
@@ -101,10 +106,39 @@
                };
                helper.ExecuteBatchSql(sqls);
                // V3.0 §05:执行位置字段迁移(旧库补列,新库已在建表语句中包含)
                MigrateExecutionColumns(helper);
            }
            catch (Exception ex)
            {
                throw new Exception($"初始化设备配置数据库失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// V3.0:旧版 DeviceDefinition 表补充执行位置五列(SQLite 无行级锁假设,逐列 PRAGMA 检查后 ALTER)
        /// </summary>
        private static void MigrateExecutionColumns(HMI.Core.Util.SqliteHelper helper)
        {
            var dt = helper.GetDataTable("PRAGMA table_info(DeviceDefinition);");
            var existing = new HashSet<string>(
                dt.Rows.Cast<DataRow>().Select(r => r["name"].ToString()), StringComparer.OrdinalIgnoreCase);
            var additions = new (string Col, string Ddl)[]
            {
                ("ExecutionMode", "ALTER TABLE DeviceDefinition ADD COLUMN ExecutionMode TEXT NOT NULL DEFAULT 'Local';"),
                ("AuthorityId", "ALTER TABLE DeviceDefinition ADD COLUMN AuthorityId TEXT NOT NULL DEFAULT '';"),
                ("BrokerId", "ALTER TABLE DeviceDefinition ADD COLUMN BrokerId TEXT NOT NULL DEFAULT '';"),
                ("OwnerProcessId", "ALTER TABLE DeviceDefinition ADD COLUMN OwnerProcessId TEXT NOT NULL DEFAULT '';"),
                ("PhysicalSessionKey", "ALTER TABLE DeviceDefinition ADD COLUMN PhysicalSessionKey TEXT NOT NULL DEFAULT '';"),
            };
            foreach (var (col, ddl) in additions)
            {
                if (!existing.Contains(col))
                {
                    helper.ExecuteSql(ddl);
                }
            }
        }
@@ -156,13 +190,18 @@
                device.UpdatedAt = DateTime.Now;
                var sql = $@"INSERT OR REPLACE INTO DeviceDefinition 
                    (DeviceId, Kind, DriverType, Transport, Endpoint, EffectiveVersion, CreatedAt, UpdatedAt)
                    (DeviceId, Kind, DriverType, Transport, Endpoint, ExecutionMode, AuthorityId, BrokerId, OwnerProcessId, PhysicalSessionKey, EffectiveVersion, CreatedAt, UpdatedAt)
                    VALUES (
                        '{Escape(device.DeviceId)}',
                        '{device.Kind}',
                        '{Escape(device.DriverType)}',
                        '{device.Transport}',
                        '{Escape(device.Endpoint)}',
                        '{device.ExecutionMode}',
                        '{Escape(device.AuthorityId)}',
                        '{Escape(device.BrokerId)}',
                        '{Escape(device.OwnerProcessId)}',
                        '{Escape(device.PhysicalSessionKey)}',
                        '{Escape(device.EffectiveVersion)}',
                        '{device.CreatedAt:yyyy-MM-dd HH:mm:ss}',
                        '{device.UpdatedAt:yyyy-MM-dd HH:mm:ss}'
@@ -203,12 +242,23 @@
                    DriverType = row["DriverType"].ToString() ?? string.Empty,
                    Transport = Enum.TryParse<TransportType>(row["Transport"].ToString(), out var tp) ? tp : TransportType.Tcp,
                    Endpoint = row["Endpoint"].ToString() ?? string.Empty,
                    ExecutionMode = HasColumn(row.Table, "ExecutionMode") && Enum.TryParse<ExecutionModeKind>(row["ExecutionMode"].ToString(), out var em)
                        ? em : ExecutionModeKind.Local,
                    AuthorityId = HasColumn(row.Table, "AuthorityId") ? row["AuthorityId"].ToString() ?? string.Empty : string.Empty,
                    BrokerId = HasColumn(row.Table, "BrokerId") ? row["BrokerId"].ToString() ?? string.Empty : string.Empty,
                    OwnerProcessId = HasColumn(row.Table, "OwnerProcessId") ? row["OwnerProcessId"].ToString() ?? string.Empty : string.Empty,
                    PhysicalSessionKey = HasColumn(row.Table, "PhysicalSessionKey") ? row["PhysicalSessionKey"].ToString() ?? string.Empty : string.Empty,
                    EffectiveVersion = row["EffectiveVersion"].ToString() ?? string.Empty,
                    CreatedAt = DateTime.TryParse(row["CreatedAt"].ToString(), out var ca) ? ca : DateTime.Now,
                    UpdatedAt = DateTime.TryParse(row["UpdatedAt"].ToString(), out var ua) ? ua : DateTime.Now,
                });
            }
            return list;
        }
        private static bool HasColumn(DataTable dt, string columnName)
        {
            return dt.Columns.Cast<DataColumn>().Any(c => c.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase));
        }
        #endregion
@@ -603,7 +653,13 @@
                var deviceDt = helper.GetDataTable("SELECT * FROM DeviceDefinition ORDER BY DeviceId;");
                foreach (DataRow row in deviceDt.Rows)
                {
                    sb.Append($"DEVICE:{row["DeviceId"]}|{row["Kind"]}|{row["DriverType"]}|{row["Transport"]}|{row["Endpoint"]};");
                    // V3.0:执行位置字段进入配置哈希(INV-04:Broker 连接携 configHash 对账);
                    // OwnerProcessId 属运行期信息,不参与哈希,避免宿主重启导致哈希漂移
                    string execMode = HasColumn(deviceDt, "ExecutionMode") ? row["ExecutionMode"].ToString() : "Local";
                    string authId = HasColumn(deviceDt, "AuthorityId") ? row["AuthorityId"].ToString() : "";
                    string bkId = HasColumn(deviceDt, "BrokerId") ? row["BrokerId"].ToString() : "";
                    string sessKey = HasColumn(deviceDt, "PhysicalSessionKey") ? row["PhysicalSessionKey"].ToString() : "";
                    sb.Append($"DEVICE:{row["DeviceId"]}|{row["Kind"]}|{row["DriverType"]}|{row["Transport"]}|{row["Endpoint"]}|{execMode}|{authId}|{bkId}|{sessKey};");
                }
                var channelDt = helper.GetDataTable("SELECT * FROM DeviceChannel ORDER BY DeviceId, ChannelId;");
@@ -630,6 +686,86 @@
        #endregion
        #region 发布门禁(V3.0 §05)
        /// <summary>
        /// 发布前校验执行位置绑定:仲裁域必填且合法、Broker 域一致性、会话唯一宿主。
        /// 返回错误列表,为空则允许发布。
        /// </summary>
        public List<string> ValidatePublishGate()
        {
            var errors = new List<string>();
            try
            {
                var devices = GetAllDevices();
                var authorityCatalog = V3AuthorityCatalog.AuthorityIds;
                // 1. 逐设备校验执行位置字段
                foreach (var dev in devices)
                {
                    if (string.IsNullOrWhiteSpace(dev.AuthorityId))
                    {
                        errors.Add($"设备 [{dev.DeviceId}] 未选择仲裁域(AuthorityId 必填,旧数据请重新保存)");
                        continue;
                    }
                    if (!authorityCatalog.Contains(dev.AuthorityId, StringComparer.OrdinalIgnoreCase))
                    {
                        errors.Add($"设备 [{dev.DeviceId}] 仲裁域 [{dev.AuthorityId}] 不在配置枚举候选中");
                        continue;
                    }
                    if (dev.ExecutionMode == ExecutionModeKind.Local)
                    {
                        // Local 设备必须由本机 Local 域仲裁(已分配给唯一执行进程)
                        if (!V3AuthorityCatalog.IsLocalAuthority(dev.AuthorityId))
                        {
                            errors.Add($"设备 [{dev.DeviceId}] 为 Local 直连,仲裁域应为 local- 前缀,实际为 [{dev.AuthorityId}]");
                        }
                    }
                    else
                    {
                        if (string.IsNullOrWhiteSpace(dev.BrokerId))
                        {
                            errors.Add($"设备 [{dev.DeviceId}] 为 Broker 共享,必须指定 BrokerId");
                        }
                        else if (!V3AuthorityCatalog.BrokerIds.Contains(dev.BrokerId, StringComparer.OrdinalIgnoreCase))
                        {
                            errors.Add($"设备 [{dev.DeviceId}] BrokerId [{dev.BrokerId}] 不在配置枚举候选中");
                        }
                        // BrokerId 与设备域一致:Broker 设备的 AuthorityId 即其 Broker 域
                        else if (!string.Equals(dev.BrokerId, dev.AuthorityId, StringComparison.OrdinalIgnoreCase))
                        {
                            errors.Add($"设备 [{dev.DeviceId}] BrokerId [{dev.BrokerId}] 与仲裁域 [{dev.AuthorityId}] 不一致");
                        }
                        if (string.IsNullOrWhiteSpace(dev.PhysicalSessionKey))
                        {
                            errors.Add($"设备 [{dev.DeviceId}] 为 Broker 共享,必须指定物理会话键 PhysicalSessionKey");
                        }
                    }
                }
                // 2. 同一物理会话只能有一个宿主:相同 SessionKey 的设备必须归属同一仲裁域
                var sessionGroups = devices
                    .Where(d => !string.IsNullOrWhiteSpace(d.PhysicalSessionKey))
                    .GroupBy(d => d.PhysicalSessionKey, StringComparer.OrdinalIgnoreCase);
                foreach (var grp in sessionGroups)
                {
                    var domains = grp.Select(d => d.AuthorityId).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
                    if (domains.Count > 1)
                    {
                        errors.Add($"物理会话 [{grp.Key}] 被多个仲裁域引用: {string.Join(", ", domains)}(同一会话只能有一个宿主)");
                    }
                }
            }
            catch (Exception ex)
            {
                throw new Exception($"发布门禁校验失败: {ex.Message}", ex);
            }
            return errors;
        }
        #endregion
        #region 辅助方法
        /// <summary>