前端:

1、优化消息排序逻辑
2、新增加载历史消息
3、修复已知问题
后端:
1、优化消息排序逻辑
2、增加用户信息缓存机制
3、修改日期类型为DateTimeOffset改善时区信息丢失问题
3、修复了已知问题
数据库:
1、新增SequenceId字段用于消息排序
2、新增ClientMsgId字段用于客户端消息回执
This commit is contained in:
2026-02-07 22:37:56 +08:00
118 changed files with 10691 additions and 452 deletions
+6 -1
View File
@@ -14,15 +14,19 @@ namespace IM_API.Services
private readonly ImContext _context;
private readonly ILogger<AuthService> _logger;
private readonly IMapper _mapper;
public AuthService(ImContext context, ILogger<AuthService> logger, IMapper mapper)
private readonly ICacheService _cache;
public AuthService(ImContext context, ILogger<AuthService> logger, IMapper mapper, ICacheService cache)
{
_context = context;
_logger = logger;
_mapper = mapper;
_cache = cache;
}
public async Task<User> LoginAsync(LoginRequestDto dto)
{
var userinfo = await _cache.GetUserCacheAsync(dto.Username);
if (userinfo != null && userinfo.Password == dto.Password) return userinfo;
string username = dto.Username;
string password = dto.Password;
var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == username && x.Password == password);
@@ -30,6 +34,7 @@ namespace IM_API.Services
{
throw new BaseException(CodeDefine.PASSWORD_ERROR);
}
await _cache.SetUserCacheAsync(user);
return user;
}
+40 -21
View File
@@ -4,6 +4,7 @@ using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Tools;
using IM_API.VOs.Conversation;
using Microsoft.EntityFrameworkCore;
namespace IM_API.Services
@@ -27,26 +28,26 @@ namespace IM_API.Services
#endregion
#region
public async Task<List<ConversationDto>> GetConversationsAsync(int userId)
public async Task<List<ConversationVo>> GetConversationsAsync(int userId)
{
// 1. 获取私聊会话
var privateList = await (from c in _context.Conversations
join f in _context.Friends on new { c.UserId, c.TargetId }
equals new { UserId = f.UserId, TargetId = f.FriendId }
where c.UserId == userId && c.ChatType == (int)ChatType.PRIVATE
where c.UserId == userId && c.ChatType == ChatType.PRIVATE
select new { c, f.Avatar, f.RemarkName })
.ToListAsync();
// 2. 获取群聊会话
var groupList = await (from c in _context.Conversations
join g in _context.Groups on c.TargetId equals g.Id
where c.UserId == userId && c.ChatType == (int)ChatType.GROUP
where c.UserId == userId && c.ChatType == ChatType.GROUP
select new { c, g.Avatar, g.Name })
.ToListAsync();
var privateDtos = privateList.Select(x =>
{
var dto = _mapper.Map<ConversationDto>(x.c);
var dto = _mapper.Map<ConversationVo>(x.c);
dto.TargetAvatar = x.Avatar;
dto.TargetName = x.RemarkName;
return dto;
@@ -54,7 +55,7 @@ namespace IM_API.Services
var groupDtos = groupList.Select(x =>
{
var dto = _mapper.Map<ConversationDto>(x.c);
var dto = _mapper.Map<ConversationVo>(x.c);
dto.TargetAvatar = x.Avatar;
dto.TargetName = x.Name;
return dto;
@@ -89,25 +90,23 @@ namespace IM_API.Services
#endregion
#region
public async Task<ConversationDto> GetConversationByIdAsync(int userId, int conversationId)
public async Task<ConversationVo> GetConversationByIdAsync(int userId, int conversationId)
{
var conversation = await _context.Conversations
.Include(x => x.LastReadMessage)
.FirstOrDefaultAsync(
x => x.UserId == userId && x.Id == conversationId
);
if (conversation is null) throw new BaseException(CodeDefine.CONVERSATION_NOT_FOUND);
var dto = _mapper.Map<ConversationDto>(conversation);
//dto.LastReadMessage = _mapper.Map<MessageBaseDto>(conversation);
if(conversation.ChatType == (int)ChatType.PRIVATE)
var dto = _mapper.Map<ConversationVo>(conversation);
if(conversation.ChatType == ChatType.PRIVATE)
{
var friendInfo = await _context.Friends.FirstOrDefaultAsync(
x => x.UserId == userId && x.FriendId == conversation.TargetId
var friendInfo = await _context.Friends.Include(n => n.FriendNavigation).FirstOrDefaultAsync(
x => x.UserId == conversation.UserId && x.FriendId == conversation.TargetId
);
if (friendInfo is null) throw new BaseException(CodeDefine.FRIEND_RELATION_NOT_FOUND);
_mapper.Map(friendInfo,dto);
}
if(conversation.ChatType == (int)ChatType.GROUP)
if(conversation.ChatType == ChatType.GROUP)
{
var groupInfo = await _context.Groups.FirstOrDefaultAsync(
x => x.Id == conversation.TargetId
@@ -125,12 +124,18 @@ namespace IM_API.Services
if (conversation is null) throw new BaseException(CodeDefine.CONVERSATION_NOT_FOUND);
var message = await _context.Messages
.Where(x => x.StreamKey == conversation.StreamKey)
.OrderByDescending(x => x.Id)
.OrderByDescending(x => x.SequenceId)
.FirstOrDefaultAsync();
conversation.LastReadMessage = message;
conversation.UnreadCount = 0;
_context.Conversations.Update(conversation);
await _context.SaveChangesAsync();
if(message != null)
{
conversation.UnreadCount = 0;
conversation.LastMessage = message.Content;
conversation.LastReadSequenceId = message.SequenceId;
conversation.LastMessageTime = message.Created;
_context.Conversations.Update(conversation);
await _context.SaveChangesAsync();
}
return true;
}
@@ -143,10 +148,10 @@ namespace IM_API.Services
StreamKeyBuilder.Private(userAId, userBId) : StreamKeyBuilder.Group(userBId);
var conversation = new Conversation()
{
ChatType = (int)chatType,
ChatType = chatType,
LastMessage = "",
LastMessageTime = DateTime.UtcNow,
LastReadMessageId = -1,
LastMessageTime = DateTime.Now,
LastReadSequenceId = null,
StreamKey = streamKey,
TargetId = userBId,
UnreadCount = 0,
@@ -156,5 +161,19 @@ namespace IM_API.Services
_context.Conversations.Add(conversation);
await _context.SaveChangesAsync();
}
public async Task UpdateConversationAfterSentAsync(UpdateConversationDto dto)
{
var cList = await _context.Conversations.Where(x => x.StreamKey == dto.StreamKey).ToListAsync();
foreach(var c in cList)
{
bool isSender = dto.SenderId == c.UserId;
c.LastMessage = dto.LastMessage;
c.LastMessageTime = dto.DateTime;
c.LastReadSequenceId = isSender ? dto.LastSequenceId : c.LastReadSequenceId;
c.UnreadCount = isSender ? 0 : c.UnreadCount + 1;
}
_context.Conversations.UpdateRange(cList);
await _context.SaveChangesAsync();
}
}
}
+4 -4
View File
@@ -132,8 +132,8 @@ namespace IM_API.Services
await _endpoint.Publish(new FriendAddEvent()
{
AggregateId = friendRequest.Id.ToString(),
OccurredAt = DateTime.UtcNow,
Created = DateTime.UtcNow,
OccurredAt = DateTime.Now,
Created = DateTime.Now,
EventId = Guid.NewGuid(),
OperatorId = friendRequest.ResponseUser,
RequestInfo = _mapper.Map<FriendRequestDto>(friendRequest),
@@ -183,7 +183,7 @@ namespace IM_API.Services
await _endpoint.Publish(new RequestFriendEvent()
{
AggregateId = friendRequst.Id.ToString(),
OccurredAt = friendRequst.Created,
OccurredAt = friendRequst.Created.UtcDateTime,
Description = friendRequst.Description,
EventId = Guid.NewGuid(),
FromUserId = friendRequst.RequestUser,
@@ -205,7 +205,7 @@ namespace IM_API.Services
Friend friendA = new Friend()
{
Avatar = userbInfo.Avatar,
Created = DateTime.UtcNow,
Created = DateTime.Now,
FriendId = userbInfo.Id,
RemarkName = remarkName ?? userbInfo.NickName,
StatusEnum = FriendStatus.Added,
+25 -6
View File
@@ -1,4 +1,5 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using IM_API.Domain.Events;
using IM_API.Dtos.Group;
using IM_API.Exceptions;
@@ -27,7 +28,7 @@ namespace IM_API.Services
private async Task<List<GroupInvite>> GetGroupInvites(int userId, int groupId, List<int> ids)
{
DateTime dateTime = DateTime.UtcNow;
DateTime dateTime = DateTime.Now;
//验证被邀请用户是否为好友
var validFriendIds = await _context.Friends
.Where(f => f.UserId == userId && ids.Contains(f.FriendId))
@@ -44,13 +45,14 @@ namespace IM_API.Services
}).ToList();
}
public async Task<GroupInfoDto> CreateGroup(int userId, GroupCreateDto groupCreateDto, List<int> userIds)
public async Task<GroupInfoDto> CreateGroupAsync(int userId, GroupCreateDto groupCreateDto)
{
List<int> userIds = groupCreateDto.UserIDs ?? [];
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
//先创建群
DateTime dateTime = DateTime.UtcNow;
DateTime dateTime = DateTime.Now;
Group group = _mapper.Map<Group>(groupCreateDto);
group.GroupMaster = userId;
_context.Groups.Add(group);
@@ -90,21 +92,38 @@ namespace IM_API.Services
}
}
public Task DeleteGroup(int userId, int groupId)
public Task DeleteGroupAsync(int userId, int groupId)
{
throw new NotImplementedException();
}
public async Task InviteUsers(int userId, int groupId, List<int> userIds)
public async Task InviteUsersAsync(int userId, int groupId, List<int> userIds)
{
var group = await _context.Groups.FirstOrDefaultAsync(
x => x.Id == groupId) ?? throw new BaseException(CodeDefine.GROUP_NOT_FOUND);
}
public Task JoinGroup(int userId, int groupId)
public Task JoinGroupAsync(int userId, int groupId)
{
throw new NotImplementedException();
}
public async Task<List<GroupInfoDto>> GetGroupListAsync(int userId, int page, int limit, bool desc)
{
var query = _context.GroupMembers
.Where(x => x.UserId == userId)
.Select(s => s.Group);
if (desc)
{
query = query.OrderByDescending(x => x.Id);
}
var list = await query
.Skip((page - 1) * limit)
.Take(limit)
.ProjectTo<GroupInfoDto>(_mapper.ConfigurationProvider)
.ToListAsync();
return list;
}
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ namespace IM_API.Services
public (string token, DateTime expiresAt) CreateAccessTokenForUser(int userId, string username, string role)
{
var expiresAt = DateTime.UtcNow.AddMinutes(_accessMinutes);
var expiresAt = DateTime.Now.AddMinutes(_accessMinutes);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
+49 -40
View File
@@ -2,12 +2,18 @@
using IM_API.Application.Interfaces;
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Dtos.Message;
using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Tools;
using IM_API.VOs.Message;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
using System.Text.RegularExpressions;
using static MassTransit.Monitoring.Performance.BuiltInCounters;
using static Microsoft.EntityFrameworkCore.DbLoggerCategory;
namespace IM_API.Services
{
@@ -19,9 +25,10 @@ namespace IM_API.Services
//废弃,此处已使用rabbitMQ替代
//private readonly IEventBus _eventBus;
private readonly IPublishEndpoint _endpoint;
private readonly ISequenceIdService _sequenceIdService;
public MessageService(
ImContext context, ILogger<MessageService> logger, IMapper mapper, IEventBus eventBus,
IPublishEndpoint publishEndpoint
ImContext context, ILogger<MessageService> logger, IMapper mapper,
IPublishEndpoint publishEndpoint, ISequenceIdService sequenceIdService
)
{
_context = context;
@@ -29,40 +36,43 @@ namespace IM_API.Services
_mapper = mapper;
//_eventBus = eventBus;
_endpoint = publishEndpoint;
_sequenceIdService = sequenceIdService;
}
public async Task<List<MessageBaseDto>> GetMessagesAsync(int userId, int conversationId, int? msgId, int? pageSize, bool desc)
public async Task<List<MessageBaseVo>> GetMessagesAsync(int userId,MessageQueryDto dto)
{
//获取会话信息,用于获取双方聊天的唯一标识streamkey
Conversation? conversation = await _context.Conversations.FirstOrDefaultAsync(
x => x.Id == conversationId && x.UserId == userId
x => x.Id == dto.ConversationId && x.UserId == userId
);
if (conversation is null) throw new BaseException(CodeDefine.CONVERSATION_NOT_FOUND);
var query = _context.Messages.AsQueryable();
if(msgId != null)
var baseQuery = _context.Messages.Where(x => x.StreamKey == conversation.StreamKey);
if (dto.Direction == 0) // Before: 找比锚点小的,按倒序排
{
query = query.Where(
x => x.StreamKey == conversation.StreamKey && x.Id < msgId.Value
)
.OrderByDescending(x => x.Id);
if (dto.Cursor.HasValue)
baseQuery = baseQuery.Where(m => m.SequenceId < dto.Cursor.Value);
var list = await baseQuery
.OrderByDescending(m => m.SequenceId) // 最新消息在最前
.Take(dto.Limit)
.Select(m => _mapper.Map<MessageBaseVo>(m))
.ToListAsync();
return list.OrderBy(s => s.SequenceId).ToList();
}
else
else // After: 找比锚点大的,按正序排(用于补洞或刷新)
{
query = query.Where(
x => x.StreamKey == conversation.StreamKey && x.Id > conversation.LastReadMessageId
);
// 如果 Cursor 为空且是 After,逻辑上说不通,通常直接返回空或报错
if (!dto.Cursor.HasValue) return new List<MessageBaseVo>();
return await baseQuery
.Where(m => m.SequenceId > dto.Cursor.Value)
.OrderBy(m => m.SequenceId) // 按时间线正序
.Take(dto.Limit)
.Select(m => _mapper.Map<MessageBaseVo>(m))
.ToListAsync();
}
if(pageSize != null)
{
query = query.Take(pageSize.Value);
}
var msgList = await query
.ToListAsync();
msgList = msgList
.OrderBy(x => x.Created)
.ThenBy(t => t.Id)
.ToList();
return _mapper.Map<List<MessageBaseDto>>(msgList);
}
public Task<int> GetUnreadCountAsync(int userId)
@@ -89,8 +99,14 @@ namespace IM_API.Services
{
throw new NotImplementedException();
}
public async Task MakeMessageAsync(Message message)
{
_context.Messages.Add(message);
await _context.SaveChangesAsync();
}
#region
public async Task<MessageBaseDto> SendGroupMessageAsync(int senderId, int groupId, MessageBaseDto dto)
public async Task<MessageBaseVo> SendGroupMessageAsync(int senderId, int groupId, MessageBaseDto dto)
{
//判断群存在
var isExist = await _context.Groups.AnyAsync(x => x.Id == groupId);
@@ -99,30 +115,23 @@ namespace IM_API.Services
var isMember = await _context.GroupMembers.AnyAsync(x => x.GroupId == groupId && x.UserId == senderId);
if (!isMember) throw new BaseException(CodeDefine.NO_GROUP_PERMISSION);
var message = _mapper.Map<Message>(dto);
message.Sender = senderId;
message.StreamKey = StreamKeyBuilder.Group(
groupId);
_context.Messages.Add(message);
await _context.SaveChangesAsync();
message.StreamKey = StreamKeyBuilder.Group(groupId);
message.SequenceId = await _sequenceIdService.GetNextSquenceIdAsync(message.StreamKey);
await _endpoint.Publish(_mapper.Map<MessageCreatedEvent>(message));
return _mapper.Map<MessageBaseDto>(message);
return _mapper.Map<MessageBaseVo>(message);
}
#endregion
#region
public async Task<MessageBaseDto> SendPrivateMessageAsync(int senderId, int receiverId, MessageBaseDto dto)
public async Task<MessageBaseVo> SendPrivateMessageAsync(int senderId, int receiverId, MessageBaseDto dto)
{
bool isExist = await _context.Friends.AnyAsync(x => x.FriendId == receiverId);
if (!isExist) throw new BaseException(CodeDefine.FRIEND_RELATION_NOT_FOUND);
var message = _mapper.Map<Message>(dto);
message.Sender = senderId;
//生成消息流唯一标识符
message.StreamKey = StreamKeyBuilder.Private(dto.SenderId, dto.ReceiverId);
_context.Messages.Add(message);
await _context.SaveChangesAsync();
message.StreamKey = StreamKeyBuilder.Private(senderId, receiverId);
message.SequenceId = await _sequenceIdService.GetNextSquenceIdAsync(message.StreamKey);
await _endpoint.Publish(_mapper.Map<MessageCreatedEvent>(message));
return _mapper.Map<MessageBaseDto>(message);
return _mapper.Map<MessageBaseVo>(message);
}
#endregion
}
@@ -0,0 +1,62 @@
using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Tools;
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
namespace IM_API.Services
{
public class RedisCacheService:ICacheService
{
private readonly IDistributedCache _cache;
public RedisCacheService(IDistributedCache cache)
{
_cache = cache;
}
public async Task<T?> GetAsync<T>(string key)
{
var valueBytes= await _cache.GetAsync(key);
if (valueBytes is null || valueBytes.Length == 0) return default;
return JsonSerializer.Deserialize<T>(valueBytes);
}
public async Task<User?> GetUserCacheAsync(string username)
{
var usernameKey = RedisKeys.GetUserinfoKeyByUsername(username);
var userid = await GetAsync<string>(usernameKey);
if (userid is null) return default;
var key = RedisKeys.GetUserinfoKey(userid);
return await GetAsync<User>(key);
}
public async Task RemoveAsync(string key) => await _cache.RemoveAsync(key);
public async Task RemoveUserCacheAsync(string username)
{
var usernameKey = RedisKeys.GetUserinfoKeyByUsername(username);
var userid = await GetAsync<string>(usernameKey);
if (userid is null) return;
var key = RedisKeys.GetUserinfoKey(userid);
await RemoveAsync(key);
}
public async Task SetAsync<T>(string key, T value, TimeSpan? expiration = null)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromHours(1)
};
var valueBytes = JsonSerializer.SerializeToUtf8Bytes(value);
await _cache.SetAsync(key, valueBytes, options);
}
public async Task SetUserCacheAsync(User user)
{
var idKey = RedisKeys.GetUserinfoKey(user.Id.ToString());
await SetAsync(idKey, user);
var usernameKey = RedisKeys.GetUserinfoKeyByUsername(user.Username);
await SetAsync(usernameKey, user.Id.ToString());
}
}
}
@@ -0,0 +1,46 @@
using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Tools;
using Microsoft.EntityFrameworkCore;
using RedLockNet;
using StackExchange.Redis;
namespace IM_API.Services
{
public class SequenceIdService : ISequenceIdService
{
private IDatabase _database;
private IDistributedLockFactory _lockFactory;
private ImContext _context;
public SequenceIdService(IConnectionMultiplexer connectionMultiplexer,
IDistributedLockFactory distributedLockFactory, ImContext imContext)
{
_database = connectionMultiplexer.GetDatabase();
_lockFactory = distributedLockFactory;
_context = imContext;
}
public async Task<long> GetNextSquenceIdAsync(string streamKey)
{
string key = RedisKeys.GetSequenceIdKey(streamKey);
string lockKey = RedisKeys.GetSequenceIdLockKey(streamKey);
var exists = await _database.KeyExistsAsync(key);
if (!exists)
{
using (var _lock = await _lockFactory.CreateLockAsync(lockKey, TimeSpan.FromSeconds(5)))
{
if (_lock.IsAcquired)
{
if(!await _database.KeyExistsAsync(key))
{
var max = await _context.Messages
.Where(x => x.StreamKey == streamKey)
.MaxAsync(m => (long?)m.SequenceId) ?? 0;
await _database.StringSetAsync(key, max, TimeSpan.FromDays(7));
}
}
}
}
return await _database.StringIncrementAsync(key);
}
}
}
+16 -2
View File
@@ -5,6 +5,7 @@ using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Tools;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
namespace IM_API.Services
{
@@ -13,31 +14,43 @@ namespace IM_API.Services
private readonly ImContext _context;
private readonly ILogger<UserService> _logger;
private readonly IMapper _mapper;
public UserService(ImContext imContext,ILogger<UserService> logger,IMapper mapper)
private readonly ICacheService _cacheService;
public UserService(ImContext imContext,ILogger<UserService> logger,IMapper mapper, ICacheService cacheService)
{
this._context = imContext;
this._logger = logger;
this._mapper = mapper;
_cacheService = cacheService;
}
#region
public async Task<UserInfoDto> GetUserInfoAsync(int userId)
{
var user = await _context.Users.FirstOrDefaultAsync(x => x.Id == userId);
//查询redis缓存,如果存在直接返回不走查库逻辑
var key = RedisKeys.GetUserinfoKey(userId.ToString());
var userinfoCache = await _cacheService.GetAsync<User>(key);
if (userinfoCache != null) return _mapper.Map<UserInfoDto>(userinfoCache);
//无缓存查库
var user = await _context.Users
.FirstOrDefaultAsync(x => x.Id == userId);
if (user == null)
{
throw new BaseException(CodeDefine.USER_NOT_FOUND);
}
await _cacheService.SetUserCacheAsync(user);
return _mapper.Map<UserInfoDto>(user);
}
#endregion
#region
public async Task<UserInfoDto> GetUserInfoByUsernameAsync(string username)
{
var userinfo = await _cacheService.GetUserCacheAsync(username);
if (userinfo != null) return _mapper.Map<UserInfoDto>(userinfo);
var user = await _context.Users.FirstOrDefaultAsync(x => x.Username == username);
if (user == null)
{
throw new BaseException(CodeDefine.USER_NOT_FOUND);
}
await _cacheService.SetUserCacheAsync(user);
return _mapper.Map<UserInfoDto>(user);
}
#endregion
@@ -70,6 +83,7 @@ namespace IM_API.Services
if (user is null) throw new BaseException(CodeDefine.USER_NOT_FOUND);
_mapper.Map(dto,user);
await _context.SaveChangesAsync();
await _cacheService.SetUserCacheAsync(user);
return _mapper.Map<UserInfoDto>(user);
}
#endregion