Compare commits
2 Commits
11faabf5f9
...
d6cc5d36b4
| Author | SHA1 | Date |
|---|---|---|
|
|
d6cc5d36b4 | |
|
|
7e5d20a64a |
|
|
@ -0,0 +1,90 @@
|
|||
using Hangfire;
|
||||
using Hangfire.Server;
|
||||
using Mapster;
|
||||
using Masuit.Tools;
|
||||
using SqlSugar;
|
||||
using WGShare.API.Helpers;
|
||||
using WGShare.Domain.Constant;
|
||||
using WGShare.Domain.DTOs.AgoraCallback;
|
||||
using WGShare.Domain.Entities;
|
||||
|
||||
namespace WGShare.API.BackgroudServices
|
||||
{
|
||||
public class AgoraCallbackComsuerService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<AgoraCallbackComsuerService> _logger;
|
||||
private readonly ISqlSugarClient _sugarClient;
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
|
||||
public AgoraCallbackComsuerService(ILogger<AgoraCallbackComsuerService> logger, ISqlSugarClient sugarClient)
|
||||
{
|
||||
_logger = logger;
|
||||
this._sugarClient = sugarClient;
|
||||
_semaphore = new SemaphoreSlim(1);// 允许最多1个线程同时访问
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("参会记录监听服务已启动");
|
||||
|
||||
stoppingToken.Register(() => _logger.LogInformation("RedisSubscriberService is stopping."));
|
||||
|
||||
// 离开频道消息订阅
|
||||
using (RedisHelper.Instance.SubscribeList(RedisKeyConstant.PubSub.MeetingRecord, async (message) =>
|
||||
{
|
||||
_logger.LogDebug($"接受消息: {message}");
|
||||
if (message == null) return;
|
||||
|
||||
|
||||
var body = message.ToString().FromJson<EventBody>();
|
||||
if (body == null)
|
||||
{
|
||||
_logger.LogError("消息体为空");
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.payload.uid.ToString().Length == 9)
|
||||
{
|
||||
_logger.LogInformation("共享屏幕不记录参会记录");
|
||||
return;
|
||||
}
|
||||
|
||||
await MeetingRecord(body);
|
||||
|
||||
}))
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(10000, stoppingToken); // 等待一段时间,然后继续循环
|
||||
}
|
||||
|
||||
_logger.LogInformation("参会记录监听服务已停止");
|
||||
}
|
||||
|
||||
public async Task MeetingRecord(EventBody body)
|
||||
{
|
||||
await _semaphore.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
var entity = body.payload.Adapt<MeetingRecord>();
|
||||
entity.EventType = body.eventType;
|
||||
|
||||
await _sugarClient.Storageable(entity)
|
||||
.WhereColumns(x => new { x.uid, x.clientSeq })
|
||||
.ToStorage()
|
||||
.AsInsertable
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
using Hangfire;
|
||||
using Hangfire.Server;
|
||||
using Masuit.Tools;
|
||||
using SqlSugar;
|
||||
using WGShare.API.Helpers;
|
||||
using WGShare.Domain.Entities;
|
||||
|
||||
namespace WGShare.API.BackgroudServices
|
||||
{
|
||||
public class OssCleanWorker : BackgroundService
|
||||
{
|
||||
private readonly ILogger<OssCleanWorker> _logger;
|
||||
private readonly ISqlSugarClient _sugarClient;
|
||||
private readonly OssHelper _ossHelper;
|
||||
|
||||
public OssCleanWorker(ILogger<OssCleanWorker> logger, ISqlSugarClient sugarClient, OssHelper ossHelper)
|
||||
{
|
||||
_logger = logger;
|
||||
this._sugarClient = sugarClient;
|
||||
this._ossHelper = ossHelper;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
RecurringJob.AddOrUpdate("DeleteOssFile", () => DeleteOssFile(), Cron.Daily());
|
||||
}
|
||||
|
||||
public async Task DeleteOssFile()
|
||||
{
|
||||
await Console.Out.WriteLineAsync($@"开始清除过期文件,当前时间:{DateTime.Now}");
|
||||
|
||||
// 查找已删除,未文件清理,并过期180天的文件
|
||||
var deleteFiles = await _sugarClient.Queryable<ShareFile>()
|
||||
.Where(x => x.IsDelete == true && x.IsFileClean == false && SqlFunc.DateDiff(DateType.Day, x.ModifyTime, SqlFunc.GetDate()) > 180)
|
||||
.ToListAsync();
|
||||
if (deleteFiles.IsNullOrEmpty())
|
||||
{
|
||||
await Console.Out.WriteLineAsync($@"当前无可清除文件,退出本次操作,当前时间:{DateTime.Now}");
|
||||
return;
|
||||
}
|
||||
|
||||
await Console.Out.WriteLineAsync($@"本次清除文件数量:{deleteFiles.Count}");
|
||||
await Console.Out.WriteLineAsync($@"本次清除文件路径:{string.Join(',', deleteFiles.Select(x => x.FileUrl))}");
|
||||
|
||||
if (_ossHelper.DeleteObjects(deleteFiles.Select(x => x.FileUrl).ToArray()))
|
||||
{
|
||||
await _sugarClient.Updateable<ShareFile>()
|
||||
.SetColumns(x => x.IsFileClean == true)
|
||||
.Where(x => deleteFiles.Select(a => a.Id).Contains(x.Id))
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
await Console.Out.WriteLineAsync($@"本次清除操作结束,当前时间:{DateTime.Now}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,18 @@
|
|||
using AgoraIO.Rtm;
|
||||
using Mapster;
|
||||
using Masuit.Tools;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using SharpCompress;
|
||||
using SqlSugar;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http;
|
||||
using WGShare.API.Controllers.Basic;
|
||||
using WGShare.API.Helpers;
|
||||
using WGShare.Domain.AgoraApiResult;
|
||||
using WGShare.Domain.Constant;
|
||||
using WGShare.Domain.DTOs.AgoraCallback;
|
||||
using WGShare.Domain.DTOs.Room;
|
||||
using WGShare.Domain.Entities;
|
||||
using WGShare.Domain.FriendlyException;
|
||||
|
|
@ -21,21 +26,112 @@ namespace WGShare.API.Controllers.Frontend
|
|||
/// Agora接口
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "frontend")]
|
||||
[Route("agora-cb")]
|
||||
[Route("agora-cb"), AllowAnonymous]
|
||||
public class AgoraCallbackController : BasicController
|
||||
{
|
||||
private readonly ILogger<AgoraCallbackController> _logger;
|
||||
private readonly AgoraHelper _agoraHelper;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AgoraCallbackController(
|
||||
ILogger<AgoraCallbackController> logger)
|
||||
ILogger<AgoraCallbackController> logger,
|
||||
AgoraHelper agoraHelper,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
this._logger = logger;
|
||||
this._agoraHelper = agoraHelper;
|
||||
this._configuration = configuration;
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("event")]
|
||||
public async Task<string> Event()
|
||||
public async Task Event()
|
||||
{
|
||||
return string.Empty;
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var bodyString = await reader.ReadToEndAsync();
|
||||
|
||||
//_logger.LogInformation($"Agora回调内容:{bodyString}");
|
||||
|
||||
// 校验请求体是否为空
|
||||
if (string.IsNullOrWhiteSpace(bodyString))
|
||||
{
|
||||
await ExceptionNotice.SendAsync(new ArgumentNullException("Agora-Signature-V2 声网请求体为空"), "声网回调异常");
|
||||
return;
|
||||
}
|
||||
|
||||
var body = bodyString.FromJson<EventBody>();
|
||||
if (body == null)
|
||||
{
|
||||
await ExceptionNotice.SendAsync(new InvalidDataException("Agora-Signature-V2 声网请求体反序列化失败"), "声网回调异常");
|
||||
return;
|
||||
}
|
||||
|
||||
// 声网健康检查回调,直接返回
|
||||
if (body.payload.channelName == "test_webhook")
|
||||
{
|
||||
_logger.LogDebug($"测试回调,测试信息:{bodyString}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
var sig = Request.Headers["Agora-Signature-V2"].ToString();
|
||||
if (!await _agoraHelper.CheckSignatureAsync(sig, bodyString))
|
||||
{
|
||||
_logger.LogWarning($"Agora回调签名验证失败,声网签名:{sig}");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (body.eventType)
|
||||
{
|
||||
case Domain.Enums.EventType.channel_create:
|
||||
break;
|
||||
case Domain.Enums.EventType.channel_destroy:
|
||||
break;
|
||||
case Domain.Enums.EventType.broadcaster_join_channel:
|
||||
case Domain.Enums.EventType.audience_join_channel:
|
||||
JoinChannelEvent(bodyString);
|
||||
break;
|
||||
case Domain.Enums.EventType.broadcaster_leave_channel:
|
||||
case Domain.Enums.EventType.audience_leave_channel:
|
||||
LeaveChannelEvent(bodyString);
|
||||
break;
|
||||
case Domain.Enums.EventType.client_role_change_to_broadcaster:
|
||||
break;
|
||||
case Domain.Enums.EventType.client_role_change__to_audience:
|
||||
break;
|
||||
default:
|
||||
await ExceptionNotice.SendAsync(new Exception("声网事件未知类型"), "声网回调异常");
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加入频道
|
||||
/// </summary>
|
||||
[NonAction]
|
||||
private void JoinChannelEvent(string bodyString)
|
||||
{
|
||||
_logger.LogDebug($"Agora回调内容 加入频道:{bodyString}");
|
||||
|
||||
RedisHelper.Instance.LPush(RedisKeyConstant.PubSub.MeetingRecord, bodyString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 离开频道
|
||||
/// </summary>
|
||||
[NonAction]
|
||||
private void LeaveChannelEvent(string bodyString)
|
||||
{
|
||||
_logger.LogDebug($"Agora回调内容 离开频道:{bodyString}");
|
||||
|
||||
// 离会记录
|
||||
RedisHelper.Instance.LPush(RedisKeyConstant.PubSub.MeetingRecord, bodyString);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,20 @@ using AgoraIO.Rtm;
|
|||
using Mapster;
|
||||
using Masuit.Tools;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiniExcelLibs;
|
||||
using SqlSugar;
|
||||
using System.Net.Http;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using WGShare.API.Controllers.Basic;
|
||||
using WGShare.API.Helpers;
|
||||
using WGShare.Domain.AgoraApiResult;
|
||||
using WGShare.Domain.Constant;
|
||||
using WGShare.Domain.DTOs.Room;
|
||||
using WGShare.Domain.Entities;
|
||||
using WGShare.Domain.Enums;
|
||||
using WGShare.Domain.FriendlyException;
|
||||
using WGShare.Domain.GeneralModel;
|
||||
using Yitter.IdGenerator;
|
||||
using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
|
||||
|
||||
namespace WGShare.API.Controllers.Frontend
|
||||
{
|
||||
|
|
@ -29,19 +32,22 @@ namespace WGShare.API.Controllers.Frontend
|
|||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly AgoraHelper _agoraHelper;
|
||||
private readonly ILogger<HomeController> _logger;
|
||||
private readonly OssHelper _ossHelper;
|
||||
|
||||
public HomeController(
|
||||
ISqlSugarClient sqlSugar,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AgoraHelper agoraHelper,
|
||||
ILogger<HomeController> logger)
|
||||
ILogger<HomeController> logger,
|
||||
OssHelper ossHelper)
|
||||
{
|
||||
_sqlSugar = sqlSugar;
|
||||
this._configuration = configuration;
|
||||
this._httpClientFactory = httpClientFactory;
|
||||
this._agoraHelper = agoraHelper;
|
||||
this._logger = logger;
|
||||
this._ossHelper = ossHelper;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -101,6 +107,20 @@ namespace WGShare.API.Controllers.Frontend
|
|||
return await _sqlSugar.Insertable(entity).ExecuteCommandAsync() > 0;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 删除会议室
|
||||
/// </summary>
|
||||
/// <param name="inputDTO"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete("room")]
|
||||
public async Task<bool> DeleteRoom([FromQuery] string roomId)
|
||||
{
|
||||
return await _sqlSugar.Updateable<Room>()
|
||||
.SetColumns(x => x.IsDelete == true)
|
||||
.Where(x => x.Id == roomId).ExecuteCommandAsync() > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 rtm token
|
||||
/// </summary>
|
||||
|
|
@ -124,5 +144,104 @@ namespace WGShare.API.Controllers.Frontend
|
|||
{
|
||||
return _configuration["Agora:appId"].ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取会议记录
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("record")]
|
||||
public async Task<string> GetMeetingRecord([FromQuery] long beginTimestamp, [FromQuery] long endTimestamp, [FromQuery] string roomNum)
|
||||
{
|
||||
var room = await _sqlSugar.Queryable<Room>().Where(x => x.RoomNum == roomNum && x.IsDelete == false).FirstAsync();
|
||||
if (room == null)
|
||||
{
|
||||
throw Oops.Oh("该会议室不存在!");
|
||||
}
|
||||
|
||||
var recordList = await _sqlSugar.Queryable<MeetingRecord>()
|
||||
.InnerJoin<User>((mr, u) => mr.uid == u.Id)
|
||||
.InnerJoin<Role>((mr, u, r) => u.RoleId == r.Id)
|
||||
.Where((mr, u, r) => mr.channelName == roomNum && mr.ts >= beginTimestamp && mr.ts <= endTimestamp)
|
||||
.Select((mr, u, r) => new MeetingRecord
|
||||
{
|
||||
Id = mr.Id,
|
||||
uid = u.Id,
|
||||
ts = mr.ts,
|
||||
account = mr.account,
|
||||
channelName = mr.channelName,
|
||||
clientSeq = mr.clientSeq,
|
||||
CreateTime = mr.CreateTime,
|
||||
duration = mr.duration,
|
||||
EventType = mr.EventType,
|
||||
platform = mr.platform,
|
||||
reason = mr.reason,
|
||||
userName = u.UserName,
|
||||
UserAccount = u.Account,
|
||||
RoleName = r.RoleName,
|
||||
}).ToListAsync();
|
||||
|
||||
|
||||
if (recordList.IsNullOrEmpty())
|
||||
{
|
||||
throw Oops.Oh("该时间段内没有记录!");
|
||||
}
|
||||
|
||||
var groupByUser = recordList.GroupBy(x => x.uid).ToList();
|
||||
|
||||
var value = new RoomMettingRecordExportDTO
|
||||
{
|
||||
RoomName = room.RoomName,
|
||||
RoomNum = room.RoomNum,
|
||||
BeginTime = DateTimeUtils.FromJavaScriptTimestampToLocal(beginTimestamp).ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
EndTime = DateTimeUtils.FromJavaScriptTimestampToLocal(endTimestamp).ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
Users = new List<UserBehavior>()
|
||||
};
|
||||
|
||||
foreach (var userRecord in groupByUser)
|
||||
{
|
||||
if (userRecord.IsNullOrEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 按时间排序
|
||||
var orderedRecord = userRecord.OrderBy(x => x.ts).ToList();
|
||||
|
||||
// 获取第一次进入房间的事件记录
|
||||
var firstJoinTime = orderedRecord
|
||||
.FirstOrDefault(x => x.EventType == EventType.broadcaster_join_channel
|
||||
|| x.EventType == EventType.audience_join_channel);
|
||||
|
||||
// 获取最后一次离开房间的事件记录
|
||||
var lastLeaveTime = orderedRecord
|
||||
.LastOrDefault(x => x.EventType == EventType.broadcaster_leave_channel
|
||||
|| x.EventType == EventType.audience_leave_channel);
|
||||
|
||||
// 计算入会次数
|
||||
var joinCount = orderedRecord.Count(x => x.EventType == EventType.broadcaster_join_channel
|
||||
|| x.EventType == EventType.audience_join_channel);
|
||||
|
||||
// 累计参会时长
|
||||
var sumTime = orderedRecord.Sum(x => x.duration);
|
||||
|
||||
value.Users.Add(new UserBehavior
|
||||
{
|
||||
Account = userRecord.FirstOrDefault().UserAccount,
|
||||
FirstJoinTime = DateTimeUtils.FromJavaScriptTimestampToLocal(firstJoinTime.ts).ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
LastExitTime = DateTimeUtils.FromJavaScriptTimestampToLocal(lastLeaveTime.ts).ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
JoinCount = joinCount,
|
||||
Role = userRecord.FirstOrDefault().RoleName,
|
||||
UserName = userRecord.FirstOrDefault().userName,
|
||||
SumTime = sumTime,
|
||||
});
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
await MiniExcel.SaveAsByTemplateAsync(stream, $@"{AppDomain.CurrentDomain.BaseDirectory}meetingRecordTemplate.xlsx", value);
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
var fileName = $@"excel/{room.RoomName}-参会记录.xlsx";
|
||||
_ossHelper.UploadWithExpireTime(fileName, stream, 10);
|
||||
return _ossHelper.GetAccessFileUrl(fileName, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Masuit.Tools;
|
||||
using System.Drawing.Printing;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using WGShare.Domain.AgoraApiResult;
|
||||
using WGShare.Domain.FriendlyException;
|
||||
|
|
@ -127,6 +128,59 @@ namespace WGShare.API.Helpers
|
|||
|
||||
}
|
||||
|
||||
// 将加密后的字节数组转换成字符串
|
||||
private string BytesToHex(byte[] bytes)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
foreach (byte b in bytes)
|
||||
{
|
||||
string hex = b.ToString("x2");
|
||||
sb.Append(hex);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// HMAC/SHA256 加密,返回加密后的字符串
|
||||
private string HmacSha256(string message, string secret)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
|
||||
|
||||
using (HMACSHA256 hmacsha256 = new HMACSHA256(keyBytes))
|
||||
{
|
||||
byte[] hashBytes = hmacsha256.ComputeHash(messageBytes);
|
||||
return BytesToHex(hashBytes);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception("Error in HmacSha256", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证声网回调签名
|
||||
/// </summary>
|
||||
/// <param name="agoraSig"></param>
|
||||
/// <param name="bodyString"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> CheckSignatureAsync(string agoraSig, string bodyString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agoraSig))
|
||||
{
|
||||
await ExceptionNotice.SendAsync(new ArgumentNullException("Agora-Signature-V2 声网密钥为空"), "声网回调异常");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HmacSha256(bodyString, _configuration["Agora:eventSecret"].ToString()) != agoraSig)
|
||||
{
|
||||
await ExceptionNotice.SendAsync(new ArgumentNullException("Agora-Signature-V2 声网密钥验证失败"), "声网回调异常");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
namespace WGShare.API.Helpers
|
||||
{
|
||||
public class DateTimeUtils
|
||||
{
|
||||
|
||||
// Unix 纪元时间
|
||||
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// 将 JavaScript 时间戳(以秒为单位)转换为 DateTime 对象(UTC 时间)。
|
||||
/// </summary>
|
||||
/// <param name="jsTimestamp">JavaScript 时间戳(以秒为单位)。</param>
|
||||
/// <returns>对应的 DateTime 对象(UTC 时间)。</returns>
|
||||
public static DateTime FromJavaScriptTimestamp(long jsTimestamp)
|
||||
{
|
||||
return Epoch.AddSeconds(jsTimestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 DateTime 对象(UTC 时间)转换为 JavaScript 时间戳(以秒为单位)。
|
||||
/// </summary>
|
||||
/// <param name="dateTime">DateTime 对象(UTC 时间)。</param>
|
||||
/// <returns>对应的 JavaScript 时间戳(以秒为单位)。</returns>
|
||||
public static long ToJavaScriptTimestamp(DateTime dateTime)
|
||||
{
|
||||
return (long)(dateTime.ToUniversalTime() - Epoch).Seconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 JavaScript 时间戳(以秒为单位)转换为本地时间的 DateTime 对象。
|
||||
/// </summary>
|
||||
/// <param name="jsTimestamp">JavaScript 时间戳(以秒为单位)。</param>
|
||||
/// <returns>对应的本地时间的 DateTime 对象。</returns>
|
||||
public static DateTime FromJavaScriptTimestampToLocal(long jsTimestamp)
|
||||
{
|
||||
DateTime utcDateTime = FromJavaScriptTimestamp(jsTimestamp);
|
||||
return utcDateTime.ToLocalTime();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,73 +46,5 @@ namespace WGShare.API.Helpers
|
|||
/// <returns></returns>
|
||||
public static int RandomExpired(int minTimeoutSeconds, int maxTimeoutSeconds) => rnd.Value.Next(minTimeoutSeconds, maxTimeoutSeconds);
|
||||
|
||||
//public static List<T> HVals<T>(string key) where T : class
|
||||
//{
|
||||
// var valueStrings = Instance.HVals(key);
|
||||
// if (valueStrings.IsNullOrEmpty())
|
||||
// {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// return valueStrings.ToList().ConvertAll(x => JsonConvert.DeserializeObject<T>(x));
|
||||
//}
|
||||
|
||||
//public static T HGet<T>(string key, string field) where T : class
|
||||
//{
|
||||
// var valueString = Instance.HGet(key, field);
|
||||
// if (valueString.IsNullOrEmpty())
|
||||
// {
|
||||
// return null;
|
||||
// }
|
||||
// return JsonConvert.DeserializeObject<T>(valueString);
|
||||
//}
|
||||
|
||||
//public static List<T> HMGet<T>(string key, params string[] fields) where T : class
|
||||
//{
|
||||
// var valueStrings = Instance.HMGet(key, fields);
|
||||
// if (valueStrings.IsNullOrEmpty())
|
||||
// {
|
||||
// return null;
|
||||
// }
|
||||
// return valueStrings.ToList().ConvertAll(x => JsonConvert.DeserializeObject<T>(x));
|
||||
//}
|
||||
|
||||
//public static Dictionary<string, T> HGetAll<T>(string key) where T : class
|
||||
//{
|
||||
// var dic = Instance.HGetAll(key);
|
||||
// if (dic == null || dic.Count == 0)
|
||||
// {
|
||||
// return null;
|
||||
// }
|
||||
// Dictionary<string, T> result = new Dictionary<string, T>();
|
||||
|
||||
// foreach (var kv in dic)
|
||||
// {
|
||||
// if (string.IsNullOrWhiteSpace(kv.Key) || string.IsNullOrWhiteSpace(kv.Value))
|
||||
// {
|
||||
// continue;
|
||||
// }
|
||||
// result.Add(kv.Key, JsonConvert.DeserializeObject<T>(kv.Value));
|
||||
// }
|
||||
|
||||
// return result;
|
||||
//}
|
||||
|
||||
|
||||
//public static long HSet<T>(string key, string fields, T value) where T : class
|
||||
//{
|
||||
// return Instance.HSet(key, fields, value.ToJsonString());
|
||||
//}
|
||||
|
||||
|
||||
//public static void HMSet<T>(string key, Dictionary<string, T> keyValues) where T : class
|
||||
//{
|
||||
// var dic = new Dictionary<string, string>();
|
||||
// foreach (var kv in keyValues)
|
||||
// {
|
||||
// dic.Add(kv.Key,kv.Value.ToJsonString());
|
||||
// }
|
||||
// Instance.HMSet(key, dic);
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ namespace WGShare.API
|
|||
builder.Services.AddSingleton(new JwtHelper(configuration));
|
||||
builder.Services.AddSingleton(new AgoraHelper(configuration));
|
||||
builder.Services.AddSingleton(new OssHelper(configuration));
|
||||
builder.Services.AddHostedService<OssCleanWorker>();
|
||||
builder.Services.AddHostedService<AgoraCallbackComsuerService>();
|
||||
builder.Services.AddAuth(configuration["Jwt:Issuer"],
|
||||
configuration["Jwt:Audience"],
|
||||
configuration["Jwt:SecretKey"]);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="meetingRecordTemplate.xlsx">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Reference\AgoraIO.dll">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
|
|
|||
|
|
@ -66,6 +66,16 @@
|
|||
Agora接口
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Controllers.Frontend.AgoraCallbackController.JoinChannelEvent(System.String)">
|
||||
<summary>
|
||||
加入频道
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Controllers.Frontend.AgoraCallbackController.LeaveChannelEvent(System.String)">
|
||||
<summary>
|
||||
离开频道
|
||||
</summary>
|
||||
</member>
|
||||
<member name="T:WGShare.API.Controllers.Frontend.HomeController">
|
||||
<summary>
|
||||
首页接口
|
||||
|
|
@ -85,6 +95,13 @@
|
|||
<param name="inputDTO"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Controllers.Frontend.HomeController.DeleteRoom(System.String)">
|
||||
<summary>
|
||||
删除会议室
|
||||
</summary>
|
||||
<param name="inputDTO"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Controllers.Frontend.HomeController.GetRTMToken">
|
||||
<summary>
|
||||
获取 rtm token
|
||||
|
|
@ -97,6 +114,12 @@
|
|||
</summary>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Controllers.Frontend.HomeController.GetMeetingRecord(System.Int64,System.Int64,System.String)">
|
||||
<summary>
|
||||
获取会议记录
|
||||
</summary>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="T:WGShare.API.Controllers.Frontend.RoomController">
|
||||
<summary>
|
||||
会议室接口
|
||||
|
|
@ -335,6 +358,35 @@
|
|||
</summary>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Helpers.AgoraHelper.CheckSignatureAsync(System.String,System.String)">
|
||||
<summary>
|
||||
验证声网回调签名
|
||||
</summary>
|
||||
<param name="agoraSig"></param>
|
||||
<param name="bodyString"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Helpers.DateTimeUtils.FromJavaScriptTimestamp(System.Int64)">
|
||||
<summary>
|
||||
将 JavaScript 时间戳(以秒为单位)转换为 DateTime 对象(UTC 时间)。
|
||||
</summary>
|
||||
<param name="jsTimestamp">JavaScript 时间戳(以秒为单位)。</param>
|
||||
<returns>对应的 DateTime 对象(UTC 时间)。</returns>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Helpers.DateTimeUtils.ToJavaScriptTimestamp(System.DateTime)">
|
||||
<summary>
|
||||
将 DateTime 对象(UTC 时间)转换为 JavaScript 时间戳(以秒为单位)。
|
||||
</summary>
|
||||
<param name="dateTime">DateTime 对象(UTC 时间)。</param>
|
||||
<returns>对应的 JavaScript 时间戳(以秒为单位)。</returns>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Helpers.DateTimeUtils.FromJavaScriptTimestampToLocal(System.Int64)">
|
||||
<summary>
|
||||
将 JavaScript 时间戳(以秒为单位)转换为本地时间的 DateTime 对象。
|
||||
</summary>
|
||||
<param name="jsTimestamp">JavaScript 时间戳(以秒为单位)。</param>
|
||||
<returns>对应的本地时间的 DateTime 对象。</returns>
|
||||
</member>
|
||||
<member name="M:WGShare.API.Helpers.OssHelper.GetUploadUrl(System.String,System.String,System.UInt32)">
|
||||
<summary>
|
||||
获取上传url
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
},
|
||||
"Console": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning"
|
||||
"Default": "Debug",
|
||||
"Microsoft": "Warning",
|
||||
"Hangfire": "Information"
|
||||
}
|
||||
//"FormatterName": "CustomTimePrefixingFormatter",
|
||||
//"FormatterOptions": {
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
"tokenExpireTimeInSecond": 7200,
|
||||
"apiPrefix": "https://api.sd-rtn.com/",
|
||||
"clientId": "80cdc24f7dfa4497a37d98da95a3c4a4",
|
||||
"clientSecret": "8323581d4d464114b1f324b26cc62e09"
|
||||
"clientSecret": "8323581d4d464114b1f324b26cc62e09",
|
||||
"eventSecret": "gPPaFQS1U"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
},
|
||||
"Console": {
|
||||
"TimestampFormat": "[yyyy-MM-dd HH:mm:ss]"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
|
@ -33,6 +36,7 @@
|
|||
"AccessKeySecret": "FKFNYRdS53FwA5ME2wM1585qX5eVEd",
|
||||
"Endpoint": "oss-cn-chengdu.aliyuncs.com",
|
||||
"Host": "https://wgshare.oss-cn-chengdu.aliyuncs.com/",
|
||||
"BucketName": "wgshare"
|
||||
"BucketName": "wgshare",
|
||||
"eventSecret": ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -79,5 +79,12 @@ namespace WGShare.Domain.Constant
|
|||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 发布订阅
|
||||
/// </summary>
|
||||
public class PubSub
|
||||
{
|
||||
public static string MeetingRecord => "meeting_record";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -27,7 +28,7 @@ namespace WGShare.Domain.DTOs.AgoraCallback
|
|||
/// <summary>
|
||||
/// 声网消息服务器向你的服务器发送事件通知的 Unix 时间戳 (ms)。通知重试时该值会更新。
|
||||
/// </summary>
|
||||
public int notifyMs { get; set; }
|
||||
public long notifyMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 会话 ID。
|
||||
|
|
@ -37,7 +38,7 @@ namespace WGShare.Domain.DTOs.AgoraCallback
|
|||
/// <summary>
|
||||
/// 通知事件的具体内容。payload 因 eventType 而异,详见频道事件类型。
|
||||
/// </summary>
|
||||
public string payload { get; set; }
|
||||
public AgoraCallbackPayload payload { get; set; }
|
||||
}
|
||||
|
||||
public class AgoraCallbackPayload
|
||||
|
|
@ -46,5 +47,54 @@ namespace WGShare.Domain.DTOs.AgoraCallback
|
|||
/// 频道名称
|
||||
/// </summary>
|
||||
public string channelName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该事件在声网业务服务器上发生的 Unix 时间戳 (s)。
|
||||
/// </summary>
|
||||
public long ts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后一个离开频道的用户 ID。(则声网消息通知可能返回不同的 lastUid,此时任选其一即可。)
|
||||
/// </summary>
|
||||
public long lastUid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主播设备所属平台
|
||||
/// </summary>
|
||||
public PlatformType platform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 序列号,标识该事件在 App 客户端上发生的顺序,可用于对同一用户的事件进行排序。详见维护用户在线状态。
|
||||
/// </summary>
|
||||
public long clientSeq { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// String 类型的用户 ID。
|
||||
/// </summary>
|
||||
public string account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主播在频道内的时长 (s)。
|
||||
/// </summary>
|
||||
public int duration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <para>主播离开频道的原因: </para>
|
||||
/// <para>1:主播正常离开频道。</para>
|
||||
/// <para>2:客户端与声网业务服务器连接超时。判断标准为声网 SD-RTN 超过 10 秒未收到该主播的任何数据包,或连接单台服务器 4 秒超时并在 1 秒内没有完成重连。</para>
|
||||
/// <para>3:权限问题。如被运营人员通过踢人 RESTful API 踢出频道。</para>
|
||||
/// <para>4:声网业务服务器内部原因。如声网业务服务器在调整负载,和客户端短暂断开连接,之后会重新连接。</para>
|
||||
/// <para>5:主播切换新设备,迫使旧设备下线。</para>
|
||||
/// <para>9:由于客户端有多个 IP 地址,SDK 主动与声网业务服务器断开连接并重连。此过程用户无感知。请检查用户是否存在多个公网 IP 或使用了 VPN。</para>
|
||||
/// <para>10:由于网络连接问题,例如 SDK 超过 4 秒未收到来自声网业务服务器的任何数据包或 socket 连接错误,SDK 主动与声网业务服务器断开连接并重连。此过程用户无感知。请检查网络连接状态。</para>
|
||||
/// <para>999:异常用户。例如,用户短时间内频繁登录登出频道会被判定为异常用户。信息:你的 App 服务端需要在收到 reason 为 999 的 104 事件 60 秒后调用踢人 API 将该用户踢出频道。否则,该用户再次加入频道后,可能无法收到相关事件通知。</para>
|
||||
/// <para>0:其他原因。</para>
|
||||
/// </summary>
|
||||
public LeaveReasonEnum reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主播在频道内的用户 ID。
|
||||
/// </summary>
|
||||
public long uid { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace WGShare.Domain.DTOs.Room
|
||||
{
|
||||
public class RoomMettingRecordExportDTO
|
||||
{
|
||||
public string RoomName { get; set; }
|
||||
public string RoomNum { get; set; }
|
||||
|
||||
public string BeginTime { get; set; }
|
||||
public string EndTime { get; set; }
|
||||
|
||||
public List<UserBehavior> Users { get; set; }
|
||||
}
|
||||
|
||||
public class UserBehavior
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public string Account { get; set; }
|
||||
public string Role { get; set; }
|
||||
public string FirstJoinTime { get; set; }
|
||||
public string LastExitTime { get; set; }
|
||||
public int JoinCount { get; set; }
|
||||
public int SumTime { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
using SqlSugar;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using WGShare.Domain.Enums;
|
||||
using Yitter.IdGenerator;
|
||||
|
||||
namespace WGShare.Domain.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// 会议室在会记录
|
||||
///</summary>
|
||||
[SugarTable("meeting_record")]
|
||||
public class MeetingRecord
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
///</summary>
|
||||
[SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件类型
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "event_type")]
|
||||
public EventType EventType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户端序列号
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "client_seq")]
|
||||
public long clientSeq { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 发生Unix时间戳
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "ts")]
|
||||
public long ts { get; set; }
|
||||
/// <summary>
|
||||
/// 用户ID
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "uid")]
|
||||
public string uid { get; set; }
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "create_time", IsOnlyIgnoreInsert = true, IsOnlyIgnoreUpdate = true)]
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 频道号
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "channel_name")]
|
||||
public string channelName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户端类型
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "platform")]
|
||||
public PlatformType platform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 离开原因
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "leave_reason")]
|
||||
public LeaveReasonEnum reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 在会时长
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "duration")]
|
||||
public int duration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字符串类型 用户id(声网返回)
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnName = "account")]
|
||||
public string account { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称
|
||||
/// </summary>
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string userName { get; set; }
|
||||
/// <summary>
|
||||
/// 用户账号
|
||||
/// </summary>
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string UserAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户角色名称
|
||||
/// </summary>
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string RoleName { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace WGShare.Domain.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// 声网回调接口:主播离开频道的原因
|
||||
/// </summary>
|
||||
public enum LeaveReasonEnum
|
||||
{
|
||||
/// <summary>
|
||||
/// 主播正常离开频道。
|
||||
/// </summary>
|
||||
normal = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 客户端与声网业务服务器连接超时。判断标准为声网 SD-RTN 超过 10 秒未收到该主播的任何数据包,或连接单台服务器 4 秒超时并在 1 秒内没有完成重连。
|
||||
/// </summary>
|
||||
timeout = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 权限问题。如被运营人员通过踢人 RESTful API 踢出频道。
|
||||
/// </summary>
|
||||
permission = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 声网业务服务器内部原因。如声网业务服务器在调整负载,和客户端短暂断开连接,之后会重新连接。
|
||||
/// </summary>
|
||||
agora_internal_error = 4,
|
||||
|
||||
/// <summary>
|
||||
/// 主播切换新设备,迫使旧设备下线。。
|
||||
/// </summary>
|
||||
force_logout = 5,
|
||||
|
||||
/// <summary>
|
||||
/// 由于客户端有多个 IP 地址,SDK 主动与声网业务服务器断开连接并重连。此过程用户无感知。请检查用户是否存在多个公网 IP 或使用了 VPN。
|
||||
/// </summary>
|
||||
multiple_ip = 9,
|
||||
|
||||
/// <summary>
|
||||
/// 由于网络连接问题,例如 SDK 超过 4 秒未收到来自声网业务服务器的任何数据包或 socket 连接错误,SDK 主动与声网业务服务器断开连接并重连。此过程用户无感知。请检查网络连接状态。
|
||||
/// </summary>
|
||||
network_error = 10,
|
||||
|
||||
/// <summary>
|
||||
/// 异常用户。例如,用户短时间内频繁登录登出频道会被判定为异常用户。
|
||||
/// </summary>
|
||||
abnormal_user = 999,
|
||||
|
||||
/// <summary>
|
||||
/// 其他原因
|
||||
/// </summary>
|
||||
other = 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace WGShare.Domain.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// 声网平台类型
|
||||
/// </summary>
|
||||
public enum PlatformType
|
||||
{
|
||||
Android = 1,
|
||||
iOS = 2,
|
||||
Windows = 5,
|
||||
Linux = 6,
|
||||
Web = 7,
|
||||
MacOS = 8,
|
||||
Other = 0
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.6" />
|
||||
<PackageReference Include="MiniExcel" Version="1.34.0" />
|
||||
<PackageReference Include="SqlSugarCore" Version="5.1.4.158" />
|
||||
<PackageReference Include="SqlSugarCore" Version="5.1.4.169" />
|
||||
<PackageReference Include="Yitter.IdGenerator" Version="1.0.14" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue