111111
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
using Evergreen.Health.Application.Health.Integrations.Ib43;
|
||||
using Evergreen.Health.Application.Health.Entities;
|
||||
using Evergreen.Health.Application.Health.Devices.Audio;
|
||||
using Evergreen.Health.Application.Health.Devices.Climate;
|
||||
using Evergreen.Health.Application.Health.Devices.Curtain;
|
||||
using Evergreen.Health.Application.Health.Devices.Dehumidifier;
|
||||
using Evergreen.Health.Application.Health.Devices.Environment;
|
||||
using Evergreen.Health.Application.Health.Devices.Fragrance;
|
||||
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
||||
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
||||
using Evergreen.Health.Application.Health.Scenes;
|
||||
using Evergreen.Health.Application.Health.Sleep;
|
||||
using SqlSugar;
|
||||
@@ -13,6 +19,7 @@ using MQTTnet.Protocol;
|
||||
using MQTTnet.Server;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
@@ -23,9 +30,17 @@ public sealed class Ib43MqttIntegrationTests
|
||||
{
|
||||
private const string LeftBinding = "a98788f2aeeb40b68f145a182d99f832";
|
||||
private const string TargetCurtain = "cover.test_curtain";
|
||||
private const string TargetDehumidifier = "humidifier.test";
|
||||
private const string HumiditySensor = "sensor.test_humidity";
|
||||
private const string TargetClimate = "climate.test";
|
||||
private const string TemperatureSensor = "sensor.test_temperature";
|
||||
private const string FragranceButton = "button.test_fragrance";
|
||||
private const string WakeFragranceButton = "button.test_wake_fragrance";
|
||||
private const string Speaker = "media_player.test_speaker";
|
||||
private const string TrackUrl = "https://example.test/sleep.mp3";
|
||||
|
||||
[Fact]
|
||||
public async Task Real_MQTT_receives_supplier_examples_and_resubscribes_after_broker_restart()
|
||||
public async Task Real_MQTT_resubscribes_and_only_fresh_empty_to_occupied_triggers_after_broker_restart()
|
||||
{
|
||||
// 只监听 loopback,随机端口;不访问用户的 EMQX、数据库或家具。
|
||||
using var reservation = new TcpListener(IPAddress.Loopback, 0);
|
||||
@@ -43,17 +58,55 @@ public sealed class Ib43MqttIntegrationTests
|
||||
var store = new Ib43StateStore(options, new(), TimeProvider.System);
|
||||
using var sink = new DatabaseSink(binding);
|
||||
var gateway = new FakeHaGateway { CoverCloseResult = "closed" };
|
||||
gateway.States[TargetCurtain] = new() { EntityId = TargetCurtain, State = "open",
|
||||
var openCurtain = new HomeAssistantState { EntityId = TargetCurtain, State = "open",
|
||||
Attributes = JsonSerializer.SerializeToElement(new { supported_features = 2 }) };
|
||||
var haOptions = Options.Create(new HomeAssistantOptions { Entities = new() { Curtain = TargetCurtain } });
|
||||
var stoppedDehumidifier = new HomeAssistantState { EntityId = TargetDehumidifier, State = "off" };
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
gateway.States[HumiditySensor] = new() { EntityId = HumiditySensor, State = "64", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "%" }) };
|
||||
gateway.States[TemperatureSensor] = new() { EntityId = TemperatureSensor, State = "27", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) };
|
||||
gateway.States[FragranceButton] = new() { EntityId = FragranceButton, State = "unknown" };
|
||||
gateway.States[WakeFragranceButton] = new() { EntityId = WakeFragranceButton, State = "unknown" };
|
||||
gateway.States[Speaker] = new() { EntityId = Speaker, State = "idle" };
|
||||
var haOptions = Options.Create(new HomeAssistantOptions
|
||||
{ Entities = new() { Curtain = TargetCurtain, Dehumidifier = TargetDehumidifier, Humidity = HumiditySensor,
|
||||
Climate = TargetClimate, Temperature = TemperatureSensor, FragranceQuickRelease = FragranceButton,
|
||||
WakeFragranceQuickRelease = WakeFragranceButton, Speaker = Speaker } });
|
||||
var execution = new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero };
|
||||
var runner = new DeviceActionRunner(gateway, TimeProvider.System, execution);
|
||||
var curtainAction = new BedPresenceCurtainCloseAction(new CurtainModule(runner, haOptions), runner, haOptions);
|
||||
var preview = new BedPresenceDehumidifierPreview(gateway, haOptions, TimeProvider.System);
|
||||
var dehumidifier = new BedPresenceDehumidifierStartAction(new DehumidifierModule(runner, haOptions),
|
||||
runner, haOptions, TimeProvider.System);
|
||||
var room = new IndoorEnvironmentReader(haOptions, TimeProvider.System);
|
||||
var climate = new BedPresenceClimatePreview(gateway, room, haOptions);
|
||||
var heat = new BedPresenceClimateHeatAction(new ClimateModule(runner, haOptions), room, runner, haOptions);
|
||||
var fragrance = new BedPresenceFragranceReleaseAction(runner, haOptions, wakeDevice: false);
|
||||
var wakeFragrance = new BedPresenceFragranceReleaseAction(runner, haOptions, wakeDevice: true);
|
||||
var audio = new FakeAudioGateway();
|
||||
var music = new TcmMusicTransitionService(audio, Options.Create(new TcmMusicOptions
|
||||
{
|
||||
Tracks = new() { ["balanced"] = [new() { Url = TrackUrl, DurationSeconds = 30 }] }
|
||||
}), NullLogger<TcmMusicTransitionService>.Instance, new ManualTestTimeProvider());
|
||||
var speaker = new BedPresenceSleepMusicAction(gateway, music, haOptions);
|
||||
var executionLog = new RecordingExecutionLog();
|
||||
using var automation = new BedPresenceAutomation(Options.Create(new BedPresenceAutomationOptions
|
||||
{ Mode = BedAutomationMode.Execute, BindingId = LeftBinding, ActionKey = "curtain-close", TargetEntityId = TargetCurtain }),
|
||||
[curtainAction], TimeProvider.System, NullLogger<BedPresenceAutomation>.Instance);
|
||||
{
|
||||
Mode = BedAutomationMode.Managed, ActionKey = curtainAction.Key, TargetEntityId = TargetCurtain,
|
||||
AdditionalRules = [new() { Mode = BedAutomationMode.Managed, ActionKey = preview.Key, TargetEntityId = TargetDehumidifier },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = dehumidifier.Key, TargetEntityId = TargetDehumidifier },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = climate.Key, TargetEntityId = TargetClimate },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = heat.Key, TargetEntityId = TargetClimate },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = fragrance.Key, TargetEntityId = FragranceButton },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = wakeFragrance.Key, TargetEntityId = WakeFragranceButton },
|
||||
new() { Mode = BedAutomationMode.Managed, ActionKey = speaker.Key, TargetEntityId = Speaker }]
|
||||
}), [curtainAction, preview, dehumidifier, climate, heat, fragrance, wakeFragrance, speaker], TimeProvider.System,
|
||||
NullLogger<BedPresenceAutomation>.Instance, executionLog);
|
||||
var observedPresence = new ObservedPresenceSink(automation);
|
||||
using var service = new Ib43MqttService(options, new Ib43MqttTransportFactory(), store,
|
||||
TimeProvider.System, NullLogger<Ib43MqttService>.Instance, sink, automation);
|
||||
TimeProvider.System, NullLogger<Ib43MqttService>.Instance, sink, observedPresence);
|
||||
using var publisher = factory.CreateMqttClient();
|
||||
try
|
||||
{
|
||||
@@ -79,42 +132,209 @@ public sealed class Ib43MqttIntegrationTests
|
||||
Assert.Equal(80d, (await sink.Repository.Latest(2, "right-test"))["heartRate"].Value);
|
||||
Assert.Empty(gateway.Calls);
|
||||
|
||||
await PublishPresence(publisher, binding, 0, 0);
|
||||
await PublishPresence(publisher, binding, 1, 0);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 4);
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1));
|
||||
Assert.Empty(gateway.Calls);
|
||||
var bed = new BedAutomationBinding(LeftBinding, 1, binding.ProductKey, binding.SerialNumber, BedSide.Left, true);
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
|
||||
// 左侧空床后上床才触发。连续重复在床不会重启,右侧的变化不触发左侧规则。
|
||||
await PublishPresence(publisher, binding, 0, 0);
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 6 && gateway.Calls.Count == 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 8 && gateway.Calls.Count == 1);
|
||||
Assert.Equal("readback-confirmed", await executionLog.WaitForResult(curtainAction.Key));
|
||||
var close = Assert.Single(gateway.Calls);
|
||||
Assert.Equal(TargetCurtain, close.EntityId);
|
||||
Assert.Equal("close_cover", close.Service);
|
||||
Assert.Equal("closed", gateway.States[TargetCurtain].State);
|
||||
Assert.Equal(1d, (await sink.Repository.Latest(1, LeftBinding))["presence"].Value);
|
||||
automation.Disable(LeftBinding, 1);
|
||||
|
||||
Assert.True(automation.TryEnable(bed, preview.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1));
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 11);
|
||||
Assert.Equal("preview-would-start", await executionLog.WaitForResult(preview.Key));
|
||||
Assert.Single(gateway.Calls);
|
||||
Assert.Equal("off", gateway.States[TargetDehumidifier].State);
|
||||
Assert.True(automation.Disable(LeftBinding, 1, preview.Key));
|
||||
|
||||
Assert.True(automation.TryEnable(bed, dehumidifier.Key));
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await PublishPresence(publisher, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 14);
|
||||
Assert.Equal("readback-confirmed", await executionLog.WaitForResult(dehumidifier.Key));
|
||||
Assert.Equal("on", gateway.States[TargetDehumidifier].State);
|
||||
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
await PublishPresence(publisher, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
var oldConnection = observedPresence.LastLeft!.ConnectionId;
|
||||
|
||||
// 保持两条实物规则开启,断开前的空床基线不能与新连接的首条在床组成上床事件。
|
||||
await broker.StopAsync();
|
||||
await WaitFor(() => store.Snapshot()[0].Transport != "connected");
|
||||
await broker.StartAsync();
|
||||
await WaitFor(() => store.Snapshot()[0].Transport == "connected");
|
||||
using var publisherAfterRestart = factory.CreateMqttClient();
|
||||
await publisherAfterRestart.ConnectAsync(new MqttClientOptionsBuilder().WithTcpServer("127.0.0.1", port).Build());
|
||||
Assert.True(automation.IsEnabled(LeftBinding, 1));
|
||||
Assert.True(automation.IsEnabled(LeftBinding, 1, dehumidifier.Key));
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var firstOnNewConnection = observedPresence.LastLeft!;
|
||||
Assert.NotEqual(oldConnection, firstOnNewConnection.ConnectionId);
|
||||
Assert.Equal(3, executionLog.Started.Count);
|
||||
Assert.Equal(2, gateway.Calls.Count);
|
||||
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var freshTransition = observedPresence.LastLeft!;
|
||||
Assert.Equal(firstOnNewConnection.ConnectionId, freshTransition.ConnectionId);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 19 && executionLog.Completed.Count >= 5);
|
||||
|
||||
await publisherAfterRestart.PublishAsync(new MqttApplicationMessageBuilder().WithTopic(binding.PostTopic)
|
||||
.WithPayload("{\"method\":\"thing.property.post\",\"params\":{\"deviceName\":\"IB43SIM001\",\"HR\":[75,16,0,81,18,0]}}")
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce).Build());
|
||||
await WaitFor(() => store.Snapshot()[0].Readings["left.heartRate"].Value == 75);
|
||||
Assert.Equal(1d, store.Snapshot()[0].Readings["left.presence"].Value);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 7);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 20);
|
||||
Assert.Equal(75d, (await sink.Repository.Latest(1, LeftBinding))["heartRate"].Value);
|
||||
Assert.Single(gateway.Calls);
|
||||
var newExecutions = executionLog.Started.Skip(3).ToArray();
|
||||
Assert.Equal(new[] { curtainAction.Key, dehumidifier.Key },
|
||||
newExecutions.Select(entry => entry.Action).OrderBy(key => key, StringComparer.Ordinal));
|
||||
Assert.All(newExecutions, entry => Assert.Equal(freshTransition.ReceivedAt, entry.TriggeredAt));
|
||||
Assert.All(executionLog.Completed.Skip(3), result => Assert.Equal("readback-confirmed", result));
|
||||
Assert.Equal(5, executionLog.Completed.Count);
|
||||
Assert.Equal(new[] { "close_cover", "turn_on" },
|
||||
gateway.Calls.Skip(2).Select(command => command.Service).OrderBy(name => name, StringComparer.Ordinal));
|
||||
|
||||
Assert.True(automation.Disable(LeftBinding, 1));
|
||||
Assert.True(automation.Disable(LeftBinding, 1, dehumidifier.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, climate.Key));
|
||||
Assert.True(automation.TryEnable(bed, climate.Key));
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 23);
|
||||
Assert.Equal("preview-climate-cool", await executionLog.WaitForResult(climate.Key));
|
||||
Assert.Equal(climate.Key, Assert.Single(executionLog.Started.Skip(5)).Action);
|
||||
Assert.Equal(4, gateway.Calls.Count);
|
||||
|
||||
Assert.True(automation.Disable(LeftBinding, 1, climate.Key));
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, heat.Key));
|
||||
gateway.States[TargetCurtain] = openCurtain;
|
||||
gateway.States[TargetDehumidifier] = stoppedDehumidifier;
|
||||
gateway.States[TemperatureSensor] = new() { EntityId = TemperatureSensor, State = "20", LastReported = DateTimeOffset.UtcNow,
|
||||
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) };
|
||||
gateway.States[TargetClimate] = new() { EntityId = TargetClimate, State = "off",
|
||||
Attributes = JsonSerializer.SerializeToElement(new { hvac_modes = new[] { "off", "heat", "cool" }, supported_features = 1,
|
||||
temperature_unit = "°C", min_temp = 16, max_temp = 30, target_temp_step = 1, temperature = 24 }) };
|
||||
Assert.True(automation.TryEnable(bed));
|
||||
Assert.True(automation.TryEnable(bed, dehumidifier.Key));
|
||||
Assert.True(automation.TryEnable(bed, heat.Key));
|
||||
foreach (var action in new[] { fragrance.Key, wakeFragrance.Key, speaker.Key })
|
||||
{
|
||||
Assert.False(automation.IsEnabled(LeftBinding, 1, action));
|
||||
Assert.True(automation.TryEnable(bed, action));
|
||||
}
|
||||
await PublishPresence(publisherAfterRestart, binding, 0, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Empty });
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => observedPresence.LastLeft is { Presence: BedPresence.Occupied });
|
||||
var combinedTransition = observedPresence.LastLeft!;
|
||||
await PublishPresence(publisherAfterRestart, binding, 1, 1);
|
||||
await WaitFor(() => Volatile.Read(ref sink.SavedReports) == 26 && executionLog.Completed.Count >= 12);
|
||||
var combinedExecutions = executionLog.Started.Skip(6).ToArray();
|
||||
Assert.Equal(new[] { heat.Key, curtainAction.Key, dehumidifier.Key, fragrance.Key, speaker.Key, wakeFragrance.Key },
|
||||
combinedExecutions.Select(entry => entry.Action).OrderBy(key => key, StringComparer.Ordinal));
|
||||
Assert.All(combinedExecutions, entry => Assert.Equal(combinedTransition.ReceivedAt, entry.TriggeredAt));
|
||||
var combinedResults = executionLog.Completed.Skip(6).ToArray();
|
||||
Assert.Equal(6, combinedResults.Length);
|
||||
Assert.Equal(3, combinedResults.Count(result => result == "readback-confirmed"));
|
||||
Assert.Equal("fragrance-release-sent", await executionLog.WaitForResult(fragrance.Key));
|
||||
Assert.Equal("fragrance-release-sent", await executionLog.WaitForResult(wakeFragrance.Key));
|
||||
Assert.Equal("speaker-playback-sent", await executionLog.WaitForResult(speaker.Key));
|
||||
|
||||
var combinedCommands = gateway.Calls.Skip(4).ToArray();
|
||||
Assert.Equal(new[] { "close_cover", "press", "press", "set_hvac_mode", "set_temperature", "turn_on" },
|
||||
combinedCommands.Select(command => command.Service).OrderBy(name => name, StringComparer.Ordinal));
|
||||
Assert.Equal(new[] { FragranceButton, WakeFragranceButton }, combinedCommands.Where(command => command.Service == "press")
|
||||
.Select(command => command.EntityId).OrderBy(id => id, StringComparer.Ordinal));
|
||||
Assert.Equal(TrackUrl, Assert.Single(audio.Tracks));
|
||||
Assert.Equal(50, Assert.Single(audio.Volumes));
|
||||
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" },
|
||||
combinedCommands.Where(command => command.EntityId == TargetClimate).Select(command => command.Service));
|
||||
Assert.Equal("closed", gateway.States[TargetCurtain].State);
|
||||
Assert.Equal("on", gateway.States[TargetDehumidifier].State);
|
||||
Assert.Equal("heat", gateway.States[TargetClimate].State);
|
||||
Assert.Equal(22, gateway.States[TargetClimate].GetDoubleAttribute("temperature"));
|
||||
|
||||
Assert.Equal("speaker-not-idle", await speaker.ExecuteAsync(Speaker, default));
|
||||
Assert.Single(audio.Tracks);
|
||||
gateway.States[WakeFragranceButton] = new() { EntityId = WakeFragranceButton, State = "unavailable" };
|
||||
Assert.Equal("fragrance-release-unavailable", await wakeFragrance.ExecuteAsync(WakeFragranceButton, default));
|
||||
Assert.Equal("target-not-configured", await fragrance.ExecuteAsync(WakeFragranceButton, default));
|
||||
Assert.Equal(10, gateway.Calls.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await service.StopAsync(timeout.Token);
|
||||
await automation.StopAsync(timeout.Token);
|
||||
await music.StopAsync(timeout.Token);
|
||||
await broker.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ObservedPresenceSink(IBedPresenceSink inner) : IBedPresenceSink
|
||||
{
|
||||
private BedPresenceSample? _lastLeft;
|
||||
public BedPresenceSample? LastLeft => Volatile.Read(ref _lastLeft);
|
||||
|
||||
public void Observe(BedPresenceSample sample, CancellationToken connectionToken)
|
||||
{
|
||||
inner.Observe(sample, connectionToken);
|
||||
if (sample.Side == BedSide.Left) Volatile.Write(ref _lastLeft, sample);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingExecutionLog : IBedPresenceExecutionLog
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _results = new();
|
||||
public ConcurrentQueue<(string Action, DateTimeOffset TriggeredAt)> Started { get; } = new();
|
||||
public ConcurrentQueue<string> Completed { get; } = new();
|
||||
public Task<string> WaitForResult(string action) => Result(action).Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
|
||||
private TaskCompletionSource<string> Result(string action) =>
|
||||
_results.GetOrAdd(action, _ => new(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
public Task<string?> BeginAsync(string bindingId, long userId, string action, string target, DateTimeOffset triggeredAt, CancellationToken token)
|
||||
{
|
||||
Assert.Equal(LeftBinding, bindingId);
|
||||
Assert.Equal(1L, userId);
|
||||
Started.Enqueue((action, triggeredAt));
|
||||
return Task.FromResult<string?>(action);
|
||||
}
|
||||
public Task CompleteAsync(string id, string result, DateTimeOffset completedAt)
|
||||
{
|
||||
Completed.Enqueue(result);
|
||||
Result(id).TrySetResult(result);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DatabaseSink : IIb43ReportSink, IDisposable
|
||||
{
|
||||
private readonly SqlSugarClient _db;
|
||||
|
||||
Reference in New Issue
Block a user