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应用场景。

评论加载中...