新通讯图像协议对接成功
This commit is contained in:
177
SHH.CameraService/GrpcImpls/Handlers/DeviceConfigHandler.cs
Normal file
177
SHH.CameraService/GrpcImpls/Handlers/DeviceConfigHandler.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// 同步设备配置处理器
|
||||
/// </summary>
|
||||
public class DeviceConfigHandler : ICommandHandler
|
||||
{
|
||||
private readonly CameraManager _cameraManager;
|
||||
|
||||
/// <summary>
|
||||
/// 命令名称
|
||||
/// </summary>
|
||||
public string ActionName => ProtocolHeaders.Sync_Camera;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="cameraManager"></param>
|
||||
public DeviceConfigHandler(CameraManager cameraManager)
|
||||
{
|
||||
_cameraManager = cameraManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行处理
|
||||
/// </summary>
|
||||
/// <param name="payload"></param>
|
||||
/// <returns></returns>
|
||||
public async Task ExecuteAsync(JToken payload)
|
||||
{
|
||||
// 1. 反序列化配置 DTO
|
||||
var dto = payload.ToObject<CameraConfigDto>();
|
||||
if (dto == null) return;
|
||||
|
||||
// 2. 尝试获取现有设备
|
||||
var device = _cameraManager.GetDevice(dto.Id);
|
||||
|
||||
if (device != null)
|
||||
{
|
||||
// =========================================================
|
||||
// 场景 A: 设备已存在 -> 执行智能更新 (Smart Update)
|
||||
// =========================================================
|
||||
Console.WriteLine($"[Sync] 更新设备配置: {dto.Id} ({dto.Name})");
|
||||
|
||||
// 将全量配置映射为部分更新 DTO
|
||||
var updateDto = new DeviceUpdateDto
|
||||
{
|
||||
// --- 冷更新参数 (变更会触发重启) ---
|
||||
IpAddress = dto.IpAddress,
|
||||
Port = dto.Port,
|
||||
Username = dto.Username,
|
||||
Password = dto.Password,
|
||||
ChannelIndex = dto.ChannelIndex,
|
||||
Brand = dto.Brand,
|
||||
RtspPath = dto.RtspPath,
|
||||
RenderHandle = dto.RenderHandle, // long 类型直接赋值
|
||||
|
||||
// --- 热更新参数 (变更立即生效) ---
|
||||
Name = dto.Name,
|
||||
Location = dto.Location,
|
||||
StreamType = dto.StreamType,
|
||||
|
||||
MainboardIp = dto.MainboardIp,
|
||||
MainboardPort = dto.MainboardPort,
|
||||
|
||||
// --- 图像处理参数 (热更新) ---
|
||||
AllowCompress = dto.AllowCompress,
|
||||
AllowExpand = dto.AllowExpand,
|
||||
TargetResolution = dto.TargetResolution,
|
||||
EnhanceImage = dto.EnhanceImage,
|
||||
UseGrayscale = dto.UseGrayscale
|
||||
};
|
||||
|
||||
// 调用 Manager 的核心更新逻辑 (它会自动判断是 Stop->Start 还是直接应用)
|
||||
await _cameraManager.UpdateDeviceConfigAsync(dto.Id, updateDto);
|
||||
}
|
||||
else
|
||||
{
|
||||
// =========================================================
|
||||
// 场景 B: 设备不存在 -> 执行新增 (Add New)
|
||||
// =========================================================
|
||||
Console.WriteLine($"[Sync] 新增设备: {dto.Id} ({dto.Name})");
|
||||
|
||||
// 构造全新的设备配置
|
||||
var newConfig = new VideoSourceConfig
|
||||
{
|
||||
Id = dto.Id,
|
||||
Name = dto.Name,
|
||||
Brand = (DeviceBrand)dto.Brand, // int -> Enum 强转
|
||||
IpAddress = dto.IpAddress,
|
||||
Port = dto.Port,
|
||||
Username = dto.Username,
|
||||
Password = dto.Password,
|
||||
ChannelIndex = dto.ChannelIndex,
|
||||
StreamType = dto.StreamType,
|
||||
RtspPath = dto.RtspPath,
|
||||
MainboardIp = dto.MainboardIp,
|
||||
MainboardPort = dto.MainboardPort,
|
||||
RenderHandle = (IntPtr)dto.RenderHandle, // long -> IntPtr 转换
|
||||
ConnectionTimeoutMs = 5000 // 默认超时
|
||||
};
|
||||
|
||||
// 添加到管理器池
|
||||
_cameraManager.AddDevice(newConfig);
|
||||
|
||||
// 重新获取引用以进行后续操作
|
||||
device = _cameraManager.GetDevice(dto.Id);
|
||||
|
||||
}
|
||||
|
||||
// ★★★ 核心修复:统一处理“运行意图” ★★★
|
||||
if (device != null)
|
||||
{
|
||||
// 将 DTO 的立即执行标志直接同步给设备的运行意图
|
||||
device.IsRunning = dto.ImmediateExecution;
|
||||
|
||||
if (dto.ImmediateExecution)
|
||||
{
|
||||
// 情况 1: 收到“启动”指令
|
||||
if (!device.IsOnline) // 只有没在线时才点火
|
||||
{
|
||||
Console.WriteLine($"[Sync] 指令:立即启动设备 {dto.Id}");
|
||||
_ = device.StartAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 情况 2: 收到“停止”指令 (即 ImmediateExecution = false)
|
||||
if (device.IsOnline) // 只有在线时才熄火
|
||||
{
|
||||
Console.WriteLine($"[Sync] 指令:立即停止设备 {dto.Id}");
|
||||
_ = device.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 3. 处理自动订阅策略 (Auto Subscriptions)
|
||||
// =========================================================
|
||||
// 无论新增还是更新,都确保订阅策略是最新的
|
||||
if (device != null && dto.AutoSubscriptions != null)
|
||||
{
|
||||
var controller = device.Controller;
|
||||
if (controller != null)
|
||||
{
|
||||
foreach (var sub in dto.AutoSubscriptions)
|
||||
{
|
||||
// 如果没有 AppId,生成一个临时的(通常 Dashboard 会下发固定的 AppId)
|
||||
string appId = string.IsNullOrWhiteSpace(sub.AppId)
|
||||
? $"AUTO_{Guid.NewGuid().ToString("N")[..8]}"
|
||||
: sub.AppId;
|
||||
|
||||
// 构造流控需求
|
||||
var req = new FrameRequirement
|
||||
{
|
||||
AppId = appId,
|
||||
TargetFps = sub.TargetFps,
|
||||
Type = (SubscriptionType)sub.Type, // int -> Enum
|
||||
Memo = sub.Memo ?? "Sync Auto",
|
||||
|
||||
// 自动订阅通常不包含具体的 Handle 或 SavePath,除非协议里带了
|
||||
// 如果需要支持网络转发,这里可以扩展映射 sub.TargetIp 等
|
||||
Handle = "",
|
||||
SavePath = ""
|
||||
};
|
||||
|
||||
// 注册到帧控制器
|
||||
controller.Register(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
169
SHH.CameraService/GrpcImpls/Handlers/DeviceStatusHandler.cs
Normal file
169
SHH.CameraService/GrpcImpls/Handlers/DeviceStatusHandler.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts;
|
||||
using SHH.Contracts.Grpc;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// 设备状态监控工作者 (gRPC 版)
|
||||
/// 职责:监控相机状态并在状态变更或心跳周期内,通过 gRPC 批量上报至所有配置的端点
|
||||
/// </summary>
|
||||
public class DeviceStatusHandler : BackgroundService
|
||||
{
|
||||
private readonly CameraManager _manager;
|
||||
private readonly ServiceConfig _config;
|
||||
private readonly ILogger<DeviceStatusHandler> _logger;
|
||||
|
||||
// 状态存储:CameraId -> 状态载荷
|
||||
private readonly ConcurrentDictionary<string, StatusEventPayload> _stateStore = new();
|
||||
|
||||
private volatile bool _isDirty = false;
|
||||
private long _lastSendTick = 0;
|
||||
|
||||
public DeviceStatusHandler(
|
||||
CameraManager manager,
|
||||
ServiceConfig config,
|
||||
ILogger<DeviceStatusHandler> logger)
|
||||
{
|
||||
_manager = manager;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// 1. 初始化本地状态缓存
|
||||
foreach (var dev in _manager.GetAllDevices())
|
||||
{
|
||||
UpdateLocalState(dev.Id, false, "Service Init");
|
||||
}
|
||||
|
||||
// 2. 订阅 SDK 状态变更事件
|
||||
_manager.OnDeviceStatusChanged += OnSdkStatusChanged;
|
||||
|
||||
_logger.LogInformation("[StatusWorker] gRPC 状态上报已启动,配置节点数: {Count}", _config.CommandEndpoints.Count);
|
||||
|
||||
// 3. 定时循环 (1秒1次检查)
|
||||
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await CheckAndBroadcastAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* 正常退出 */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[StatusWorker] 运行异常");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_manager.OnDeviceStatusChanged -= OnSdkStatusChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK 状态变更回调
|
||||
/// </summary>
|
||||
private void OnSdkStatusChanged(long deviceId, bool isOnline, string reason)
|
||||
{
|
||||
UpdateLocalState(deviceId, isOnline, reason);
|
||||
_isDirty = true;
|
||||
}
|
||||
|
||||
private void UpdateLocalState(long deviceId, bool isOnline, string reason)
|
||||
{
|
||||
var evt = new StatusEventPayload
|
||||
{
|
||||
CameraId = deviceId.ToString(),
|
||||
IsOnline = isOnline,
|
||||
Reason = reason,
|
||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
};
|
||||
_stateStore[deviceId.ToString()] = evt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行广播逻辑
|
||||
/// </summary>
|
||||
private async Task CheckAndBroadcastAsync(CancellationToken ct)
|
||||
{
|
||||
long now = Environment.TickCount64;
|
||||
|
||||
// 策略: 有变更(Dirty) 或 超过5秒(强制心跳)
|
||||
bool shouldSend = _isDirty || (now - _lastSendTick > 5000);
|
||||
|
||||
if (shouldSend && _config.CommandEndpoints.Any())
|
||||
{
|
||||
// 1. 构建 gRPC 请求包
|
||||
var request = new StatusBatchRequest
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
};
|
||||
|
||||
// 转换内存中的状态快照为 Protobuf 列表
|
||||
foreach (var item in _stateStore.Values)
|
||||
{
|
||||
request.Items.Add(new StatusEventItem
|
||||
{
|
||||
CameraId = item.CameraId,
|
||||
IsOnline = item.IsOnline,
|
||||
Reason = item.Reason,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 遍历所有端点进行发送
|
||||
foreach (var endpoint in _config.CommandEndpoints)
|
||||
{
|
||||
try
|
||||
{
|
||||
string grpcUrl = endpoint.Uri.Replace("tcp://", "http://").Trim();
|
||||
|
||||
// --- 增加以下诊断代码 ---
|
||||
using var channel = GrpcChannel.ForAddress(grpcUrl);
|
||||
var client = new GatewayProvider.GatewayProviderClient(channel);
|
||||
|
||||
// 获取 gRPC 内部生成的服务全称
|
||||
// 这就是客户端尝试调用的真实路径:/包名.服务名/方法名
|
||||
var methodName = "ReportStatusBatch";
|
||||
var serviceName = client.GetType().DeclaringType?.Name ?? "Unknown";
|
||||
|
||||
_logger.LogInformation("[gRPC Debug] 准备调用端点: {Url}", grpcUrl);
|
||||
_logger.LogInformation("[gRPC Debug] 客户端契约服务名: {Service}", serviceName);
|
||||
|
||||
// 执行调用
|
||||
var response = await client.ReportStatusBatchAsync(request,
|
||||
deadline: DateTime.UtcNow.AddSeconds(2), cancellationToken: ct);
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
_logger.LogInformation("[gRPC Success] 上报成功");
|
||||
_isDirty = false;
|
||||
_lastSendTick = Environment.TickCount64;
|
||||
}
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
// 这里是关键:打印 RpcException 的详细状态
|
||||
_logger.LogError("[gRPC Error] StatusCode: {Code}, Detail: {Detail}", ex.StatusCode, ex.Status.Detail);
|
||||
|
||||
// 如果是 Unimplemented,通常意味着路径不对
|
||||
if (ex.StatusCode == StatusCode.Unimplemented)
|
||||
{
|
||||
_logger.LogError("[gRPC Fix] 请检查服务端是否注册了名为 'GatewayProvider' 的服务,且其 package 声明与客户端一致。");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[gRPC Fatal] 非 RPC 异常: {Msg}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
SHH.CameraService/GrpcImpls/Handlers/GatewayService.cs
Normal file
102
SHH.CameraService/GrpcImpls/Handlers/GatewayService.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts.Grpc; // 引用 Proto 生成的命名空间
|
||||
|
||||
namespace SHH.CameraService
|
||||
{
|
||||
/// <summary>
|
||||
/// gRPC 指令接收后台服务
|
||||
/// 职责:
|
||||
/// 1. 维护与 AiVideo 的 gRPC 长连接。
|
||||
/// 2. 完成节点逻辑注册。
|
||||
/// 3. 监听 Server Streaming 指令流并移交给 Dispatcher。
|
||||
/// </summary>
|
||||
public class GatewayService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<GatewayService> _logger;
|
||||
private readonly ServiceConfig _config;
|
||||
private readonly CommandDispatcher _dispatcher;
|
||||
|
||||
public GatewayService(
|
||||
ILogger<GatewayService> logger,
|
||||
ServiceConfig config,
|
||||
CommandDispatcher dispatcher)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// 预留系统启动缓冲时间,确保数据库和 SDK 已就绪
|
||||
_logger.LogInformation("[gRPC Bus] 指令接收服务启动,等待环境预热...");
|
||||
await Task.Delay(3000, stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 地址适配:将 tcp 转换为 http,并将 127.0.0.1 修正为 localhost 解决 Unimplemented 异常
|
||||
var ep = _config.CommandEndpoints.First();
|
||||
string targetUrl = ep.Uri.Replace("tcp://", "http://").Replace("127.0.0.1", "localhost");
|
||||
|
||||
using var channel = GrpcChannel.ForAddress(targetUrl);
|
||||
var client = new GatewayProvider.GatewayProviderClient(channel);
|
||||
|
||||
// --- 第一步:发起节点逻辑注册 (Unary) ---
|
||||
_logger.LogInformation("[gRPC Bus] 正在发起逻辑注册: {Url}", targetUrl);
|
||||
var regResp = await client.RegisterInstanceAsync(new RegisterRequest
|
||||
{
|
||||
InstanceId = _config.AppId,
|
||||
Version = "2.0.0-grpc",
|
||||
ServerIp = "127.0.0.1",
|
||||
StartTimeTicks = DateTime.Now.Ticks
|
||||
}, cancellationToken: stoppingToken);
|
||||
|
||||
if (regResp.Success)
|
||||
{
|
||||
_logger.LogInformation("[gRPC Bus] 注册成功。正在建立双向指令通道...");
|
||||
|
||||
// --- 第二步:开启 Server Streaming 指令流 ---
|
||||
using var call = client.OpenCommandChannel(new CommandStreamRequest
|
||||
{
|
||||
InstanceId = _config.AppId
|
||||
}, cancellationToken: stoppingToken);
|
||||
|
||||
// --- 第三步:循环读取服务端推送的指令 ---
|
||||
// 只要服务端流未断开,此处会一直阻塞等待新消息
|
||||
while (await call.ResponseStream.MoveNext(stoppingToken))
|
||||
{
|
||||
var protoMsg = call.ResponseStream.Current;
|
||||
|
||||
// 核心变更:不再直接处理业务,而是通过分发器进行路由
|
||||
// 使用 _ = 异步处理,避免某个 Handler 执行过慢导致指令流阻塞
|
||||
_ = _dispatcher.DispatchAsync(protoMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 响应系统正常退出信号
|
||||
break;
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
_logger.LogError("[gRPC Bus] RPC 异常 (Status: {Code}): {Msg}", ex.StatusCode, ex.Message);
|
||||
// 链路异常,进入重连等待阶段
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[gRPC Bus] 非预期链路异常: {Msg},5秒后尝试重连", ex.Message);
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
SHH.CameraService/GrpcImpls/Handlers/ICommandHandler.cs
Normal file
18
SHH.CameraService/GrpcImpls/Handlers/ICommandHandler.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// 抽象指令处理器接口
|
||||
/// </summary>
|
||||
public interface ICommandHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// 该处理器支持的 Action 名称 (如 "AddCamera", "Reboot")
|
||||
/// </summary>
|
||||
string ActionName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 执行指令逻辑
|
||||
/// </summary>
|
||||
/// <param name="payload">指令携带的数据 (JSON JToken)</param>
|
||||
Task ExecuteAsync(Newtonsoft.Json.Linq.JToken payload);
|
||||
}
|
||||
84
SHH.CameraService/GrpcImpls/Handlers/RemoveCameraHandler.cs
Normal file
84
SHH.CameraService/GrpcImpls/Handlers/RemoveCameraHandler.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.CameraService
|
||||
{
|
||||
/// <summary>
|
||||
/// 移除设备指令处理器
|
||||
/// </summary>
|
||||
public class RemoveCameraHandler : ICommandHandler
|
||||
{
|
||||
private readonly CameraManager _cameraManager;
|
||||
|
||||
/// <summary>
|
||||
/// 指令名称
|
||||
/// </summary>
|
||||
public string ActionName => ProtocolHeaders.Remove_Camera;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="cameraManager"></param>
|
||||
public RemoveCameraHandler(CameraManager cameraManager)
|
||||
{
|
||||
_cameraManager = cameraManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理指令
|
||||
/// </summary>
|
||||
/// <param name="payload"></param>
|
||||
public async Task ExecuteAsync(JToken payload)
|
||||
{
|
||||
long deviceId = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 增强型 ID 解析
|
||||
if (payload.Type == JTokenType.Object)
|
||||
{
|
||||
// 兼容大小写不敏感的解析
|
||||
var idToken = payload["Id"] ?? payload["id"];
|
||||
if (idToken != null) deviceId = idToken.Value<long>();
|
||||
}
|
||||
else if (payload.Type == JTokenType.Integer || payload.Type == JTokenType.String)
|
||||
{
|
||||
// 兼容字符串形式的 ID
|
||||
long.TryParse(payload.ToString(), out deviceId);
|
||||
}
|
||||
|
||||
if (deviceId <= 0)
|
||||
{
|
||||
Console.WriteLine($"[{ActionName}] 收到无效指令: ID解析失败 ({payload})");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 预检查
|
||||
var device = _cameraManager.GetDevice(deviceId);
|
||||
if (device == null)
|
||||
{
|
||||
Console.WriteLine($"[{ActionName}] 设备 {deviceId} 已经不在管理池中,无需操作。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 安全移除
|
||||
// 这里建议增加审计日志,记录谁触发了删除(如果协议里有用户信息的话)
|
||||
device.AddAuditLog("收到远程指令:彻底移除设备");
|
||||
Console.WriteLine($"[{ActionName}] 正在安全移除设备: {deviceId} ({device.Config.Name})");
|
||||
|
||||
// CameraManager 内部会:StopAsync -> DisposeAsync -> TryRemove -> SaveChanges
|
||||
await _cameraManager.RemoveDeviceAsync(deviceId);
|
||||
|
||||
Console.WriteLine($"[{ActionName}] 设备 {deviceId} 已彻底清理并从持久化库中移除。");
|
||||
|
||||
// 4. (可选) 此处可以调用 CommandDispatcher 发送 Success ACK
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获异常,防止影响全局 Socket 轮询
|
||||
Console.WriteLine($"[{ActionName}] 移除设备 {deviceId} 过程中发生致命错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user