添加项目文件。

This commit is contained in:
2026-05-09 17:06:30 +08:00
parent c60f5fe117
commit 720ef957d4
378 changed files with 14843 additions and 0 deletions
@@ -0,0 +1,84 @@
using IM.DomainCommons;
using MessageService.Domain.Enums;
using MessageService.Domain.Events;
using MessageService.Domain.Tools;
namespace MessageService.Domain.Entities
{
public class Conversation : AggregateRootEntity
{
/// <summary>
/// 用户
/// </summary>
public Guid UserId { get; private set; }
/// <summary>
/// 对方ID(群聊为群聊ID,单聊为单聊ID)
/// </summary>
public Guid TargetId { get; private set; }
public string TargetAvatar { get; private set; }
public string TargetName { get; private set; }
/// <summary>
/// 最后一条未读消息ID
/// </summary>
public long? LastReadSequenceId { get; private set; }
/// <summary>
/// 未读消息数
/// </summary>
public int UnreadCount { get; private set; }
public ChatType ChatType { get; private set; }
/// <summary>
/// 消息推送唯一标识符
/// </summary>
public string StreamKey { get; private set; }
/// <summary>
/// 最后一条最新消息
/// </summary>
public string LastMessage { get; private set; }
private Conversation() { }
public Conversation(Guid userId, Guid targetId, string targetAvatar, string targetName, long? lastReadSequenceId, int unreadCount, ChatType chatType, string lastMessage)
{
UserId = userId;
TargetId = targetId;
TargetAvatar = targetAvatar;
TargetName = targetName;
LastReadSequenceId = lastReadSequenceId;
UnreadCount = unreadCount;
ChatType = chatType;
LastMessage = lastMessage;
StreamKey = ChatType == ChatType.GROUP ? StreamKeyBuilder.Group(targetId) : StreamKeyBuilder.Private(userId, targetId);
AddDomainEvent(new ConversationCreatedDomainEvent(this));
}
public void Update(long? LastReadSequenceId = default, int? unreadCount = default, string? lastMsg = default)
{
if (LastReadSequenceId != null)
{
LastReadSequenceId = LastReadSequenceId.Value;
}
if (unreadCount != null)
{
UnreadCount += unreadCount.Value;
}
if (lastMsg != null)
{
LastMessage = lastMsg;
}
}
public void UpdateProfile(string name, string avatar)
{
TargetAvatar = avatar;
TargetName = name;
}
}
}
+217
View File
@@ -0,0 +1,217 @@
using IM.Commons.IntegrationEvents;
using IM.DomainCommons;
using MessageService.Domain.Enums;
using MessageService.Domain.Events;
using MessageService.Domain.KeyObjects;
using MessageService.Domain.Tools;
using System.Text.Json;
namespace MessageService.Domain.Entities
{
public class Message : AggregateRootEntity
{
/// <summary>
/// 聊天类型
/// 0:私聊,1:群聊)
/// </summary>
public ChatType ChatType { get; private set; }
/// <summary>
/// 消息类型
/// (0:文本,1:图片,2:语音,3:视频,4:文件,5:语音聊天,6:视频聊天)
/// </summary>
public MessageType MsgType { get; private set; }
public Guid ClientMsgId { get; private set; }
/// <summary>
/// 消息内容
/// </summary>
public MessageContent Content { get; private set; } = null!;
/// <summary>
/// 发送者
/// </summary>
public Guid SenderId { get; private set; }
/// <summary>
/// 接收者(私聊为用户ID,群聊为群聊ID)
/// </summary>
public Guid TargetId { get; private set; }
/// <summary>
/// 消息状态(0:已发送,1:已撤回)
/// </summary>
public MessageState State { get; private set; } = MessageState.Sent;
/// <summary>
/// 消息推送唯一标识符
/// </summary>
public string StreamKey { get; private set; }
/// <summary>
/// 消息排序标识
/// </summary>
public long SequenceId { get; private set; }
private Message() { }
private Message(MessageCreateContext ctx, long sequenceId, MessageType messageType
, MessageContent content
)
{
ChatType = ctx.ChatType;
SenderId = ctx.SenderId;
ClientMsgId = ctx.ClientMsgId;
TargetId = ctx.TargetId;
SequenceId = sequenceId;
MsgType = messageType;
StreamKey = ChatType == ChatType.GROUP ? StreamKeyBuilder.Group(ctx.TargetId) : StreamKeyBuilder.Private(ctx.SenderId, ctx.TargetId);
Content = content;
}
/// <summary>
/// 文本消息
/// </summary>
/// <param name="ctx"></param>
/// <param name="text">文本内容</param>
/// <param name="sequenceId"></param>
/// <returns></returns>
public static Message BuildTxt(MessageCreateContext ctx, string text, long sequenceId)
{
var content = new MessageContent(text, new TextBody(text));
return new Message(ctx, sequenceId, MessageType.Text, content);
}
/// <summary>
/// 图片消息
/// </summary>
/// <param name="ctx"></param>
/// <param name="url">相对路径</param>
/// <param name="width">宽度</param>
/// <param name="height">高度</param>
/// <param name="thumb">预览图</param>
/// <param name="sequenceId"></param>
/// <returns></returns>
public static Message BuildImg(MessageCreateContext ctx, string url,
int width, int height, string thumb, long sequenceId)
{
var content = new MessageContent("[图片]", new ImageBody(url, width, height, thumb));
return new Message(ctx, sequenceId, MessageType.Image, content);
}
/// <summary>
/// 视频消息
/// </summary>
/// <param name="ctx"></param>
/// <param name="url"></param>
/// <param name="width"></param>
/// <param name="height"></param>
/// <param name="thumb"></param>
/// <param name="sequenceId"></param>
/// <returns></returns>
public static Message BuildVideo(MessageCreateContext ctx, string url,
int width, int height, string thumb, long sequenceId)
{
var content = new MessageContent("[视频]", new VideoBody(url, width, height, thumb));
return new Message(ctx, sequenceId, MessageType.Video, content);
}
/// <summary>
/// 语音消息
/// </summary>
/// <param name="ctx"></param>
/// <param name="url"></param>
/// <param name="duration">持续时间</param>
/// <param name="sequenceId"></param>
/// <returns></returns>
public static Message BuildVoice(MessageCreateContext ctx, string url,
int duration, long sequenceId)
{
var content = new MessageContent("[音频]", new VoiceBody(url, duration));
return new Message(ctx, sequenceId, MessageType.Voice, content);
}
/// <summary>
/// 文件
/// </summary>
/// <param name="ctx"></param>
/// <param name="url">相对路径</param>
/// <param name="name">文件名</param>
/// <param name="size">文件大小(KB</param>
/// <param name="format">文件格式</param>
/// <param name="sequenceId"></param>
/// <returns></returns>
public static Message BuildFile(MessageCreateContext ctx, string url,
string name, long size,
string format, long sequenceId)
{
var content = new MessageContent("[文件]", new FileBody(url, name, size, format));
return new Message(ctx, sequenceId, MessageType.File, content);
}
/// <summary>
/// 添加扩展字段
/// </summary>
/// <param name="key">字段名</param>
/// <param name="value">值</param>
/// <returns></returns>
public Message WithExt(string key, string value)
{
var dic = new Dictionary<string, string>();
dic.Add(key, value);
Content.RawExt = JsonSerializer.Serialize(dic);
return this;
}
/// <summary>
/// 添加消息引用
/// </summary>
/// <param name="quote"></param>
/// <returns></returns>
public Message WithQuote(QuoteInfo quote)
{
this.Content.Quote = quote;
return this;
}
/// <summary>
/// 完成消息
/// </summary>
public void Send()
{
AddDomainEvent(new MessageCreatedDomainEvent(this));
}
public void Withdraw()
{
if (State != MessageState.Sent)
{
throw new DomainException("消息不可撤回");
}
State = MessageState.Withdrwan;
AddDomainEvent(new MessageWithdrawDomainEvent(this));
}
public MsgCreatedEvent ToIntegrationEvent()
{
return new MsgCreatedEvent
{
Id = this.Id,
ClientId = this.ClientMsgId, // 假设你有存发送端的设备ID
ChatType = this.ChatType.ToString(), // 比如 "private" 或 "group"
MsgType = this.MsgType.ToString() ?? MessageType.Text.ToString(), // 动态获取类型名,或从 Content 里的定义拿
SenderId = this.SenderId,
TargetId = this.TargetId,
State = State.ToString(),
StreamKey = this.StreamKey,
SequenceId = this.SequenceId,
// 核心载荷:利用计算属性触发反序列化,打包成 DTO
Content = new MsgContent(
this.Content.Fallback,
this.Content.Body, // 触发实体内部的反序列化逻辑
this.Content.Ext,
this.Content.Quote != null ? new QuoteInfoDto(
this.Content.Quote.MessageId,
this.Content.Quote.SenderId,
this.Content.Quote.SenderName,
this.Content.Quote.MessageType.ToString(),
this.Content.Quote.Preview
) : null
)
};
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace MessageService.Domain.Enums
{
public enum ChatType
{
PRIVATE = 0,
GROUP = 1
}
}
@@ -0,0 +1,14 @@
namespace MessageService.Domain.Enums
{
public enum MessageState
{
/// <summary>
/// 已发送
/// </summary>
Sent = 0,
/// <summary>
/// 已撤回
/// </summary>
Withdrwan = 1
}
}
@@ -0,0 +1,13 @@
namespace MessageService.Domain.Enums
{
public enum MessageType
{
Text = 0,
Image = 1,
Voice = 2,
Video = 3,
File = 4,
VoiceChat = 5,
VideoChat = 6
}
}
@@ -0,0 +1,7 @@
using MediatR;
using MessageService.Domain.Entities;
namespace MessageService.Domain.Events
{
public record ConversationCreatedDomainEvent(Conversation Conversation) : INotification;
}
@@ -0,0 +1,7 @@
using MediatR;
using MessageService.Domain.Entities;
namespace MessageService.Domain.Events
{
public record MessageCreatedDomainEvent(Message Message) : INotification;
}
@@ -0,0 +1,7 @@
using MediatR;
using MessageService.Domain.Entities;
namespace MessageService.Domain.Events
{
public record MessageWithdrawDomainEvent(Message Message) : INotification;
}
@@ -0,0 +1,14 @@
using MessageService.Domain.Entities;
namespace MessageService.Domain.IReposities
{
public interface IConversationReposity
{
Task<Conversation?> FindByIdAsync(Guid id);
Task<IEnumerable<Conversation>> FindByUserIdAsync(Guid userId);
Task<IEnumerable<Conversation>> FindByTargetIdAsync(Guid targetId);
Task<IEnumerable<Conversation>> FindByStreamKeyAsync(string streamKey);
void Create(Conversation conversation);
Task<IEnumerable<string>> FindAllStreamKeyAsync(Guid userId);
}
}
@@ -0,0 +1,11 @@
using MessageService.Domain.Entities;
namespace MessageService.Domain.IReposities
{
public interface IMessageReposity
{
Task<Message?> FindByIdAsync(Guid id);
Task<(IEnumerable<Message> messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit);
void Create(Message message);
}
}
@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MessageService.Domain.KeyObjects
{
public class MessageContent
{
// 兜底文本
[JsonPropertyName("fallback")]
public string Fallback { get; init; }
// 核心 Payload,反序列化时根据外层 MessageType 转换为具体子类
public string RawBody { get; init; }
public string RawExt { get; set; } = string.Empty;
[JsonPropertyName("body")]
// 业务代码使用的对象
[NotMapped]
public object Body => string.IsNullOrEmpty(RawBody) ? null : JsonSerializer.Deserialize<object>(RawBody);
[NotMapped]
public Dictionary<string, string> Ext => string.IsNullOrEmpty(RawExt)
? new()
: JsonSerializer.Deserialize<Dictionary<string, string>>(RawExt);
// 引用信息
[JsonPropertyName("quote")]
public QuoteInfo Quote { get; set; } = new();
private MessageContent() { }
public MessageContent(string fallback, object body)
{
Fallback = fallback;
RawBody = body == null ? null : JsonSerializer.Serialize(body);
}
}
}
@@ -0,0 +1,11 @@
using MessageService.Domain.Enums;
namespace MessageService.Domain.KeyObjects
{
public record MessageCreateContext(
ChatType ChatType,
Guid ClientMsgId,
Guid SenderId,
Guid TargetId
);
}
@@ -0,0 +1,8 @@
namespace MessageService.Domain.KeyObjects
{
public record TextBody(string Text);
public record ImageBody(string Url, int Width, int Height, string Thumb);
public record VideoBody(string Url, int Width, int Height, string Thumb);
public record VoiceBody(string Url, int Duration);
public record FileBody(string Url, string FileName, long Size, string Format);
}
@@ -0,0 +1,59 @@
using IM.DomainCommons;
using MessageService.Domain.Enums;
namespace MessageService.Domain.KeyObjects
{
public class QuoteInfo
{
/// <summary>
/// 被引用消息的唯一标识
/// </summary>
public Guid MessageId { get; init; } = Guid.Empty;
/// <summary>
/// 被引用消息的发送者 ID
/// </summary>
public Guid SenderId { get; init; } = Guid.Empty;
/// <summary>
/// 快照:发送者当时的昵称 (非常关键)
/// 避免客户端为了显示 "回复 @张三" 而去额外查询一次用户信息
/// </summary>
public string SenderName { get; init; } = string.Empty;
/// <summary>
/// 被引用消息的类型 (例如:1=文本, 2=图片, 3=文件)
/// 帮助客户端决定如何渲染左侧的 Icon (比如是一段文字,还是一个小图片占位符)
/// </summary>
public MessageType MessageType { get; init; } = MessageType.Text;
/// <summary>
/// 被引用消息的内容预览
/// 如果原消息是文本,则截取前50个字符;如果是图片,可以是 "[图片]"
/// </summary>
public string Preview { get; init; } = string.Empty;
/// <summary>
/// 构造函数与自校验
/// </summary>
public QuoteInfo() { }
public QuoteInfo(Guid messageId, Guid senderId, string senderName, MessageType messageType, string preview)
{
if (messageId == Guid.Empty)
throw new DomainException("回复消息ID不可为空");
if (senderId == Guid.Empty)
throw new DomainException("回复发送者ID不可为空");
MessageId = messageId;
SenderId = senderId;
SenderName = string.IsNullOrWhiteSpace(senderName) ? "Unknown" : senderName;
MessageType = messageType;
// 限制预览文本的长度,防止 Payload 过大(截断处理)
Preview = string.IsNullOrWhiteSpace(preview)
? string.Empty
: preview.Length > 50 ? preview[..47] + "..." : preview;
}
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\IM.Commons\IM.Commons.csproj" />
<ProjectReference Include="..\DomainCommons\IM.DomainCommons.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
namespace MessageService.Domain.Tools
{
public static class StreamKeyBuilder
{
public static string Private(Guid a, Guid b)
{
// Guid 类型使用 Guid.Empty 来判断是否为无效/初始化的 ID
if (a == Guid.Empty || b == Guid.Empty)
throw new ArgumentException("IDs cannot be empty.");
// Guid 实现了 IComparable,使用 CompareTo 来保持生成 Key 的顺序一致性
return $"p:{(a.CompareTo(b) < 0 ? $"{a}_{b}" : $"{b}_{a}")}";
}
public static string Group(Guid groupId)
{
if (groupId == Guid.Empty)
throw new ArgumentException("Group ID cannot be empty.");
return $"g:{groupId}";
}
}
}