using System.Text.RegularExpressions;
using Dpz.Core.Entity.Base.PublicStruct;
using Microsoft.AspNetCore.SignalR;
namespace Dpz.Core.Web.Library.Hub;
/// <summary>
/// 群组聊天客户端接口
/// </summary>
public interface IGroupChatClient
{
Task OnMessageReceived(GroupChatMessage message);
Task OnUserJoined(VmUserInfo user);
Task OnUserLeft(VmUserInfo user);
Task OnHistoryLoaded(List<GroupChatMessage> messages, bool hasMore);
Task OnError(string error);
Task OnDraw(object data);
Task OnDrawingUserChanged(VmUserInfo? user);
}
/// <summary>
/// 群组聊天Hub
/// </summary>
public class GroupChatHub(
IChatRecordService chatRecordService,
ILogger<GroupChatHub> logger,
IFusionCache fusionCache,
IHttpCurrentUserService currentUserService
) : Hub<IGroupChatClient>
{
private const string GroupId = WebToolsExtensions.DefaultGroupId;
private const string DrawingUserKey = "GroupChat:DrawingUser";
private const string ConnectionSessionKeyPrefix = "GroupChat:ConnectionSession:";
private const string UserConnectionCountKeyPrefix = "GroupChat:UserConnectionCount:";
private const string UserFontColorKeyPrefix = "GroupChat:UserFontColor:";
/// <summary>
/// 加入群组
/// </summary>
/// <param name="sessionId">会话ID</param>
public async Task<VmUserInfo?> JoinGroup(string? sessionId = null)
{
sessionId ??= Context
.GetHttpContext()
?.Request.Cookies[Program.AuthorizeCookieName + ".SessionId"];
if (string.IsNullOrWhiteSpace(sessionId))
{
await Clients.Caller.OnError("需要SessionId才能加入群聊");
return null;
}
// 存储 connectionId -> sessionId 映射到分布式缓存,供后续方法使用
var connectionSessionKey = $"{ConnectionSessionKeyPrefix}{Context.ConnectionId}";
await fusionCache.SetAsync(connectionSessionKey, sessionId, TimeSpan.FromHours(24));
VmUserInfo user;
// 检查是否已登录
if (Context.User?.Authenticated == true)
{
user =
await currentUserService.GetUserInfoAsync()
?? throw new InvalidCredentialException();
}
else
{
var cacheKey = $"GroupChat:AnonymousUser:{sessionId}";
user = await fusionCache.GetOrSetAsync(
cacheKey,
async _ =>
{
var displayName = await AssignNameAsync();
return new VmUserInfo
{
Id = sessionId,
Name = displayName,
Avatar =
$"https://cravatar.cn/avatar/{sessionId.GenerateHashMd5()}?d=wavatar",
Sign = $"匿名用户 - {displayName}",
LastAccessTime = DateTime.Now,
Key = "",
};
},
TimeSpan.FromDays(7)
);
}
await Groups.AddToGroupAsync(Context.ConnectionId, GroupId);
// 增加用户连接计数,只有首次连接时才广播加入消息
var userConnectionCountKey = $"{UserConnectionCountKeyPrefix}{user.Id}";
var connectionCount = await fusionCache.GetOrSetAsync(
userConnectionCountKey,
_ => Task.FromResult(0),
TimeSpan.FromHours(24)
);
connectionCount++;
await fusionCache.SetAsync(userConnectionCountKey, connectionCount, TimeSpan.FromHours(24));
if (connectionCount == 1)
{
await Clients.Group(GroupId).OnUserJoined(user);
}
// 如果当前有人在画图,通知新加入的用户
var drawingUser = await fusionCache.TryGetAsync<VmUserInfo>(DrawingUserKey);
if (drawingUser.HasValue)
{
await Clients.Caller.OnDrawingUserChanged(drawingUser.Value);
}
logger.LogInformation("用户 {UserId} ({UserName}) 加入群组", user.Id, user.Name);
return user;
}
/// <summary>
/// 发送消息
/// </summary>
public async Task SendMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
{
await Clients.Caller.OnError("消息内容不能为空");
return;
}
var user = await GetCurrentUserAsync();
if (user == null)
{
return;
}
var fontColor = await GetUserFontColorAsync(user.Id);
// 保存聊天记录
await chatRecordService.SaveGroupRecordAsync(user, GroupId, message, fontColor);
// 广播消息
var messageData = new GroupChatMessage
{
UserId = user.Id,
UserName = user.Name,
Avatar = user.Avatar,
Message = message,
SendTime = DateTime.Now,
FontColor = fontColor,
};
await Clients.Group(GroupId).OnMessageReceived(messageData);
logger.LogInformation(
"用户 {UserId} ({UserName}) 发送消息: {Message}",
user.Id,
user.Name,
message
);
}
/// <summary>
/// 设置聊天文字颜色
/// </summary>
/// <param name="color">颜色值,支持 Hex 或 rgb</param>
public async Task SetFontColor(string color)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return;
}
if (!IsValidFontColor(color))
{
await Clients.Caller.OnError("颜色格式无效,仅支持 Hex 或 rgb(r,g,b)");
return;
}
var normalizedColor = NormalizeFontColor(color);
var cacheKey = $"{UserFontColorKeyPrefix}{user.Id}";
await fusionCache.SetAsync(cacheKey, normalizedColor, TimeSpan.FromDays(30));
}
/// <summary>
/// 获取当前用户字体颜色
/// </summary>
public async Task<string?> GetFontColor()
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return null;
}
return await GetUserFontColorAsync(user.Id);
}
/// <summary>
/// 请求画图权限
/// </summary>
public async Task RequestDrawingAccess()
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return;
}
// 使用 IFusionCache 实现简单的 分布式锁
var currentDrawingUser = await fusionCache.TryGetAsync<VmUserInfo>(DrawingUserKey);
if (!currentDrawingUser.HasValue)
{
// 30分钟超时自动释放
await fusionCache.SetAsync(DrawingUserKey, user, TimeSpan.FromMinutes(30));
await Clients.Group(GroupId).OnDrawingUserChanged(user);
}
else if (currentDrawingUser.Value.Id == user.Id)
{
// 已经是自己了
await Clients.Caller.OnDrawingUserChanged(user);
}
else
{
await Clients.Caller.OnError(
$"当前用户 {currentDrawingUser.Value.Name} 正在画图,请稍后再试"
);
}
}
/// <summary>
/// 释放画图权限
/// </summary>
public async Task ReleaseDrawingAccess()
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return;
}
var currentDrawingUser = await fusionCache.TryGetAsync<VmUserInfo>(DrawingUserKey);
if (currentDrawingUser.HasValue && currentDrawingUser.Value.Id == user.Id)
{
await fusionCache.RemoveAsync(DrawingUserKey);
await Clients.Group(GroupId).OnDrawingUserChanged(null);
}
}
/// <summary>
/// 发送画图数据
/// </summary>
public async Task SendDrawingData(object data)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return;
}
var currentDrawingUser = await fusionCache.TryGetAsync<VmUserInfo>(DrawingUserKey);
if (currentDrawingUser.HasValue && currentDrawingUser.Value.Id == user.Id)
{
await Clients.OthersInGroup(GroupId).OnDraw(data);
}
}
/// <summary>
/// 获取历史记录
/// </summary>
public async Task GetHistory(int pageIndex = 1, int pageSize = 50)
{
if (await GetCurrentUserAsync() == null)
{
return;
}
// 限制最多1000条
var maxPageSize = 1000;
var actualPageSize = Math.Min(pageSize, maxPageSize);
var actualPageIndex = pageIndex;
var records = await chatRecordService.GetGroupRecordAsync(
GroupId,
actualPageIndex,
actualPageSize
);
List<GroupChatMessage> messages = [];
foreach (var item in records)
{
if (item.Sender == null)
{
continue;
}
var message = new GroupChatMessage
{
UserId = item.Sender.Id,
UserName = item.Sender.Name,
Avatar = item.Sender.Avatar,
Message = item.Message,
SendTime = item.SendTime,
FontColor = ResolveHistoryFontColor(item.FontColor),
};
messages.Add(message);
}
// 计算是否还有更多记录
var hasMore = records.CurrentPageIndex < records.TotalPageCount;
await Clients.Caller.OnHistoryLoaded(messages, hasMore);
}
private async Task<string?> GetUserFontColorAsync(string userId)
{
var cacheKey = $"{UserFontColorKeyPrefix}{userId}";
return await fusionCache.GetOrDefaultAsync<string?>(cacheKey);
}
private static string? ResolveHistoryFontColor(string? fontColor)
{
if (string.IsNullOrWhiteSpace(fontColor))
{
return null;
}
if (!IsValidFontColor(fontColor))
{
return null;
}
return NormalizeFontColor(fontColor);
}
private static bool IsValidFontColor(string color)
{
if (string.IsNullOrWhiteSpace(color))
{
return false;
}
var value = color.Trim();
if (Regex.IsMatch(value, "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"))
{
return true;
}
var rgbMatch = Regex.Match(
value,
"^rgb\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*\\)$",
RegexOptions.IgnoreCase
);
if (!rgbMatch.Success)
{
return false;
}
for (var i = 1; i <= 3; i++)
{
if (!int.TryParse(rgbMatch.Groups[i].Value, out var num))
{
return false;
}
if (num is < 0 or > 255)
{
return false;
}
}
return true;
}
private static string NormalizeFontColor(string color)
{
var value = color.Trim();
var rgbMatch = Regex.Match(
value,
"^rgb\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*\\)$",
RegexOptions.IgnoreCase
);
if (!rgbMatch.Success)
{
return value;
}
var red = int.Parse(rgbMatch.Groups[1].Value);
var green = int.Parse(rgbMatch.Groups[2].Value);
var blue = int.Parse(rgbMatch.Groups[3].Value);
return $"rgb({red},{green},{blue})";
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var user = await GetCurrentUserAsync();
if (user != null)
{
// 减少用户连接计数
var userConnectionCountKey = $"{UserConnectionCountKeyPrefix}{user.Id}";
var connectionCount = await fusionCache.GetOrDefaultAsync(userConnectionCountKey, 0);
connectionCount = Math.Max(0, connectionCount - 1);
if (connectionCount > 0)
{
await fusionCache.SetAsync(
userConnectionCountKey,
connectionCount,
TimeSpan.FromHours(24)
);
}
else
{
// 所有连接都断开,移除计数缓存并广播离开消息
await fusionCache.RemoveAsync(userConnectionCountKey);
await Clients.Group(GroupId).OnUserLeft(user);
logger.LogInformation("用户 {UserId} ({UserName}) 离开群组", user.Id, user.Name);
// 如果是画图用户断开连接,释放画图权限
var drawingUser = await fusionCache.TryGetAsync<VmUserInfo>(DrawingUserKey);
if (drawingUser.HasValue && drawingUser.Value.Id == user.Id)
{
await fusionCache.RemoveAsync(DrawingUserKey);
await Clients.Group(GroupId).OnDrawingUserChanged(null);
}
}
}
// 清理 connectionId -> sessionId 缓存
var connectionSessionKey = $"{ConnectionSessionKeyPrefix}{Context.ConnectionId}";
await fusionCache.RemoveAsync(connectionSessionKey);
await base.OnDisconnectedAsync(exception);
}
private async Task<VmUserInfo?> GetCurrentUserAsync()
{
// SessionId 获取优先级:缓存 -> Cookie
var connectionSessionKey = $"{ConnectionSessionKeyPrefix}{Context.ConnectionId}";
var cachedSessionId = await fusionCache.TryGetAsync<string>(connectionSessionKey);
var sessionId = cachedSessionId.HasValue
? cachedSessionId.Value
: Context.GetHttpContext()?.Request.Cookies[Program.AuthorizeCookieName + ".SessionId"];
if (string.IsNullOrWhiteSpace(sessionId))
{
await Clients.Caller.OnError("需要SessionId,请先调用 JoinGroup");
return null;
}
if (Context.User?.Authenticated == true)
{
return await currentUserService.GetUserInfoAsync();
}
var cacheKey = $"GroupChat:AnonymousUser:{sessionId}";
var anonymousUser = await fusionCache.TryGetAsync<VmUserInfo>(cacheKey);
if (!anonymousUser.HasValue)
{
await Clients.Caller.OnError("用户信息不存在,请重新加入群组");
return null;
}
return anonymousUser.Value;
}
private async Task<string> AssignNameAsync()
{
for (var i = 0; i < 5; i++)
{
var candidate = GenerateBaseName();
var nameKey = $"GroupChat:TakenName:{candidate}";
var isTaken = await fusionCache.TryGetAsync<string>(nameKey);
if (!isTaken.HasValue)
{
await fusionCache.SetAsync(nameKey, "taken", TimeSpan.FromDays(7));
return candidate;
}
}
var baseName = GenerateBaseName();
var suffix = Random.Shared.Next(1000, 9999);
var finalName = $"{baseName}{suffix}";
// 占位
await fusionCache.SetAsync(
$"GroupChat:TakenName:{finalName}",
"taken",
TimeSpan.FromDays(7)
);
return finalName;
string GenerateBaseName()
{
var adjList = Adjectives.AdjectiveList;
var nameList = AnonymousNames.NameList;
var adj = adjList[Random.Shared.Next(adjList.Count)];
var name = nameList[Random.Shared.Next(nameList.Count)];
return $"{adj}de{name}";
}
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
代码解释
这是一个基于 ASP.NET Core SignalR 实现的群组聊天系统,包含实时消息、用户管理、协同画图和历史记录等功能。
核心组成部分
1. IGroupChatClient 接口
定义了客户端可以接收的消息类型:
OnMessageReceived- 接收聊天消息OnUserJoined/OnUserLeft- 用户进出通知OnHistoryLoaded- 历史记录加载OnError- 错误提示OnDraw- 接收画图数据OnDrawingUserChanged- 画图用户变更通知
2. GroupChatHub 类
主要功能实现:
依赖注入服务
IChatRecordService- 聊天记录持久化ILogger- 日志记录IFusionCache- 分布式缓存(用于跨服务器状态同步)IHttpCurrentUserService- 当前用户信息获取
关键常量
GroupId = "默认群组ID"
DrawingUserKey = "当前画图用户缓存键"
ConnectionSessionKeyPrefix = "连接会话映射前缀"
UserConnectionCountKeyPrefix = "用户连接计数前缀"
UserFontColorKeyPrefix = "用户字体颜色前缀"
主要功能
1. 用户加入群组 (JoinGroup)
- 从Cookie或参数获取sessionId
- 已登录用户:获取真实用户信息
- 匿名用户:生成随机昵称(形如"勇敢de小明")+ Gravatar头像
- 加入SignalR群组
- 连接计数管理(支持多标签页)
- 只在首次连接时广播加入消息
- 同步当前画图用户状态
2. 发送消息 (SendMessage)
- 验证消息内容
- 获取用户自定义字体颜色
- 持久化聊天记录
- 广播消息给所有群组成员
3. 字体颜色管理
SetFontColor - 设置用户字体颜色(支持Hex/RGB格式)
GetFontColor - 获取当前用户颜色
IsValidFontColor - 校验颜色格式(正则验证)
NormalizeFontColor - 标准化RGB格式为 "rgb(r,g,b)"
4. 协同画图功能
RequestDrawingAccess - 请求画图权限(分布式锁实现)
ReleaseDrawingAccess - 释放画图权限
SendDrawingData - 广播画图数据(仅画图者可发送)
特点:
- 同一时间只允许一人画图
- 30分钟自动释放锁
- 断线自动释放权限
5. 历史记录 (GetHistory)
- 支持分页(最多1000条)
- 过滤无效发送者
- 解析历史字体颜色
- 返回是否有更多记录标志
6. 连接管理 (OnDisconnectedAsync)
- 连接计数递减
- 所有连接断开时:
├─ 广播用户离开消息
├─ 清理连接计数缓存
└─ 释放画图权限(如果持有)
- 清理连接会话映射
技术亮点
分布式缓存应用
// 支持多服务器部署
- 用户连接计数跨实例同步
- 画图权限分布式锁
- 匿名用户信息持久化
- 字体颜色设置共享
多标签页支持
通过连接计数实现:
- 用户打开多个标签页只广播一次"加入"消息
- 关闭部分标签页不触发"离开"消息
- 所有标签页关闭才真正离开群组
匿名用户处理
// 昵称生成策略
1. 尝试生成5次无重复昵称(形容词+名词)
2. 失败则追加随机4位数字后缀
3. 使用sessionId的MD5生成Gravatar头像
4. 7天缓存有效期
安全设计
- 颜色值正则验证(防止XSS)
- RGB值范围校验(0-255)
- 画图权限严格校验
- 历史记录数量限制(防止内存溢出)
数据流示例
客户端连接 → JoinGroup(sessionId)
↓
获取/生成用户信息
↓
加入SignalR群组
↓
广播 OnUserJoined (仅首次连接)
↓
SendMessage("Hello")
↓
保存到数据库 + 广播 OnMessageReceived
↓
断开连接 → OnDisconnectedAsync
↓
广播 OnUserLeft (最后一个连接)
此系统非常适合需要实时协作、多人聊天和简单绘图功能的Web应用场景。
AI 正在分析代码…
评论加载中...