diff --git a/WGShare.API/BackgroudServices/AgoraCallbackComsuerService.cs b/WGShare.API/BackgroudServices/AgoraCallbackComsuerService.cs new file mode 100644 index 0000000..9415a1c --- /dev/null +++ b/WGShare.API/BackgroudServices/AgoraCallbackComsuerService.cs @@ -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 _logger; + private readonly ISqlSugarClient _sugarClient; + private readonly SemaphoreSlim _semaphore; + + public AgoraCallbackComsuerService(ILogger 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(); + 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(); + 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(); + } + } + } +} diff --git a/WGShare.API/BackgroudServices/AgoraDataService.cs b/WGShare.API/BackgroudServices/AgoraDataService.cs deleted file mode 100644 index 8af0e34..0000000 --- a/WGShare.API/BackgroudServices/AgoraDataService.cs +++ /dev/null @@ -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 _logger; - private readonly ISqlSugarClient _sugarClient; - private readonly OssHelper _ossHelper; - - public OssCleanWorker(ILogger 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() - .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() - .SetColumns(x => x.IsFileClean == true) - .Where(x => deleteFiles.Select(a => a.Id).Contains(x.Id)) - .ExecuteCommandAsync(); - } - - await Console.Out.WriteLineAsync($@"本次清除操作结束,当前时间:{DateTime.Now}"); - } - } -} diff --git a/WGShare.API/Controllers/Frontend/AgoraCallbackController.cs b/WGShare.API/Controllers/Frontend/AgoraCallbackController.cs index 0ceb99a..8b4e689 100644 --- a/WGShare.API/Controllers/Frontend/AgoraCallbackController.cs +++ b/WGShare.API/Controllers/Frontend/AgoraCallbackController.cs @@ -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接口 /// [ApiExplorerSettings(GroupName = "frontend")] - [Route("agora-cb")] + [Route("agora-cb"), AllowAnonymous] public class AgoraCallbackController : BasicController { private readonly ILogger _logger; + private readonly AgoraHelper _agoraHelper; + private readonly IConfiguration _configuration; public AgoraCallbackController( - ILogger logger) + ILogger logger, + AgoraHelper agoraHelper, + IConfiguration configuration) { this._logger = logger; + this._agoraHelper = agoraHelper; + this._configuration = configuration; } + [HttpPost("event")] - public async Task 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(); + 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; + } + + } + + /// + /// 加入频道 + /// + [NonAction] + private void JoinChannelEvent(string bodyString) + { + _logger.LogDebug($"Agora回调内容 加入频道:{bodyString}"); + + RedisHelper.Instance.LPush(RedisKeyConstant.PubSub.MeetingRecord, bodyString); + } + + /// + /// 离开频道 + /// + [NonAction] + private void LeaveChannelEvent(string bodyString) + { + _logger.LogDebug($"Agora回调内容 离开频道:{bodyString}"); + + // 离会记录 + RedisHelper.Instance.LPush(RedisKeyConstant.PubSub.MeetingRecord, bodyString); + } + + } + + } diff --git a/WGShare.API/Controllers/Frontend/HomeController.cs b/WGShare.API/Controllers/Frontend/HomeController.cs index 82c10c0..fae5b6b 100644 --- a/WGShare.API/Controllers/Frontend/HomeController.cs +++ b/WGShare.API/Controllers/Frontend/HomeController.cs @@ -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 _logger; + private readonly OssHelper _ossHelper; public HomeController( ISqlSugarClient sqlSugar, IConfiguration configuration, IHttpClientFactory httpClientFactory, AgoraHelper agoraHelper, - ILogger logger) + ILogger 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; } + + /// + /// 删除会议室 + /// + /// + /// + [HttpDelete("room")] + public async Task DeleteRoom([FromQuery] string roomId) + { + return await _sqlSugar.Updateable() + .SetColumns(x => x.IsDelete == true) + .Where(x => x.Id == roomId).ExecuteCommandAsync() > 0; + } + /// /// 获取 rtm token /// @@ -124,5 +144,104 @@ namespace WGShare.API.Controllers.Frontend { return _configuration["Agora:appId"].ToString(); } + + /// + /// 获取会议记录 + /// + /// + [HttpGet("record")] + public async Task GetMeetingRecord([FromQuery] long beginTimestamp, [FromQuery] long endTimestamp, [FromQuery] string roomNum) + { + var room = await _sqlSugar.Queryable().Where(x => x.RoomNum == roomNum && x.IsDelete == false).FirstAsync(); + if (room == null) + { + throw Oops.Oh("该会议室不存在!"); + } + + var recordList = await _sqlSugar.Queryable() + .InnerJoin((mr, u) => mr.uid == u.Id) + .InnerJoin((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() + }; + + 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); + } } } diff --git a/WGShare.API/Helpers/AgoraHelper.cs b/WGShare.API/Helpers/AgoraHelper.cs index 778acc1..5f73984 100644 --- a/WGShare.API/Helpers/AgoraHelper.cs +++ b/WGShare.API/Helpers/AgoraHelper.cs @@ -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); + } + } + + /// + /// 验证声网回调签名 + /// + /// + /// + /// + public async Task 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; + } } } diff --git a/WGShare.API/Helpers/DateTimeUtils.cs b/WGShare.API/Helpers/DateTimeUtils.cs new file mode 100644 index 0000000..b5e7d13 --- /dev/null +++ b/WGShare.API/Helpers/DateTimeUtils.cs @@ -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); + + /// + /// 将 JavaScript 时间戳(以秒为单位)转换为 DateTime 对象(UTC 时间)。 + /// + /// JavaScript 时间戳(以秒为单位)。 + /// 对应的 DateTime 对象(UTC 时间)。 + public static DateTime FromJavaScriptTimestamp(long jsTimestamp) + { + return Epoch.AddSeconds(jsTimestamp); + } + + /// + /// 将 DateTime 对象(UTC 时间)转换为 JavaScript 时间戳(以秒为单位)。 + /// + /// DateTime 对象(UTC 时间)。 + /// 对应的 JavaScript 时间戳(以秒为单位)。 + public static long ToJavaScriptTimestamp(DateTime dateTime) + { + return (long)(dateTime.ToUniversalTime() - Epoch).Seconds; + } + + /// + /// 将 JavaScript 时间戳(以秒为单位)转换为本地时间的 DateTime 对象。 + /// + /// JavaScript 时间戳(以秒为单位)。 + /// 对应的本地时间的 DateTime 对象。 + public static DateTime FromJavaScriptTimestampToLocal(long jsTimestamp) + { + DateTime utcDateTime = FromJavaScriptTimestamp(jsTimestamp); + return utcDateTime.ToLocalTime(); + + } + } +} diff --git a/WGShare.API/Helpers/RedisHelper.cs b/WGShare.API/Helpers/RedisHelper.cs index bcf82c7..835b7ba 100644 --- a/WGShare.API/Helpers/RedisHelper.cs +++ b/WGShare.API/Helpers/RedisHelper.cs @@ -45,74 +45,6 @@ namespace WGShare.API.Helpers /// 最大秒数 /// public static int RandomExpired(int minTimeoutSeconds, int maxTimeoutSeconds) => rnd.Value.Next(minTimeoutSeconds, maxTimeoutSeconds); - - //public static List HVals(string key) where T : class - //{ - // var valueStrings = Instance.HVals(key); - // if (valueStrings.IsNullOrEmpty()) - // { - // return null; - // } - - // return valueStrings.ToList().ConvertAll(x => JsonConvert.DeserializeObject(x)); - //} - - //public static T HGet(string key, string field) where T : class - //{ - // var valueString = Instance.HGet(key, field); - // if (valueString.IsNullOrEmpty()) - // { - // return null; - // } - // return JsonConvert.DeserializeObject(valueString); - //} - - //public static List HMGet(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(x)); - //} - - //public static Dictionary HGetAll(string key) where T : class - //{ - // var dic = Instance.HGetAll(key); - // if (dic == null || dic.Count == 0) - // { - // return null; - // } - // Dictionary result = new Dictionary(); - - // foreach (var kv in dic) - // { - // if (string.IsNullOrWhiteSpace(kv.Key) || string.IsNullOrWhiteSpace(kv.Value)) - // { - // continue; - // } - // result.Add(kv.Key, JsonConvert.DeserializeObject(kv.Value)); - // } - - // return result; - //} - - - //public static long HSet(string key, string fields, T value) where T : class - //{ - // return Instance.HSet(key, fields, value.ToJsonString()); - //} - - - //public static void HMSet(string key, Dictionary keyValues) where T : class - //{ - // var dic = new Dictionary(); - // foreach (var kv in keyValues) - // { - // dic.Add(kv.Key,kv.Value.ToJsonString()); - // } - // Instance.HMSet(key, dic); - //} + } } diff --git a/WGShare.API/Program.cs b/WGShare.API/Program.cs index 2f3088d..b7ff144 100644 --- a/WGShare.API/Program.cs +++ b/WGShare.API/Program.cs @@ -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(); + builder.Services.AddHostedService(); builder.Services.AddAuth(configuration["Jwt:Issuer"], configuration["Jwt:Audience"], configuration["Jwt:SecretKey"]); diff --git a/WGShare.API/WGShare.API.csproj b/WGShare.API/WGShare.API.csproj index cbe3796..b2a151f 100644 --- a/WGShare.API/WGShare.API.csproj +++ b/WGShare.API/WGShare.API.csproj @@ -29,6 +29,9 @@ + + PreserveNewest + Never diff --git a/WGShare.API/WGShare.API.xml b/WGShare.API/WGShare.API.xml index 16316fb..b86f7e0 100644 --- a/WGShare.API/WGShare.API.xml +++ b/WGShare.API/WGShare.API.xml @@ -66,6 +66,16 @@ Agora接口 + + + 加入频道 + + + + + 离开频道 + + 首页接口 @@ -85,6 +95,13 @@ + + + 删除会议室 + + + + 获取 rtm token @@ -97,6 +114,12 @@ + + + 获取会议记录 + + + 会议室接口 @@ -335,6 +358,35 @@ + + + 验证声网回调签名 + + + + + + + + 将 JavaScript 时间戳(以秒为单位)转换为 DateTime 对象(UTC 时间)。 + + JavaScript 时间戳(以秒为单位)。 + 对应的 DateTime 对象(UTC 时间)。 + + + + 将 DateTime 对象(UTC 时间)转换为 JavaScript 时间戳(以秒为单位)。 + + DateTime 对象(UTC 时间)。 + 对应的 JavaScript 时间戳(以秒为单位)。 + + + + 将 JavaScript 时间戳(以秒为单位)转换为本地时间的 DateTime 对象。 + + JavaScript 时间戳(以秒为单位)。 + 对应的本地时间的 DateTime 对象。 + 获取上传url diff --git a/WGShare.API/appsettings.Development.json b/WGShare.API/appsettings.Development.json index fa7b4e5..697dbbe 100644 --- a/WGShare.API/appsettings.Development.json +++ b/WGShare.API/appsettings.Development.json @@ -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" } } diff --git a/WGShare.API/appsettings.json b/WGShare.API/appsettings.json index e4d688f..28d75ef 100644 --- a/WGShare.API/appsettings.json +++ b/WGShare.API/appsettings.json @@ -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": "" } } diff --git a/WGShare.API/meetingRecordTemplate.xlsx b/WGShare.API/meetingRecordTemplate.xlsx new file mode 100644 index 0000000..824af45 Binary files /dev/null and b/WGShare.API/meetingRecordTemplate.xlsx differ diff --git a/WGShare.Domain/Constant/RedisKeyConstant.cs b/WGShare.Domain/Constant/RedisKeyConstant.cs index 7b33549..04c0f77 100644 --- a/WGShare.Domain/Constant/RedisKeyConstant.cs +++ b/WGShare.Domain/Constant/RedisKeyConstant.cs @@ -79,5 +79,12 @@ namespace WGShare.Domain.Constant } + /// + /// 发布订阅 + /// + public class PubSub + { + public static string MeetingRecord => "meeting_record"; + } } } diff --git a/WGShare.Domain/DTOs/AgoraCallback/EventBody.cs b/WGShare.Domain/DTOs/AgoraCallback/EventBody.cs index d844ae5..224ad09 100644 --- a/WGShare.Domain/DTOs/AgoraCallback/EventBody.cs +++ b/WGShare.Domain/DTOs/AgoraCallback/EventBody.cs @@ -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 /// /// 声网消息服务器向你的服务器发送事件通知的 Unix 时间戳 (ms)。通知重试时该值会更新。 /// - public int notifyMs { get; set; } + public long notifyMs { get; set; } /// /// 会话 ID。 @@ -37,7 +38,7 @@ namespace WGShare.Domain.DTOs.AgoraCallback /// /// 通知事件的具体内容。payload 因 eventType 而异,详见频道事件类型。 /// - public string payload { get; set; } + public AgoraCallbackPayload payload { get; set; } } public class AgoraCallbackPayload @@ -46,5 +47,54 @@ namespace WGShare.Domain.DTOs.AgoraCallback /// 频道名称 /// public string channelName { get; set; } + + /// + /// 该事件在声网业务服务器上发生的 Unix 时间戳 (s)。 + /// + public long ts { get; set; } + + /// + /// 最后一个离开频道的用户 ID。(则声网消息通知可能返回不同的 lastUid,此时任选其一即可。) + /// + public long lastUid { get; set; } + + /// + /// 主播设备所属平台 + /// + public PlatformType platform { get; set; } + + /// + /// 序列号,标识该事件在 App 客户端上发生的顺序,可用于对同一用户的事件进行排序。详见维护用户在线状态。 + /// + public long clientSeq { get; set; } + + /// + /// String 类型的用户 ID。 + /// + public string account { get; set; } + + /// + /// 主播在频道内的时长 (s)。 + /// + public int duration { get; set; } + + /// + /// 主播离开频道的原因: + /// 1:主播正常离开频道。 + /// 2:客户端与声网业务服务器连接超时。判断标准为声网 SD-RTN 超过 10 秒未收到该主播的任何数据包,或连接单台服务器 4 秒超时并在 1 秒内没有完成重连。 + /// 3:权限问题。如被运营人员通过踢人 RESTful API 踢出频道。 + /// 4:声网业务服务器内部原因。如声网业务服务器在调整负载,和客户端短暂断开连接,之后会重新连接。 + /// 5:主播切换新设备,迫使旧设备下线。 + /// 9:由于客户端有多个 IP 地址,SDK 主动与声网业务服务器断开连接并重连。此过程用户无感知。请检查用户是否存在多个公网 IP 或使用了 VPN。 + /// 10:由于网络连接问题,例如 SDK 超过 4 秒未收到来自声网业务服务器的任何数据包或 socket 连接错误,SDK 主动与声网业务服务器断开连接并重连。此过程用户无感知。请检查网络连接状态。 + /// 999:异常用户。例如,用户短时间内频繁登录登出频道会被判定为异常用户。信息:你的 App 服务端需要在收到 reason 为 999 的 104 事件 60 秒后调用踢人 API 将该用户踢出频道。否则,该用户再次加入频道后,可能无法收到相关事件通知。 + /// 0:其他原因。 + /// + public LeaveReasonEnum reason { get; set; } + + /// + /// 主播在频道内的用户 ID。 + /// + public long uid { get; set; } } } diff --git a/WGShare.Domain/DTOs/Room/RoomMettingRecordExportDTO.cs b/WGShare.Domain/DTOs/Room/RoomMettingRecordExportDTO.cs new file mode 100644 index 0000000..e655c4e --- /dev/null +++ b/WGShare.Domain/DTOs/Room/RoomMettingRecordExportDTO.cs @@ -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 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; } + } +} diff --git a/WGShare.Domain/Entities/MeetingRecord.cs b/WGShare.Domain/Entities/MeetingRecord.cs new file mode 100644 index 0000000..2d86ae1 --- /dev/null +++ b/WGShare.Domain/Entities/MeetingRecord.cs @@ -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 +{ + /// + /// 会议室在会记录 + /// + [SugarTable("meeting_record")] + public class MeetingRecord + { + /// + /// + /// + [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)] + public long Id { get; set; } + + /// + /// 事件类型 + /// + [SugarColumn(ColumnName = "event_type")] + public EventType EventType { get; set; } + + /// + /// 客户端序列号 + /// + [SugarColumn(ColumnName = "client_seq")] + public long clientSeq { get; set; } + + + /// + /// 发生Unix时间戳 + /// + [SugarColumn(ColumnName = "ts")] + public long ts { get; set; } + /// + /// 用户ID + /// + [SugarColumn(ColumnName = "uid")] + public string uid { get; set; } + /// + /// 创建时间 + /// + [SugarColumn(ColumnName = "create_time", IsOnlyIgnoreInsert = true, IsOnlyIgnoreUpdate = true)] + public DateTime CreateTime { get; set; } + + /// + /// 频道号 + /// + [SugarColumn(ColumnName = "channel_name")] + public string channelName { get; set; } + + /// + /// 客户端类型 + /// + [SugarColumn(ColumnName = "platform")] + public PlatformType platform { get; set; } + + /// + /// 离开原因 + /// + [SugarColumn(ColumnName = "leave_reason")] + public LeaveReasonEnum reason { get; set; } + + /// + /// 在会时长 + /// + [SugarColumn(ColumnName = "duration")] + public int duration { get; set; } + + /// + /// 字符串类型 用户id(声网返回) + /// + [SugarColumn(ColumnName = "account")] + public string account { get; set; } + + + /// + /// 用户名称 + /// + [SugarColumn(IsIgnore = true)] + public string userName { get; set; } + /// + /// 用户账号 + /// + [SugarColumn(IsIgnore = true)] + public string UserAccount { get; set; } + + /// + /// 用户角色名称 + /// + [SugarColumn(IsIgnore = true)] + public string RoleName { get; set; } + } +} diff --git a/WGShare.Domain/Enums/LeaveReasonEnum.cs b/WGShare.Domain/Enums/LeaveReasonEnum.cs new file mode 100644 index 0000000..de29ce1 --- /dev/null +++ b/WGShare.Domain/Enums/LeaveReasonEnum.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WGShare.Domain.Enums +{ + /// + /// 声网回调接口:主播离开频道的原因 + /// + public enum LeaveReasonEnum + { + /// + /// 主播正常离开频道。 + /// + normal = 1, + + /// + /// 客户端与声网业务服务器连接超时。判断标准为声网 SD-RTN 超过 10 秒未收到该主播的任何数据包,或连接单台服务器 4 秒超时并在 1 秒内没有完成重连。 + /// + timeout = 2, + + /// + /// 权限问题。如被运营人员通过踢人 RESTful API 踢出频道。 + /// + permission = 3, + + /// + /// 声网业务服务器内部原因。如声网业务服务器在调整负载,和客户端短暂断开连接,之后会重新连接。 + /// + agora_internal_error = 4, + + /// + /// 主播切换新设备,迫使旧设备下线。。 + /// + force_logout = 5, + + /// + /// 由于客户端有多个 IP 地址,SDK 主动与声网业务服务器断开连接并重连。此过程用户无感知。请检查用户是否存在多个公网 IP 或使用了 VPN。 + /// + multiple_ip = 9, + + /// + /// 由于网络连接问题,例如 SDK 超过 4 秒未收到来自声网业务服务器的任何数据包或 socket 连接错误,SDK 主动与声网业务服务器断开连接并重连。此过程用户无感知。请检查网络连接状态。 + /// + network_error = 10, + + /// + /// 异常用户。例如,用户短时间内频繁登录登出频道会被判定为异常用户。 + /// + abnormal_user = 999, + + /// + /// 其他原因 + /// + other = 0 + } +} diff --git a/WGShare.Domain/Enums/PlatformType.cs b/WGShare.Domain/Enums/PlatformType.cs new file mode 100644 index 0000000..f848de4 --- /dev/null +++ b/WGShare.Domain/Enums/PlatformType.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WGShare.Domain.Enums +{ + /// + /// 声网平台类型 + /// + public enum PlatformType + { + Android = 1, + iOS = 2, + Windows = 5, + Linux = 6, + Web = 7, + MacOS = 8, + Other = 0 + } +} diff --git a/WGShare.Domain/WGShare.Domain.csproj b/WGShare.Domain/WGShare.Domain.csproj index 2d6f2bc..bae8b90 100644 --- a/WGShare.Domain/WGShare.Domain.csproj +++ b/WGShare.Domain/WGShare.Domain.csproj @@ -13,7 +13,7 @@ - +