using System;
|
using System.Diagnostics;
|
using System.IO;
|
using System.Linq;
|
using System.Threading;
|
using System.Threading.Tasks;
|
using Aging.Infrastructure.Broker;
|
using Aging.Infrastructure.Core;
|
using Aging.Infrastructure.Drivers;
|
using Aging.ResourceContracts;
|
using CanSimulatorServer;
|
using DeviceBroker;
|
using Xunit;
|
|
namespace Aging.MvpTests
|
{
|
/// <summary>
|
/// Broker 全链路 e2e:客户端(arb.db 租约 + CanDeviceDriver BrokerRpc 代理)↔
|
/// DeviceBroker 宿主(命名管道 + 栅栏再校验 + configHash 对账 + SessionGate 原子片段)。
|
/// 宿主的物理 CAN 驱动经 TCP 连进程内托管的模拟器服务(固定端口 19555,与 App.config 一致)。
|
/// 每个测试使用唯一 brokerId → 独立管道名,避免并行串扰。
|
/// </summary>
|
public class BrokerE2ETests : IDisposable
|
{
|
private const string DevId = "CAN-B1";
|
private const int SimPort = 19555;
|
|
private readonly string _dir;
|
private readonly string _dataDb;
|
private readonly string _arbDb;
|
private readonly string _brokerId;
|
private readonly string _pipeName;
|
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
private readonly Task _serverTask;
|
private readonly DbLeaseStore _clientLease;
|
private readonly TcpSimulatorHost _sim;
|
|
public BrokerE2ETests()
|
{
|
_dir = Path.Combine(Path.GetTempPath(), "brk_" + Guid.NewGuid().ToString("N"));
|
Directory.CreateDirectory(_dir);
|
_dataDb = Path.Combine(_dir, "data.db");
|
_arbDb = Path.Combine(_dir, "arb.db");
|
_brokerId = "broker-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
_pipeName = BrokerPipeName.For(_brokerId);
|
|
CreateDataDb();
|
|
// 模拟器服务先于宿主就位(宿主首次 CONNECT 会拉起物理 TCP 会话)
|
_sim = new TcpSimulatorHost();
|
_sim.StartAsync(SimPort).GetAwaiter().GetResult();
|
|
var server = new BrokerServer(_dataDb, _arbDb, _brokerId);
|
server.LoadDevices();
|
_serverTask = Task.Run(() => server.RunAsync(_cts.Token));
|
_clientLease = new DbLeaseStore(_arbDb, "local-main");
|
|
// 等监听就位(客户端本身有 3s 连接超时,另有 BROKER_UNREACHABLE 重试兜底)
|
Thread.Sleep(250);
|
}
|
|
public void Dispose()
|
{
|
try { _cts.Cancel(); } catch { }
|
try { _serverTask.Wait(1500); } catch { }
|
try { _sim?.Dispose(); } catch { }
|
_cts.Dispose();
|
foreach (var f in Directory.GetFiles(_dir))
|
{
|
try { File.Delete(f); } catch { }
|
}
|
try { Directory.Delete(_dir); } catch { }
|
}
|
|
// ==================== 正向链路 ====================
|
|
[Fact]
|
public async Task WithLease_Connect_Send_Query_Succeeds()
|
{
|
var grant = GrabLease();
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
|
var tx = await driver.SendCommandAsync("SEND:0x123:11 22", CancellationToken.None);
|
Assert.Contains("OK:TX", tx);
|
|
var status = await driver.QueryAsync("STATUS", CancellationToken.None);
|
Assert.Contains("STATUS", status);
|
|
// Broker 侧执行成功后租约仍有效(未被误释放)
|
Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
|
_clientLease.Release(grant, "test-end");
|
}
|
|
[Fact]
|
public async Task TwoSequencedFragments_SameSession_ExecuteSerially()
|
{
|
var grant = GrabLease();
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
|
// SessionGate 下同会话片段严格串行(同进程单客户端本就串行,验证不产生交错错误)
|
var a = await driver.SendCommandAsync("SEND:0x100:01", CancellationToken.None);
|
var b = await driver.SendCommandAsync("SEND:0x200:02", CancellationToken.None);
|
Assert.Contains("OK:TX", a);
|
Assert.Contains("OK:TX", b);
|
_clientLease.Release(grant, "test-end");
|
}
|
|
// ==================== 负向链路 ====================
|
|
[Fact]
|
public async Task NoLease_ConnectAllowed_Send_RejectedStaleLease()
|
{
|
// CONNECT/DISCONNECT 仅登记/释放宿主物理会话,豁免租约;
|
// SEND 有总线输出,无租约必须拒绝(INV-01 禁止无租约直接 I/O)
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
|
var ex = await Record.ExceptionAsync(
|
() => driver.SendCommandAsync("SEND:0x100:01", CancellationToken.None));
|
Assert.NotNull(ex);
|
Assert.Contains("LEASE_STALE", ex.Message);
|
}
|
|
[Fact]
|
public async Task ConfigHashMismatch_Rejected()
|
{
|
GrabLease(); // 有租约,但哈希对不上 → INV-04 拦截
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, "deadbeefdeadbeef");
|
|
var ex = await Record.ExceptionAsync(
|
() => RetryConnectAsync(driver, expectThrow: true));
|
Assert.NotNull(ex);
|
Assert.Contains("CONFIG_MISMATCH", ex.Message);
|
}
|
|
[Fact]
|
public async Task ReleasedLease_Send_Rejected()
|
{
|
var grant = GrabLease();
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
|
// 租约归还后再 I/O → 栅栏必须拒绝(禁止无租约直接 I/O)
|
_clientLease.Release(grant, "released-by-test");
|
var ex = await Record.ExceptionAsync(
|
() => driver.SendCommandAsync("SEND:0x100:01", CancellationToken.None));
|
Assert.NotNull(ex);
|
Assert.Contains("LEASE_STALE", ex.Message);
|
}
|
|
[Fact]
|
public async Task MaintenanceLease_BrokeredWrite_Succeeds()
|
{
|
// 调试页维护写权链路:维护租约写入 arb.db(OwnerType=Maintenance)后,
|
// Broker 栅栏同样放行(占用可见/拒绝语义已由生产场景用例覆盖)
|
var outcome = _clientLease.Acquire(new AcquireRequest
|
{
|
OwnerId = "maint-session-1",
|
OwnerType = OwnerTypeV3.Maintenance,
|
ResourceKeys = new[] { DevId },
|
TtlSeconds = 30
|
});
|
Assert.Equal(AcquireResultCode.Acquired, outcome.Code);
|
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
var tx = await driver.SendCommandAsync("SEND:0x300:AB", CancellationToken.None);
|
Assert.Contains("OK:TX", tx);
|
|
_clientLease.Release(outcome.Grants[0], "maint-end");
|
}
|
|
// ==================== 辅助 ====================
|
|
[Fact]
|
public async Task BrokerReceive_PiggybackPush_DeliveredToProxyDriver()
|
{
|
// 验收点:共享设备(Broker)能收到同总线对端发来的帧(经宿主捎带推送)。
|
// 修复历史上 BrokeredCanDriver 无接收事件、面板收不到 RX 的顽症。
|
var grant = GrabLease();
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
|
CanFrameish received = null;
|
driver.DriverFrameReceived += (s, f) => received = new CanFrameish { Id = f.FrameIdHex, Data = f.DataHex };
|
|
// 对端:同模拟器同总线 CAN_E2E 的节点 1,本地直连发帧
|
using var peer = new CanDeviceDriver("CAN-PEER", "CAN_E2E:1", "127.0.0.1", SimPort);
|
peer.ConnectAsync(CancellationToken.None).GetAwaiter().GetResult();
|
peer.SendCommandAsync("SEND:0x7E7:EE FF", CancellationToken.None).GetAwaiter().GetResult();
|
|
// 触发宿主 drain 捎带:任一非接收类查询(STATUS)即可带回推送
|
for (int i = 0; i < 20 && received == null; i++)
|
{
|
await driver.QueryAsync("STATUS", CancellationToken.None);
|
if (received == null) await Task.Delay(50);
|
}
|
|
Assert.NotNull(received);
|
Assert.Equal("0x7E7", received.Id);
|
Assert.Contains("EE FF", received.Data);
|
|
_clientLease.Release(grant, "test-end");
|
}
|
|
// ==================== 并发与生命周期 ====================
|
|
/// <summary>单客户端连续 5 次快速发送:验证 SessionGate 串行化不丢命令、租约不被误释放。</summary>
|
[Fact]
|
public async Task SingleClient_RapidSend5Times_AllSucceed_LeaseStaysActive()
|
{
|
var grant = GrabLease();
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
|
for (int i = 0; i < 5; i++)
|
{
|
var tx = await driver.SendCommandAsync($"SEND:0x{i:X3}:AA", CancellationToken.None);
|
Assert.Contains("OK:TX", tx);
|
}
|
|
// 5 次操作后租约仍 Active(未被意外释放或过期)
|
Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
|
_clientLease.Release(grant, "test-end");
|
}
|
|
/// <summary>
|
/// 两个 Broker 代理驱动同时持有同一设备的活跃租约,并发发送各 5 次。
|
/// 由于 arb.db 以 ResourceKey 为主键只允许一行 Active,第二个租约通过直接 SQL 插入模拟
|
/// (MVP 栅栏仅校验 Phase=Active + 未过期,不比对 OwnerId)。
|
/// 验证 SessionGate(SemaphoreSlim=1) 将两个并发请求串行执行,两者均成功。
|
/// </summary>
|
[Fact]
|
public async Task TwoClients_ConcurrentSend_BothSucceed_SessionGateSerializes()
|
{
|
// 客户端 A:经正常 Acquire 获取 Production 租约
|
var grantA = GrabLease();
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driverA = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driverA.ConnectAsync(CancellationToken.None));
|
|
// 客户端 B:直接往 arb.db 插入一条 Maintenance Active 租约行,模拟另一个持有者
|
InsertDirectLeaseRow(DevId, "concurrent-client-B", OwnerTypeV3.Maintenance, ttlSeconds: 30);
|
using var driverB = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driverB.ConnectAsync(CancellationToken.None));
|
|
// 并发发送各 5 次
|
var tasksA = Enumerable.Range(0, 5).Select(i =>
|
driverA.SendCommandAsync($"SEND:0x{i:X3}:AA", CancellationToken.None)).ToArray();
|
var tasksB = Enumerable.Range(0, 5).Select(i =>
|
driverB.SendCommandAsync($"SEND:0x{i + 0x10:X3}:BB", CancellationToken.None)).ToArray();
|
|
var results = await Task.WhenAll(tasksA.Concat(tasksB));
|
Assert.All(results, r => Assert.Contains("OK:TX", r));
|
|
// 两个租约行均保持 Active
|
Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
|
_clientLease.Release(grantA, "test-end-A");
|
}
|
|
/// <summary>
|
/// 两个 Production 客户端竞争同一设备租约:第一个成功获取,第二个应得到 ConflictHeld。
|
/// </summary>
|
[Fact]
|
public void TwoProductionClients_SecondGetsConflictHeld()
|
{
|
var grantA = GrabLease();
|
Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
|
|
// 第二个客户端(不同 OwnerId,同 OwnerType=Production)
|
var secondLease = new DbLeaseStore(_arbDb, "second-client");
|
var outcome = secondLease.Acquire(new AcquireRequest
|
{
|
OwnerId = "competing-client",
|
OwnerType = OwnerTypeV3.Production,
|
ResourceKeys = new[] { DevId },
|
TtlSeconds = 30,
|
WaitTimeoutMs = 0
|
});
|
|
Assert.Equal(AcquireResultCode.ConflictHeld, outcome.Code);
|
Assert.NotNull(outcome.Holder);
|
|
_clientLease.Release(grantA, "test-end");
|
}
|
|
/// <summary>
|
/// 完整生命周期:Acquire → Connect → Send → Release → 再 Acquire → 再 Send。
|
/// 验证租约释放后资源回到 Free,可被重新获取并继续使用。
|
/// </summary>
|
[Fact]
|
public async Task LeaseLifecycle_AcquireSendReleaseAcquireSendAgain_AllSucceed()
|
{
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
|
// 第一轮:获取租约 → 连接 → 发送 → 释放
|
LeaseGrant grant1;
|
{
|
var outcome = _clientLease.Acquire(new AcquireRequest
|
{
|
OwnerId = "lifecycle-1",
|
OwnerType = OwnerTypeV3.Production,
|
ResourceKeys = new[] { DevId },
|
TtlSeconds = 30
|
});
|
Assert.Equal(AcquireResultCode.Acquired, outcome.Code);
|
grant1 = outcome.Grants[0];
|
}
|
|
using (var driver1 = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash))
|
{
|
await RetryAsync(() => driver1.ConnectAsync(CancellationToken.None));
|
var tx = await driver1.SendCommandAsync("SEND:0x100:AA", CancellationToken.None);
|
Assert.Contains("OK:TX", tx);
|
}
|
|
Assert.True(_clientLease.Release(grant1, "lifecycle-1-end"));
|
Assert.Equal(LeasePhase.Free, _clientLease.PhaseOf(DevId));
|
|
// 第二轮:重新获取 → 连接 → 发送
|
LeaseGrant grant2;
|
{
|
var outcome = _clientLease.Acquire(new AcquireRequest
|
{
|
OwnerId = "lifecycle-2",
|
OwnerType = OwnerTypeV3.Production,
|
ResourceKeys = new[] { DevId },
|
TtlSeconds = 30
|
});
|
Assert.Equal(AcquireResultCode.Acquired, outcome.Code);
|
grant2 = outcome.Grants[0];
|
}
|
|
using (var driver2 = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash))
|
{
|
await RetryAsync(() => driver2.ConnectAsync(CancellationToken.None));
|
var tx = await driver2.SendCommandAsync("SEND:0x200:BB", CancellationToken.None);
|
Assert.Contains("OK:TX", tx);
|
}
|
|
_clientLease.Release(grant2, "lifecycle-2-end");
|
}
|
|
/// <summary>
|
/// 并发压力:单客户端 20 个 Task 同时发送,验证 SessionGate + 管道写入锁不崩溃。
|
/// </summary>
|
[Fact]
|
public async Task SingleClient_ConcurrentSend20Tasks_AllSucceed()
|
{
|
var grant = GrabLease();
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
|
var tasks = Enumerable.Range(0, 20).Select(i =>
|
driver.SendCommandAsync($"SEND:0x{i:X3}:DD", CancellationToken.None)).ToArray();
|
|
var results = await Task.WhenAll(tasks);
|
Assert.All(results, r => Assert.Contains("OK:TX", r));
|
|
Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
|
_clientLease.Release(grant, "test-end");
|
}
|
|
// ==================== 辅助 ====================
|
|
/// <summary>绕过 Acquire 互斥,直接向 arb.db 插入一条 Active 租约行(测试并发场景用)。</summary>
|
private void InsertDirectLeaseRow(string deviceId, string ownerId, OwnerTypeV3 ownerType, int ttlSeconds)
|
{
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
using var conn = new System.Data.SQLite.SQLiteConnection(
|
$"Data Source={_arbDb};Version=3;");
|
conn.Open();
|
using var cmd = conn.CreateCommand();
|
cmd.CommandText = @"
|
INSERT OR REPLACE INTO resource_state
|
(ResourceKey, Generation, Phase, LeaseId, OwnerId, OwnerType,
|
ProcessInstanceId, MachineName, HostProcessId, Epoch, Reason,
|
AcquiredUtcMs, ExpiresUtcMs, UpdatedUtcMs)
|
VALUES (@key, 1, 1, @lid, @oid, @otype, '', 'local', 0, 1, 'direct-insert',
|
@now, @exp, @now)";
|
cmd.Parameters.AddWithValue("@key", DbLeaseStore.NormKey(deviceId));
|
cmd.Parameters.AddWithValue("@lid", "lease_" + Guid.NewGuid().ToString("N").Substring(0, 12));
|
cmd.Parameters.AddWithValue("@oid", ownerId);
|
cmd.Parameters.AddWithValue("@otype", (int)ownerType);
|
cmd.Parameters.AddWithValue("@now", now);
|
cmd.Parameters.AddWithValue("@exp", now + ttlSeconds * 1000L);
|
cmd.ExecuteNonQuery();
|
}
|
|
private sealed class CanFrameish { public string Id; public string Data; public string Src; }
|
|
[Fact]
|
public async Task BrokerResponderEcho_ViaBusEcho_DeliveredToProxyDriver()
|
{
|
// 复现用户场景:仅 Broker 设备自己发帧 + 总线应答器开启 → 应收到 RESPONDER 回显。
|
var grant = GrabLease();
|
var hash = DeviceConfigHash.Compute(_dataDb);
|
using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
|
await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
|
|
CanFrameish echo = null;
|
driver.DriverFrameReceived += (s, f) => echo = new CanFrameish { Id = f.FrameIdHex, Data = f.DataHex, Src = f.SourceNodeId };
|
|
// 经 RPC 开启总线应答器
|
var on = await driver.QueryAsync("BUS_ECHO:ON", CancellationToken.None);
|
Assert.StartsWith("OK:", on);
|
|
// Broker 设备自己发一帧
|
await driver.SendCommandAsync("SEND:0x111:AA BB CC", CancellationToken.None);
|
|
// 轮询 STATUS 触发宿主 drain 捎带,等回显送达
|
for (int i = 0; i < 30 && echo == null; i++)
|
{
|
await driver.QueryAsync("STATUS", CancellationToken.None);
|
if (echo == null) await Task.Delay(50);
|
}
|
|
Assert.NotNull(echo);
|
Assert.Equal("0x111", echo.Id);
|
Assert.Contains("RESPONDER", echo.Src);
|
|
_clientLease.Release(grant, "test-end");
|
}
|
|
// ==================== 辅助(原有) ====================
|
|
private LeaseGrant GrabLease()
|
{
|
var outcome = _clientLease.Acquire(new AcquireRequest
|
{
|
OwnerId = "e2e-" + Guid.NewGuid().ToString("N"),
|
OwnerType = OwnerTypeV3.Production,
|
ResourceKeys = new[] { DevId },
|
TtlSeconds = 30
|
});
|
Assert.Equal(AcquireResultCode.Acquired, outcome.Code);
|
return outcome.Grants[0];
|
}
|
|
private static async Task RetryAsync(Func<Task> action, int attempts = 4)
|
{
|
Exception last = null;
|
for (int i = 0; i < attempts; i++)
|
{
|
try { await action().ConfigureAwait(false); return; }
|
catch (InvalidOperationException ex) when (ex.Message.Contains("BROKER_UNREACHABLE"))
|
{
|
last = ex; // 宿主尚未开始监听,短暂重试
|
await Task.Delay(400).ConfigureAwait(false);
|
}
|
}
|
throw last ?? new InvalidOperationException("重试仍失败");
|
}
|
|
/// <summary>负向用例:握手可重试(管道未就位),但栅栏/哈希拒绝属终态应直接上抛。</summary>
|
private async Task RetryConnectAsync(CanDeviceDriver driver, bool expectThrow)
|
{
|
Exception last = null;
|
for (int i = 0; i < 4; i++)
|
{
|
try
|
{
|
await driver.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
|
return; // 意外成功——由外层断言捕捉
|
}
|
catch (InvalidOperationException ex) when (ex.Message.Contains("BROKER_UNREACHABLE"))
|
{
|
last = ex;
|
await Task.Delay(400).ConfigureAwait(false);
|
}
|
catch (InvalidOperationException)
|
{
|
throw; // LEASE_STALE / CONFIG_MISMATCH 终态
|
}
|
}
|
throw last ?? new InvalidOperationException("未获得预期拒绝");
|
}
|
|
private void CreateDataDb()
|
{
|
using var conn = new System.Data.SQLite.SQLiteConnection(
|
$"Data Source={_dataDb};Version=3;");
|
conn.Open();
|
using (var ddl = conn.CreateCommand())
|
{
|
ddl.CommandText = @"
|
CREATE TABLE DeviceDefinition (
|
DeviceId TEXT PRIMARY KEY, Kind TEXT NOT NULL, 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);";
|
ddl.ExecuteNonQuery();
|
}
|
using (var ins = conn.CreateCommand())
|
{
|
ins.CommandText = @"
|
INSERT INTO DeviceDefinition VALUES (
|
'CAN-B1','CanDevice','CAN','Can','CAN_E2E:0',
|
'Broker',@auth,@broker,'','can/CAN_E2E/main',
|
'V1','2026-11-08 00:00:00','2026-11-08 00:00:00');";
|
ins.Parameters.AddWithValue("@auth", _brokerId);
|
ins.Parameters.AddWithValue("@broker", _brokerId);
|
ins.ExecuteNonQuery();
|
}
|
}
|
}
|
}
|