前端: #65

Merged
nanxun merged 1 commits from feature-nxdev into main 2026-02-12 21:53:46 +08:00
55 changed files with 1986 additions and 95 deletions

View File

@ -11,6 +11,7 @@ using System.Linq;
using System.Threading.Tasks;
using MassTransit;
using IM_API.Configs;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace IM_API.Tests.Services
{
@ -61,7 +62,9 @@ namespace IM_API.Tests.Services
});
await _context.SaveChangesAsync();
var service = new GroupService(_context, _mapper, _loggerMock.Object, _publishMock.Object);
var userService = new Mock<UserService>();
var service = new GroupService(_context, _mapper, _loggerMock.Object, _publishMock.Object,userService.Object);
// Act (执行测试: 第1页每页2条倒序)
var result = await service.GetGroupListAsync(userId, page: 1, limit: 2, desc: true);
@ -76,8 +79,10 @@ namespace IM_API.Tests.Services
[Fact]
public async Task GetGroupList_ShouldReturnEmpty_WhenUserHasNoGroups()
{
var userSerivce = new Mock<UserService>();
// Arrange
var service = new GroupService(_context, _mapper, _loggerMock.Object, _publishMock.Object);
var service = new GroupService(_context, _mapper, _loggerMock.Object, _publishMock.Object, userSerivce.Object);
// Act
var result = await service.GetGroupListAsync(userId: 999, page: 1, limit: 10, desc: false);

View File

@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+10f79fb537b71581876f17031e09898ddf99e367")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+dc6ecf224df4e8714171e8b5d23afaa90b3a1f81")]
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@ -1 +1 @@
6a1eca496991f1712085223e3079932051f23c0e5f7568bc971c393fde95395b
1b9e709aa84e0b4f6260cd10cf25bfc3a30c60e75a3966fc7d4cdf489eae898b

View File

@ -0,0 +1,24 @@
using IM_API.Domain.Events;
using IM_API.Interface.Services;
using MassTransit;
namespace IM_API.Application.EventHandlers.GroupInviteActionUpdateHandler
{
public class RequestDbHandler : IConsumer<GroupInviteActionUpdateEvent>
{
private readonly IGroupService _groupService;
public RequestDbHandler(IGroupService groupService)
{
_groupService = groupService;
}
public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context)
{
var @event = context.Message;
if(@event.Action == Models.GroupInviteState.Passed)
{
await _groupService.MakeGroupRequestAsync(@event.UserId, @event.InviteUserId,@event.GroupId);
}
}
}
}

View File

@ -0,0 +1,34 @@
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Hubs;
using IM_API.VOs.Group;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
namespace IM_API.Application.EventHandlers.GroupInviteActionUpdateHandler
{
public class SignalRHandler : IConsumer<GroupInviteActionUpdateEvent>
{
private IHubContext<ChatHub> _hub;
public SignalRHandler(IHubContext<ChatHub> hub)
{
_hub = hub;
}
public async Task Consume(ConsumeContext<GroupInviteActionUpdateEvent> context)
{
var @event = context.Message;
var msg = new HubResponse<GroupInviteActionUpdateVo>("Event", new GroupInviteActionUpdateVo
{
Action = @event.Action,
GroupId = @event.GroupId,
InvitedUserId = @event.UserId,
InviteUserId = @event.InviteUserId,
InviteId = @event.InviteId
});
await _hub.Clients.Users([@event.UserId.ToString(), @event.InviteUserId.ToString()])
.SendAsync("ReceiveMessage",msg);
}
}
}

View File

@ -0,0 +1,26 @@
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Hubs;
using IM_API.VOs.Group;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
namespace IM_API.Application.EventHandlers.GroupInviteHandler
{
public class GroupInviteSignalRHandler : IConsumer<GroupInviteEvent>
{
private readonly IHubContext<ChatHub> _hub;
public GroupInviteSignalRHandler(IHubContext<ChatHub> hub)
{
_hub = hub;
}
public async Task Consume(ConsumeContext<GroupInviteEvent> context)
{
var @event = context.Message;
var list = @event.Ids.Select(id => id.ToString()).ToArray();
var msg = new HubResponse<GroupInviteVo>("Event", new GroupInviteVo { GroupId = @event.GroupId, UserId = @event.UserId });
await _hub.Clients.Users(list).SendAsync("ReceiveMessage", msg);
}
}
}

View File

@ -0,0 +1,21 @@
using IM_API.Domain.Events;
using IM_API.Interface.Services;
using MassTransit;
namespace IM_API.Application.EventHandlers.GroupJoinHandler
{
public class GroupJoinConversationHandler : IConsumer<GroupJoinEvent>
{
private IConversationService _conversationService;
public GroupJoinConversationHandler(IConversationService conversationService)
{
_conversationService = conversationService;
}
public async Task Consume(ConsumeContext<GroupJoinEvent> context)
{
var @event = context.Message;
await _conversationService.MakeConversationAsync(@event.UserId, @event.GroupId, Models.ChatType.GROUP);
}
}
}

View File

@ -0,0 +1,22 @@
using IM_API.Domain.Events;
using IM_API.Interface.Services;
using MassTransit;
namespace IM_API.Application.EventHandlers.GroupJoinHandler
{
public class GroupJoinDbHandler : IConsumer<GroupJoinEvent>
{
private readonly IGroupService _groupService;
public GroupJoinDbHandler(IGroupService groupService)
{
_groupService = groupService;
}
public async Task Consume(ConsumeContext<GroupJoinEvent> context)
{
await _groupService.MakeGroupMemberAsync(context.Message.UserId,
context.Message.GroupId, context.Message.IsCreated ?
Models.GroupMemberRole.Master : Models.GroupMemberRole.Normal);
}
}
}

View File

@ -0,0 +1,45 @@
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Hubs;
using IM_API.Tools;
using IM_API.VOs.Group;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using StackExchange.Redis;
namespace IM_API.Application.EventHandlers.GroupJoinHandler
{
public class GroupJoinSignalrHandler : IConsumer<GroupJoinEvent>
{
private readonly IHubContext<ChatHub> _hub;
private readonly IDatabase _redis;
public GroupJoinSignalrHandler(IHubContext<ChatHub> hub, IConnectionMultiplexer connectionMultiplexer)
{
_hub = hub;
_redis = connectionMultiplexer.GetDatabase();
}
public async Task Consume(ConsumeContext<GroupJoinEvent> context)
{
var @event = context.Message;
string stramKey = StreamKeyBuilder.Group(@event.GroupId);
//将用户加入群组通知
var list = await _redis.SetMembersAsync(RedisKeys.GetConnectionIdKey(@event.UserId.ToString()));
if(list != null && list.Length > 0)
{
var tasks = list.Select(connectionId =>
_hub.Groups.AddToGroupAsync(connectionId!, stramKey)
).ToList();
await Task.WhenAll(tasks);
}
//发送通知给群成员
var msg = new GroupJoinVo
{
GroupId = @event.GroupId,
UserId = @event.UserId
};
await _hub.Clients.Group(stramKey).SendAsync("ReceiveMessage",new HubResponse<GroupJoinVo>("Event",msg));
}
}
}

View File

@ -1,13 +1,17 @@
using IM_API.Domain.Events;
using IM_API.Hubs;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
namespace IM_API.Application.EventHandlers.GroupRequestHandler
{
public class GroupRequestSignalRHandler : IConsumer<GroupRequestEvent>
public class GroupRequestSignalRHandler(IHubContext<ChatHub> hubContext) : IConsumer<GroupRequestEvent>
{
Task IConsumer<GroupRequestEvent>.Consume(ConsumeContext<GroupRequestEvent> context)
private readonly IHubContext<ChatHub> _hub = hubContext;
public async Task Consume(ConsumeContext<GroupRequestEvent> context)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,31 @@
using IM_API.Domain.Events;
using MassTransit;
namespace IM_API.Application.EventHandlers.GroupRequestHandler
{
public class NextEventHandler : IConsumer<GroupRequestEvent>
{
private readonly IPublishEndpoint _endpoint;
public NextEventHandler(IPublishEndpoint endpoint)
{
_endpoint = endpoint;
}
public async Task Consume(ConsumeContext<GroupRequestEvent> context)
{
var @event = context.Message;
if(@event.Action == Models.GroupRequestState.Passed)
{
await _endpoint.Publish(new GroupJoinEvent
{
AggregateId = @event.AggregateId,
OccurredAt = @event.OccurredAt,
EventId = Guid.NewGuid(),
GroupId = @event.GroupId,
OperatorId = @event.OperatorId,
UserId = @event.UserId
});
}
}
}
}

View File

@ -0,0 +1,31 @@
using IM_API.Domain.Events;
using MassTransit;
namespace IM_API.Application.EventHandlers.GroupRequestUpdateHandler
{
public class NextEventHandler : IConsumer<GroupRequestUpdateEvent>
{
private readonly IPublishEndpoint _endpoint;
public NextEventHandler(IPublishEndpoint endpoint)
{
_endpoint = endpoint;
}
public async Task Consume(ConsumeContext<GroupRequestUpdateEvent> context)
{
var @event = context.Message;
if(@event.Action == Models.GroupRequestState.Passed)
{
await _endpoint.Publish(new GroupJoinEvent
{
AggregateId = @event.AggregateId,
OccurredAt = @event.OccurredAt,
EventId = Guid.NewGuid(),
GroupId = @event.GroupId,
OperatorId = @event.OperatorId,
UserId = @event.UserId
});
}
}
}
}

View File

@ -0,0 +1,29 @@
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Hubs;
using IM_API.VOs.Group;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
namespace IM_API.Application.EventHandlers.GroupRequestUpdateHandler
{
public class RequestUpdateSignalrHandler : IConsumer<GroupRequestUpdateEvent>
{
private readonly IHubContext<ChatHub> _hub;
public RequestUpdateSignalrHandler(IHubContext<ChatHub> hub)
{
_hub = hub;
}
public async Task Consume(ConsumeContext<GroupRequestUpdateEvent> context)
{
var msg = new HubResponse<GroupRequestUpdateVo>("Event", new GroupRequestUpdateVo
{
GroupId = context.Message.GroupId,
RequestId = context.Message.RequestId,
UserId = context.Message.UserId
});
await _hub.Clients.User(context.Message.UserId.ToString()).SendAsync("ReceiveMessage", msg);
}
}
}

View File

@ -1,8 +1,14 @@
using AutoMapper;
using IM_API.Application.EventHandlers.FriendAddHandler;
using IM_API.Application.EventHandlers.GroupInviteActionUpdateHandler;
using IM_API.Application.EventHandlers.GroupInviteHandler;
using IM_API.Application.EventHandlers.GroupJoinHandler;
using IM_API.Application.EventHandlers.GroupRequestHandler;
using IM_API.Application.EventHandlers.GroupRequestUpdateHandler;
using IM_API.Application.EventHandlers.MessageCreatedHandler;
using IM_API.Application.EventHandlers.RequestFriendHandler;
using IM_API.Configs.Options;
using IM_API.Domain.Events;
using MassTransit;
using MySqlConnector;
@ -21,6 +27,16 @@ namespace IM_API.Configs
x.AddConsumer<RequestFriendSignalRHandler>();
x.AddConsumer<FriendAddConversationHandler>();
x.AddConsumer<MessageCreatedDbHandler>();
x.AddConsumer<GroupJoinConversationHandler>();
x.AddConsumer<GroupJoinDbHandler>();
x.AddConsumer<GroupJoinSignalrHandler>();
x.AddConsumer<GroupRequestSignalRHandler>();
x.AddConsumer<Application.EventHandlers.GroupRequestHandler.NextEventHandler>();
x.AddConsumer<Application.EventHandlers.GroupRequestUpdateHandler.NextEventHandler>();
x.AddConsumer<GroupInviteSignalRHandler>();
x.AddConsumer<RequestDbHandler>();
x.AddConsumer<SignalRHandler>();
x.AddConsumer<RequestUpdateSignalrHandler>();
x.UsingRabbitMq((ctx,cfg) =>
{
cfg.Host(options.Host, "/", h =>

View File

@ -32,12 +32,40 @@ namespace IM_API.Controllers
[HttpPost]
[ProducesResponseType(typeof(BaseResponse<GroupInfoDto>), StatusCodes.Status200OK)]
public async Task<ActionResult> CreateGroup([FromBody]GroupCreateDto groupCreateDto)
public async Task<IActionResult> CreateGroup([FromBody]GroupCreateDto groupCreateDto)
{
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
var groupInfo = await _groupService.CreateGroupAsync(int.Parse(userIdStr), groupCreateDto);
var res = new BaseResponse<GroupInfoDto>(groupInfo);
return Ok(res);
}
[HttpPost]
[ProducesResponseType(typeof(BaseResponse<object?>), StatusCodes.Status200OK)]
public async Task<IActionResult> HandleGroupInvite([FromBody]HandleGroupInviteDto dto)
{
string userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
await _groupService.HandleGroupInviteAsync(int.Parse(userIdStr), dto);
var res = new BaseResponse<object?>();
return Ok(res);
}
[HttpPost]
[ProducesResponseType(typeof(BaseResponse<object?>), StatusCodes.Status200OK)]
public async Task<IActionResult> HandleGroupRequest([FromBody]HandleGroupRequestDto dto)
{
string userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _groupService.HandleGroupRequestAsync(int.Parse(userIdStr),dto);
var res = new BaseResponse<object?>();
return Ok(res);
}
[HttpPost]
[ProducesResponseType(typeof(BaseResponse<object?>), StatusCodes.Status200OK)]
public async Task<IActionResult> InviteUser([FromBody]GroupInviteUserDto dto)
{
string userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _groupService.InviteUsersAsync(int.Parse(userIdStr), dto.GroupId, dto.Ids);
var res = new BaseResponse<object?>();
return Ok(res);
}
}
}

View File

@ -0,0 +1,14 @@
using IM_API.Models;
namespace IM_API.Domain.Events
{
public record GroupInviteActionUpdateEvent : DomainEvent
{
public override string EventType => "IM.GROUPS_INVITE_UPDATE";
public int UserId { get; set; }
public int InviteUserId { get; set; }
public int InviteId { get; set; }
public int GroupId { get; set; }
public GroupInviteState Action { get; set; }
}
}

View File

@ -2,7 +2,7 @@
{
public record GroupInviteEvent : DomainEvent
{
public override string EventType => "IM.GROUPS_GROUP_INVITE";
public override string EventType => "IM.GROUPS_INVITE_ADD";
public required List<int> Ids { get; init; }
public int GroupId { get; init; }
public int UserId { get; init; }

View File

@ -0,0 +1,10 @@
namespace IM_API.Domain.Events
{
public record GroupJoinEvent : DomainEvent
{
public override string EventType => "IM.GROUPS_MEMBER_ADD";
public int UserId { get; set; }
public int GroupId { get; set; }
public bool IsCreated { get; set; } = false;
}
}

View File

@ -1,4 +1,5 @@
using IM_API.Dtos.Group;
using IM_API.Models;
namespace IM_API.Domain.Events
{
@ -8,6 +9,7 @@ namespace IM_API.Domain.Events
public int GroupId { get; init; }
public int UserId { get; set; }
public string Description { get; set; }
public GroupRequestState Action { get; set; }
}
}

View File

@ -0,0 +1,14 @@
using IM_API.Models;
namespace IM_API.Domain.Events
{
public record GroupRequestUpdateEvent : DomainEvent
{
public override string EventType => "IM.GROUPS_REQUEST_UPDATE";
public int UserId { get; set; }
public int GroupId { get; set; }
public int AdminUserId { get; set; }
public int RequestId { get; set; }
public GroupRequestState Action { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace IM_API.Dtos.Group
{
public class GroupInviteUserDto
{
public int GroupId { get; set; }
public List<int> Ids { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using IM_API.Models;
namespace IM_API.Dtos.Group
{
public class HandleGroupInviteDto
{
public int InviteId { get; set; }
public GroupInviteState Action { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using IM_API.Models;
namespace IM_API.Dtos.Group
{
public class HandleGroupRequestDto
{
public int RequestId { get; set; }
public GroupRequestState Action { get; set; }
}
}

View File

@ -7,6 +7,7 @@ using IM_API.Models;
using IM_API.Tools;
using IM_API.VOs.Message;
using Microsoft.AspNetCore.SignalR;
using StackExchange.Redis;
using System.Security.Claims;
namespace IM_API.Hubs
@ -15,14 +16,13 @@ namespace IM_API.Hubs
{
private IMessageSevice _messageService;
private readonly IConversationService _conversationService;
private readonly IEventBus _eventBus;
private readonly IMapper _mapper;
public ChatHub(IMessageSevice messageService, IConversationService conversationService, IEventBus eventBus, IMapper mapper)
private readonly IDatabase _redis;
public ChatHub(IMessageSevice messageService, IConversationService conversationService,
IConnectionMultiplexer connectionMultiplexer)
{
_messageService = messageService;
_conversationService = conversationService;
_eventBus = eventBus;
_mapper = mapper;
_redis = connectionMultiplexer.GetDatabase();
}
public async override Task OnConnectedAsync()
@ -32,6 +32,7 @@ namespace IM_API.Hubs
Context.Abort();
return;
}
//将用户加入已加入聊天的会话组
string userIdStr = Context.User.FindFirstValue(ClaimTypes.NameIdentifier);
var keys = await _conversationService.GetUserAllStreamKeyAsync(int.Parse(userIdStr));
@ -39,8 +40,20 @@ namespace IM_API.Hubs
{
await Groups.AddToGroupAsync(Context.ConnectionId, key);
}
//储存用户对应的连接id
await _redis.SetAddAsync(RedisKeys.GetConnectionIdKey(userIdStr), Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (!Context.User.Identity.IsAuthenticated)
{
string useridStr = Context.User.FindFirstValue(ClaimTypes.NameIdentifier);
await _redis.SetRemoveAsync(RedisKeys.GetConnectionIdKey(useridStr), Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception);
}
public async Task<HubResponse<MessageBaseVo?>> SendMessage(MessageBaseDto dto)
{
if (!Context.User.Identity.IsAuthenticated)

View File

@ -33,8 +33,4 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Application\EventHandlers\GroupInviteHandler\" />
</ItemGroup>
</Project>

View File

@ -1,4 +1,5 @@
using IM_API.Dtos.Group;
using IM_API.Models;
namespace IM_API.Interface.Services
{
@ -43,6 +44,12 @@ namespace IM_API.Interface.Services
/// <param name="desc"></param>
/// <returns></returns>
Task<List<GroupInfoDto>> GetGroupListAsync(int userId, int page, int limit, bool desc);
Task UpdateGroupConversationAsync(GroupUpdateConversationDto dto);
Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto);
Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto);
Task MakeGroupRequestAsync(int userId,int? adminUserId,int groupId);
Task MakeGroupMemberAsync(int userId, int groupId, GroupMemberRole? role);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class updategroupgroupiduserid : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "group_request_ibfk_2",
table: "group_request");
migrationBuilder.CreateIndex(
name: "IX_group_request_UserId",
table: "group_request",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "group_request_ibfk_2",
table: "group_request",
column: "UserId",
principalTable: "users",
principalColumn: "ID");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "group_request_ibfk_2",
table: "group_request");
migrationBuilder.DropIndex(
name: "IX_group_request_UserId",
table: "group_request");
migrationBuilder.AddForeignKey(
name: "group_request_ibfk_2",
table: "group_request",
column: "GroupId",
principalTable: "users",
principalColumn: "ID");
}
}
}

View File

@ -513,6 +513,8 @@ namespace IM_API.Migrations
b.HasKey("Id")
.HasName("PRIMARY");
b.HasIndex("UserId");
b.HasIndex(new[] { "GroupId" }, "GroupId")
.HasDatabaseName("GroupId1");
@ -985,15 +987,15 @@ namespace IM_API.Migrations
.IsRequired()
.HasConstraintName("group_request_ibfk_1");
b.HasOne("IM_API.Models.User", "GroupNavigation")
b.HasOne("IM_API.Models.User", "User")
.WithMany("GroupRequests")
.HasForeignKey("GroupId")
.HasForeignKey("UserId")
.IsRequired()
.HasConstraintName("group_request_ibfk_2");
b.Navigation("Group");
b.Navigation("GroupNavigation");
b.Navigation("User");
});
modelBuilder.Entity("IM_API.Models.LoginLog", b =>

View File

@ -37,5 +37,5 @@ public partial class GroupRequest
public virtual Group Group { get; set; } = null!;
public virtual User GroupNavigation { get; set; } = null!;
public virtual User User { get; set; } = null!;
}

View File

@ -475,8 +475,8 @@ public partial class ImContext : DbContext
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("group_request_ibfk_1");
entity.HasOne(d => d.GroupNavigation).WithMany(p => p.GroupRequests)
.HasForeignKey(d => d.GroupId)
entity.HasOne(d => d.User).WithMany(p => p.GroupRequests)
.HasForeignKey(d => d.UserId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("group_request_ibfk_2");
});

View File

@ -18,31 +18,25 @@ namespace IM_API.Services
private readonly IMapper _mapper;
private readonly ILogger<GroupService> _logger;
private readonly IPublishEndpoint _endPoint;
public GroupService(ImContext context, IMapper mapper, ILogger<GroupService> logger, IPublishEndpoint publishEndpoint)
private readonly IUserService _userService;
public GroupService(ImContext context, IMapper mapper, ILogger<GroupService> logger,
IPublishEndpoint publishEndpoint, IUserService userService)
{
_context = context;
_mapper = mapper;
_logger = logger;
_endPoint = publishEndpoint;
_userService = userService;
}
private async Task<List<GroupInvite>> GetGroupInvites(int userId, int groupId, List<int> ids)
private async Task<List<int>> validFriendshipAsync (int userId, List<int> ids)
{
DateTime dateTime = DateTime.Now;
DateTime dateTime = DateTime.UtcNow;
//验证被邀请用户是否为好友
var validFriendIds = await _context.Friends
return await _context.Friends
.Where(f => f.UserId == userId && ids.Contains(f.FriendId))
.Select(f => f.FriendId)
.ToListAsync();
//创建群成员对象
return validFriendIds.Select(fid => new GroupInvite
{
Created = dateTime,
GroupId = groupId,
InvitedUser = fid,
StateEnum = GroupInviteState.Pending,
InviteUser = userId
}).ToList();
}
public async Task<GroupInfoDto> CreateGroupAsync(int userId, GroupCreateDto groupCreateDto)
@ -57,31 +51,21 @@ namespace IM_API.Services
group.GroupMaster = userId;
_context.Groups.Add(group);
await _context.SaveChangesAsync();
var groupInvites = new List<GroupInvite>();
if (userIds.Count > 0)
{
groupInvites = await GetGroupInvites(userId,group.Id, userIds);
_context.GroupInvites.AddRange(groupInvites);
//邀请好友
await InviteUsersAsync(userId, group.Id ,userIds);
}
var groupMember = new GroupMember
{
UserId = userId,
Created = dateTime,
RoleEnum = GroupMemberRole.Master,
GroupId = group.Id
};
_context.GroupMembers.Add(groupMember);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
await _endPoint.Publish(new GroupInviteEvent
await _endPoint.Publish(new GroupJoinEvent
{
EventId = Guid.NewGuid(),
AggregateId = userId.ToString(),
GroupId = group.Id,
EventId = Guid.NewGuid(),
OccurredAt = dateTime,
Ids = groupInvites.Select(x => x.Id).ToList(),
OperatorId = userId,
UserId = userId
UserId = userId,
IsCreated = true
});
return _mapper.Map<GroupInfoDto>(group);
}
@ -101,7 +85,43 @@ namespace IM_API.Services
{
var group = await _context.Groups.FirstOrDefaultAsync(
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
//过滤非好友
var groupInviteIds = await validFriendshipAsync(userId, userIds);
var inviteList = groupInviteIds.Select(id => new GroupInvite
{
Created = DateTime.UtcNow,
GroupId = group.Id,
InviteUser = userId,
InvitedUser = id,
StateEnum = GroupInviteState.Pending
}).ToList();
_context.GroupInvites.AddRange(inviteList);
await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupInviteEvent
{
GroupId = groupId,
AggregateId = userId.ToString(),
OccurredAt = DateTime.UtcNow,
EventId = Guid.NewGuid(),
Ids = userIds,
OperatorId = userId,
UserId = userId
});
}
public async Task MakeGroupMemberAsync(int userId, int groupId ,GroupMemberRole? role)
{
var isExist = await _context.GroupMembers.AnyAsync(x => x.GroupId == groupId && x.UserId == userId);
if (isExist) return;
var groupMember = new GroupMember
{
UserId = userId,
Created = DateTime.UtcNow,
RoleEnum = role ?? GroupMemberRole.Normal,
GroupId = groupId
};
_context.GroupMembers.Add(groupMember);
await _context.SaveChangesAsync();
}
public Task JoinGroupAsync(int userId, int groupId)
@ -136,5 +156,96 @@ namespace IM_API.Services
_context.Groups.Update(group);
await _context.SaveChangesAsync();
}
public async Task HandleGroupInviteAsync(int userid, HandleGroupInviteDto dto)
{
var user = _userService.GetUserInfoAsync(userid);
var inviteInfo = await _context.GroupInvites.FirstOrDefaultAsync(x => x.Id == dto.InviteId)
?? throw new BaseException(CodeDefine.INVALID_ACTION);
if (inviteInfo.InvitedUser != userid) throw new BaseException(CodeDefine.AUTH_FAILED);
inviteInfo.StateEnum = dto.Action;
_context.GroupInvites.Update(inviteInfo);
await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupInviteActionUpdateEvent
{
Action = dto.Action,
AggregateId = userid.ToString(),
OccurredAt = DateTime.UtcNow,
EventId = Guid.NewGuid(),
GroupId = inviteInfo.GroupId,
InviteId = inviteInfo.Id,
InviteUserId = inviteInfo.InviteUser.Value,
OperatorId = userid,
UserId = userid
});
}
public async Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto)
{
var user = _userService.GetUserInfoAsync(userid);
//判断请求存在
var requestInfo = await _context.GroupRequests.FirstOrDefaultAsync(x => x.Id == dto.RequestId)
?? throw new BaseException(CodeDefine.INVALID_ACTION);
//判断成员存在
var memberInfo = await _context.GroupMembers.FirstOrDefaultAsync(x => x.UserId == userid)
?? throw new BaseException(CodeDefine.NO_GROUP_PERMISSION);
//判断成员权限
if (memberInfo.RoleEnum != GroupMemberRole.Master && memberInfo.RoleEnum != GroupMemberRole.Administrator)
throw new BaseException(CodeDefine.NO_GROUP_PERMISSION);
requestInfo.StateEnum = dto.Action;
_context.GroupRequests.Update(requestInfo);
await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupRequestUpdateEvent
{
Action = requestInfo.StateEnum,
AdminUserId = userid,
AggregateId = userid.ToString(),
OccurredAt = DateTime.UtcNow,
EventId = Guid.NewGuid(),
GroupId = requestInfo.GroupId,
OperatorId = userid,
UserId = requestInfo.UserId,
RequestId = requestInfo.Id
});
}
public async Task MakeGroupRequestAsync(int userId, int? adminUserId, int groupId)
{
var requestInfo = await _context.GroupRequests
.FirstOrDefaultAsync(x => x.UserId == userId && x.GroupId == groupId);
if (requestInfo != null) return;
var member = await _context.GroupMembers.FirstOrDefaultAsync(
x => x.UserId == adminUserId && x.GroupId == groupId);
var request = new GroupRequest
{
Created = DateTime.UtcNow,
Description = string.Empty,
GroupId = groupId,
UserId = userId,
StateEnum = GroupRequestState.Pending
};
if(member != null && (
member.RoleEnum == GroupMemberRole.Administrator || member.RoleEnum == GroupMemberRole.Master))
{
request.StateEnum = GroupRequestState.Passed;
}
_context.GroupRequests.Add(request);
await _context.SaveChangesAsync();
await _endPoint.Publish(new GroupRequestEvent
{
OccurredAt = DateTime.UtcNow,
Description = request.Description,
GroupId = request.GroupId,
Action = request.StateEnum,
UserId = userId,
AggregateId = userId.ToString(),
EventId = Guid.NewGuid(),
OperatorId = userId
});
return;
}
}
}

View File

@ -2,21 +2,10 @@
{
public static class RedisKeys
{
public static string GetUserinfoKey(string userId)
{
return $"user::uinfo::{userId}";
}
public static string GetUserinfoKeyByUsername(string username)
{
return $"user::uinfobyid::{username}";
}
public static string GetSequenceIdKey(string streamKey)
{
return $"chat::seq::{streamKey}";
}
public static string GetSequenceIdLockKey(string streamKey)
{
return $"lock::seq::{streamKey}";
}
public static string GetUserinfoKey(string userId) => $"user::uinfo::{userId}";
public static string GetUserinfoKeyByUsername(string username) => $"user::uinfobyid::{username}";
public static string GetSequenceIdKey(string streamKey) => $"chat::seq::{streamKey}";
public static string GetSequenceIdLockKey(string streamKey) => $"lock::seq::{streamKey}";
public static string GetConnectionIdKey(string userId) => $"signalr::user::con::{userId}";
}
}

View File

@ -0,0 +1,13 @@
using IM_API.Models;
namespace IM_API.VOs.Group
{
public class GroupInviteActionUpdateVo
{
public int GroupId { get; set; }
public int InviteUserId { get; set; }
public int InvitedUserId { get; set; }
public int InviteId { get; set; }
public GroupInviteState Action { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace IM_API.VOs.Group
{
public class GroupInviteVo
{
public int GroupId { get; set; }
public int UserId { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace IM_API.VOs.Group
{
public class GroupJoinVo
{
public int UserId { get; set; }
public int GroupId { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace IM_API.VOs.Group
{
public class GroupRequestUpdateVo
{
public int RequestId { get; set; }
public int GroupId { get; set; }
public int UserId { get; set; }
}
}

View File

@ -20,8 +20,7 @@ onMounted(async () => {
}
})
</script>
<style scoped>
<style>
#app {
width: 100vw;
height: 100vh;
@ -29,4 +28,8 @@ onMounted(async () => {
padding: 0;
overflow: hidden;
}
body {
overflow: hidden;
}
</style>

View File

@ -0,0 +1,98 @@
<template>
<teleport to="body">
<div
v-if="visible"
class="context-menu"
:style="{ top: style.top, left: style.left }"
@click="hide"
>
<div
v-for="item in menuItems"
:key="item.label"
class="menu-item"
:class="{ danger: item.type === 'danger' }"
@click="item.action"
>
<span class="icon">{{ item.icon }}</span>
{{ item.label }}
</div>
</div>
</teleport>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
const visible = ref(false);
const menuItems = ref([]);
const style = reactive({
top: '0px',
left: '0px'
});
// e
const show = (e, items) => {
e.preventDefault(); //
menuItems.value = items;
// 使 client
style.top = `${e.clientY}px`;
style.left = `${e.clientX}px`;
visible.value = true;
};
const hide = () => {
visible.value = false;
};
//
onMounted(() => {
window.addEventListener('click', hide);
window.addEventListener('contextmenu', hide); //
});
onUnmounted(() => {
window.removeEventListener('click', hide);
window.removeEventListener('contextmenu', hide);
});
defineExpose({ show, hide });
</script>
<style scoped>
.context-menu {
position: fixed;
z-index: 10000;
min-width: 140px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
border: 1px solid #eee;
padding: 5px 0;
font-family: sans-serif;
}
.menu-item {
padding: 8px 16px;
font-size: 13px;
color: #333;
cursor: pointer;
display: flex;
align-items: center;
transition: background 0.2s;
}
.menu-item:hover {
background-color: #f5f5f5;
}
.menu-item.danger {
color: #ff4d4f;
}
.menu-item .icon {
margin-right: 8px;
font-size: 14px;
}
</style>

View File

@ -1,14 +1,16 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useContactStore } from '@/stores/contact';
import { groupService } from '@/services/group';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import { useMessage } from '../messages/useAlert';
const contactStore = useContactStore();
const message = useMessage();
const props = defineProps({ modelValue: Boolean });
const emit = defineEmits(['update:modelValue', 'create']);
const friends = ref([
{ id: 1, name: '张三', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=1' },
{ id: 2, name: '李四', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=2' },
{ id: 3, name: '王五', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=3' },
]);
const friends = ref([])
const groupName = ref('');
const selected = ref(new Set()); // 使 Set
@ -17,10 +19,23 @@ const toggle = (id) => {
selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id);
};
const submit = () => {
emit('create', { name: groupName.value, members: Array.from(selected.value) });
emit('update:modelValue', false);
const submit = async () => {
const res = await groupService.createGroup({
name: groupName.value,
avatar: "https://baidu.com",
userIDs: [...selected.value]
});
if(res.code == SYSTEM_BASE_STATUS.SUCCESS){
message.show('群聊创建成功。');
}else{
message.error(res.message);
}
};
onMounted(async () =>{
friends.value = contactStore.contacts;
})
</script>
<template>
@ -36,10 +51,10 @@ const submit = () => {
<input v-model="groupName" placeholder="群组名称..." class="mini-input" />
<div class="list">
<div v-for="f in friends" :key="f.id" @click="toggle(f.id)" class="item">
<img :src="f.avatar" class="avatar" />
<span class="name">{{ f.name }}</span>
<input type="checkbox" :checked="selected.has(f.id)" />
<div v-for="f in friends" :key="f.friendId" @click="toggle(f.friendId)" class="item">
<img :src="f.userInfo.avatar" class="avatar" />
<span class="name">{{ f.remarkName }}</span>
<input type="checkbox" :checked="selected.has(f.friendId)" />
</div>
</div>
</main>

View File

@ -30,7 +30,7 @@ const routes = [
{
path: '/messages/chat/:id',
name: '/msgChat',
component: () => import('@/views/messages/MessageContent.vue'),
component: () => import('@/views/messages/messageContent/MessageContent.vue'),
props: true
}
]

View File

@ -0,0 +1,10 @@
import { request } from "./api"
export const groupService = {
/**
* 创建群聊
* @param {*} data
* @returns
*/
createGroup: (data) => request.post('/Group/CreateGroup', data)
}

View File

@ -49,9 +49,9 @@ function handleStartChat(contact) {
/* 1. 基础容器:锁定宽高,禁止抖动 */
.im-container {
display: flex;
width: 1000px;
height: 650px;
margin: 40px auto;
width: 100%;
height: 100vh;
margin: 0 auto;
background: #fff;
border-radius: 4px;
overflow: hidden;

View File

@ -3,20 +3,21 @@
<header class="chat-header">
<span class="title">{{ conversationInfo?.targetName || '未选择会话' }}</span>
<div class="actions">
<button @click="startCall('video')" v-html="feather.icons['video'].toSvg({width:16, height: 16})"></button>
<button @click="startCall('voice')" v-html="feather.icons['phone'].toSvg({width:16, height: 16})"></button>
<button class="tool-btn" @click="startCall('video')" v-html="feather.icons['video'].toSvg({width:20, height: 20})"></button>
<button class="tool-btn" @click="startCall('voice')" v-html="feather.icons['phone-call'].toSvg({width:20, height: 20})"></button>
</div>
</header>
<div class="chat-history" ref="historyRef">
<HistoryLoading ref="loadingRef" :loading="isLoading" :finished="isFinished" :error="hasError" @retry="loadHistoryMsg"/>
<UserHoverCard ref="userHoverCardRef"/>
<ContextMenu ref="menuRef"/>
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
<img @mouseenter="(e) => handleHoverCard(e,m)" @mouseleave="closeHoverCard" :src="m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) : m.senderAvatar ?? defaultAvatar" class="avatar-chat" />
<div class="msg-content">
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{ m.senderName }}</div>
<div class="bubble">
<div class="bubble" @contextmenu.prevent="(e) => handleRightClick(e, m)">
<div v-if="m.type === 'Text'">{{ m.content }}</div>
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
<div class="status" v-if="m.senderId == myInfo.id">
@ -69,6 +70,7 @@ import HistoryLoading from '@/components/messages/HistoryLoading.vue';
import { useMessage } from '@/components/messages/useAlert';
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
import UserHoverCard from '@/components/user/UserHoverCard.vue';
import ContextMenu from '@/components/ContextMenu.vue';
const props = defineProps({
id:{
@ -85,6 +87,7 @@ const input = ref(''); // 输入框内容
const historyRef = ref(null); // DOM
const loadingRef = ref(null)
const userHoverCardRef = ref(null);
const menuRef = ref(null);
const myInfo = useAuthStore().userInfo;
const conversationInfo = ref(null)
@ -139,6 +142,38 @@ const closeHoverCard = () => {
userHoverCardRef.value.hide();
}
const handleRightClick = (e, m) => {
e.stopPropagation();
const items = [
{
label: '复制',
action: () => console.log('打开之前的悬浮卡片', user)
},
{
label: '转发',
action: () => console.log('进入私聊', user.id)
},
{
label: '多选',
action: () => {}
},
{
label: '翻译',
action: () => {}
},
{
label: '引用',
action: () => {}
},
{
label: '删除',
type: 'danger',
action: () => alert('删除成功')
}
];
menuRef.value.show(e, items);
}
watch(
() => chatStore.messages,
async (newVal) => {
@ -366,9 +401,11 @@ onUnmounted(() => {
}
.tool-btn {
border: 0;
background-color: white;
border: none;
background: none;
}
/* 历史区域:自动撑开并处理滚动 */
.chat-history {
flex: 1;

View File

@ -0,0 +1,21 @@
export function useRightClickHandler() {
const items = [
{
label: '查看资料',
action: () => console.log('打开之前的悬浮卡片', user)
},
{
label: '发送消息',
action: () => console.log('进入私聊', user.id)
},
{
label: '修改备注',
action: () => { }
},
{
label: '删除好友',
type: 'danger',
action: () => alert('删除成功')
}
];
}