fix: align backend APIs and upload flow

This commit is contained in:
2026-09-11 18:18:59 +08:00
parent 32177a7293
commit 898cf5dba0
69 changed files with 1081 additions and 153 deletions
@@ -16,22 +16,33 @@ namespace FileService.Application.EventHandler
private readonly IObjectStorageRouter router;
private readonly IUploadFileReposity uploadFile;
private readonly IUnitOfWork uwork;
private readonly IStorageRedisCache storageCache;
private readonly IUploadTaskReposity uploadTask;
public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUnitOfWork uwork, IStorageRedisCache storageCache)
public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUploadTaskReposity uploadTask, IUnitOfWork uwork)
{
this.router = router;
this.uploadFile = uploadFile;
this.uwork = uwork;
this.storageCache = storageCache;
this.uploadTask = uploadTask;
}
public async Task Consume(ConsumeContext<UploadTaskCompleteEvent> context)
{
var @event = context.Message;
var existingFile = await uploadFile.FindBySourceTaskIdAsync(@event.TaskId);
if (existingFile != null)
{
return;
}
var task = await uploadTask.FindByIdAsync(@event.TaskId);
if (task is null)
{
throw new InvalidOperationException($"Upload task {@event.TaskId} has not been committed yet.");
}
var storage = router.Route(@event.ProviderCode);
var taskCache = await storageCache.GetAsync(@event.SessionId);
if(@event.ProviderCode == "Local")
try
{
await storage.CompleteUploadAsync(new StorageContracts.CompleteUploadCommand(
ProviderCode: @event.ProviderCode,
@@ -46,14 +57,27 @@ namespace FileService.Application.EventHandler
Checksum: s.Checksum
)).ToList()
), context.CancellationToken);
uploadFile.Create(new Domain.Entities.UploadFile(
var file = new Domain.Entities.UploadFile(
ownerId: @event.OperatorId,
fileName: @event.FileName,
fileSize: taskCache.FileSize,
fileSize: @event.FileSize,
contentType: @event.ContentType,
new Domain.ValueObjects.StorageLocation(taskCache.ProviderCode, taskCache.Bucket, taskCache.ObjectKey, taskCache.Region),
checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun)
));
new Domain.ValueObjects.StorageLocation(@event.ProviderCode, @event.Bucket, @event.ObjectKey, @event.Region),
checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun),
sourceTaskId: @event.TaskId,
chatType: @event.ChatType,
targetId: @event.TargetId,
isPublic: false
);
uploadFile.Create(file);
task.CompleteUpload(file.Id);
await uwork.SaveChangesAsync(context.CancellationToken);
}
catch (Exception ex)
{
task.Fail(ex.Message);
await uwork.SaveChangesAsync(context.CancellationToken);
throw;
}
}
}
@@ -60,6 +60,7 @@ namespace FileService.Application.StorageContracts
public sealed record PresignedUrl(
string Url,
string Method,
IReadOnlyDictionary<string, string> Headers,
DateTimeOffset ExpiresAt);
@@ -12,12 +12,14 @@ namespace FileService.Application.UploadFile
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public FileName FileName { get; set; }
public string FileName { get; set; }
public long FileSize { get; set; }
public ContentType ContentType { get; set; }
public string ContentType { get; set; }
public FileState State { get; set; }
public StorageLocation StorageLocation { get; set; }
public CheckSum CheckSum { get; set; }
public string CheckSum { get; set; }
public string? ChatType { get; set; }
public Guid? TargetId { get; set; }
public bool IsPublic { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset Updated { get; set; }
@@ -0,0 +1,19 @@
using System.Net.Http.Json;
namespace FileService.Application.UploadFile
{
public interface IGroupAccessService
{
Task<bool> CheckMemberAsync(Guid userId, Guid groupId);
}
public class GroupAccessService(HttpClient httpClient) : IGroupAccessService
{
public async Task<bool> CheckMemberAsync(Guid userId, Guid groupId)
{
var result = await httpClient.GetFromJsonAsync<IM.Commons.Result<bool>>(
$"api/groupmember/checkmember?userId={userId}&groupId={groupId}");
return result?.Succeeded == true && result.Data;
}
}
}
@@ -12,8 +12,11 @@ namespace FileService.Application.UploadFile
public UploadFileMapperConfig()
{
CreateMap<Domain.Entities.UploadFile, FileResponse>()
.ForMember(dest => dest.FileName, opt => opt.MapFrom(src => src.FileName.Value))
.ForMember(dest => dest.ContentType, opt => opt.MapFrom(src => src.ContentType.Value))
.ForMember(dest => dest.CheckSum, opt => opt.MapFrom(src => src.CheckSum.Value))
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.CreationTime))
.ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime))
.ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime))
;
}
}
@@ -6,6 +6,7 @@ using FileService.Domain.ValueObjects;
using IM.Commons;
using IM.InitCommon;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
namespace FileService.Application.UploadFile
{
@@ -15,27 +16,35 @@ namespace FileService.Application.UploadFile
private readonly IMapper mapper;
private readonly IObjectStorageRouter router;
private readonly IOptions<StorageOptions> options;
private readonly IGroupAccessService groupAccessService;
public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
IObjectStorageRouter router, IOptions<StorageOptions> options)
IObjectStorageRouter router, IOptions<StorageOptions> options,
IGroupAccessService groupAccessService)
{
this.reposity = reposity;
this.mapper = mapper;
this.router = router;
this.options = options;
this.groupAccessService = groupAccessService;
}
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id)
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id, Guid requesterId)
{
var file = await reposity.FindByIdAsync(id);
if (file == null)
{
return Result.Fail<FileResponse>(ResultCode.FILE_NOT_FOUND);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileResponse>(ResultCode.PERMISSION_DENIED);
}
var response = mapper.Map<FileResponse>(file);
response.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
response.IsPublic = response.IsPublic || response.Url != null;
return Result.Success(response);
}
@@ -45,15 +54,29 @@ namespace FileService.Application.UploadFile
/// </summary>
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default)
{
// 秒传:相同 checksum 的文件已存在则直接返回已有记录
if (!string.IsNullOrEmpty(command.CheckSum))
var checksum = command.CheckSum;
if (string.IsNullOrWhiteSpace(checksum))
{
var existing = await reposity.FindByCheckSumGlobalAsync("md5", command.CheckSum);
if (existing != null)
var hash = await MD5.HashDataAsync(command.Content, token);
checksum = Convert.ToHexString(hash).ToLowerInvariant();
if (command.Content.CanSeek)
{
command.Content.Position = 0;
}
}
// 秒传:相同 checksum 的文件已存在则直接返回已有记录
if (!string.IsNullOrEmpty(checksum))
{
var existing = await reposity.FindByCheckSumGlobalAsync("md5", checksum);
var existingPublicUrl = existing == null
? null
: router.Route(existing.StorageLocation.StorageProvider).GetPublicUrl(existing.StorageLocation);
if (existing != null && (existingPublicUrl != null ||
(!command.IsPublic && existing.OwnerId == command.OwnerId)))
{
var hit = mapper.Map<FileResponse>(existing);
var hitStorage = router.Route(existing.StorageLocation.StorageProvider);
hit.Url = hitStorage.GetPublicUrl(existing.StorageLocation);
hit.Url = existingPublicUrl;
hit.IsPublic = hit.IsPublic || hit.Url != null;
return Result.Success(hit);
}
}
@@ -84,7 +107,8 @@ namespace FileService.Application.UploadFile
fileSize: command.FileSize,
contentType: command.ContentType,
storageLocation: location,
checkSum: new CheckSum("md5", command.CheckSum ?? string.Empty));
checkSum: new CheckSum("md5", checksum),
isPublic: command.IsPublic);
reposity.Create(file);
@@ -103,6 +127,10 @@ namespace FileService.Application.UploadFile
{
return Result.Fail<FileDownload>(ResultCode.FILE_NOT_FOUND);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileDownload>(ResultCode.PERMISSION_DENIED);
}
var stream = await router.Route(file.StorageLocation.StorageProvider)
.OpenReadAsync(file.StorageLocation, token);
@@ -113,17 +141,33 @@ namespace FileService.Application.UploadFile
file.FileName.Value));
}
// FileName 值对象限制 20 字符;原始名超长时安全截断(保留扩展名),真实文件名由 objectKey 保证唯一。
private async Task<bool> CanAccessAsync(Domain.Entities.UploadFile file, Guid requesterId)
{
var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation);
if (file.IsPublic || publicUrl != null || file.OwnerId == requesterId) return true;
if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase))
{
return file.TargetId == requesterId;
}
if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) && file.TargetId.HasValue)
{
return await groupAccessService.CheckMemberAsync(requesterId, file.TargetId.Value);
}
return false;
}
// 文件名超长时安全截断(保留扩展名),真实存储键由 objectKey 保证唯一。
private static FileName SafeFileName(string fileName)
{
if (fileName.Length <= 20)
const int maxFileNameLength = 255;
if (fileName.Length <= maxFileNameLength)
{
return new FileName(fileName);
}
var ext = Path.GetExtension(fileName);
var stem = Path.GetFileNameWithoutExtension(fileName);
var keep = Math.Max(0, 20 - ext.Length);
var keep = Math.Max(0, maxFileNameLength - ext.Length);
return new FileName(stem[..Math.Min(stem.Length, keep)] + ext);
}
}
@@ -13,5 +13,10 @@ namespace FileService.Application.UploadFileTask
public string UploadSessionId { get; init; }
public StorageLocation StorageLocation { get; init; }
public bool Instant { get; init; }
public string UploadMode { get; init; } = "LocalMultipart";
public int TotalPartCount { get; init; }
public long PartSizeBytes { get; init; }
public global::FileService.Application.UploadFile.FileResponse? File { get; init; }
}
}
@@ -33,14 +33,22 @@ namespace FileService.Application.UploadFileTask
// 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录
var existingFile = await uploadFileReposity.FindByCheckSumGlobalAsync("md5", command.checkSum);
if (existingFile != null)
if (existingFile != null && CanReuse(existingFile, command))
{
var storageForResponse = router.Route(existingFile.StorageLocation.StorageProvider);
var fileResponse = mapper.Map<UploadFile.FileResponse>(existingFile);
fileResponse.Url = storageForResponse.GetPublicUrl(existingFile.StorageLocation);
fileResponse.IsPublic = fileResponse.IsPublic || fileResponse.Url != null;
return Result.Success(new TaskInitResponse
{
TaskId = existingFile.Id,
UploadSessionId = existingFile.Id.ToString(),
StorageLocation = existingFile.StorageLocation
StorageLocation = existingFile.StorageLocation,
Instant = true,
UploadMode = "Instant",
TotalPartCount = 0,
PartSizeBytes = 0,
File = fileResponse
});
}
@@ -70,6 +78,18 @@ namespace FileService.Application.UploadFileTask
var res = mapper.Map<TaskInitResponse>(initRes);
res.TaskId = task.Id;
res = new TaskInitResponse
{
TaskId = task.Id,
UploadSessionId = initRes.UploadSessionId,
StorageLocation = initRes.Location,
Instant = false,
UploadMode = string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)
? "LocalMultipart"
: "Presigned",
TotalPartCount = totalPartCount,
PartSizeBytes = storageOption.DefaultPartSizeBytes
};
task.StartUpload();
reposity.Create(task);
@@ -97,6 +117,12 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED);
}
if (taskCache.TotalPartCount < partNum || partNum < 1)
{
return Result.Fail<PresignedUrl>(ResultCode.INVALID_PART_NUMBER);
@@ -123,13 +149,31 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if (task.UploaderId != command.userId)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PERMISSION_DENIED);
}
// 校验分片数量必须匹配
if (command.Parts.Count != taskCache.TotalPartCount)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PART_COUNT_MISMATCH);
}
// 校验所有分片都已在上传缓存中注册
var expectedPartNumbers = Enumerable.Range(1, taskCache.TotalPartCount).ToHashSet();
if (!expectedPartNumbers.SetEquals(command.Parts.Select(x => x.PartNumber)))
{
return Result.Fail<UploadTaskResponse>(ResultCode.INVALID_PART_NUMBER);
}
// 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。
if (string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase))
{
foreach (var part in command.Parts)
{
if (!taskCache.Parts.TryGetValue(part.PartNumber, out _))
@@ -137,10 +181,26 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
}
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task.State == Domain.UploadTaskState.Completed)
{
var completedResponse = mapper.Map<UploadTaskResponse>(task);
if (task.ResultFileId.HasValue)
{
var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value);
if (file != null)
{
completedResponse.File = mapper.Map<UploadFile.FileResponse>(file);
completedResponse.File.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
completedResponse.File.IsPublic = completedResponse.File.IsPublic || completedResponse.File.Url != null;
}
}
return Result.Success(completedResponse);
}
task.CompleteUpload(new Domain.ValueObjects.StorageLocation(
task.StartMerging(new Domain.ValueObjects.StorageLocation(
taskCache.ProviderCode, taskCache.Bucket,
taskCache.ObjectKey, taskCache.Region
));
@@ -162,19 +222,26 @@ namespace FileService.Application.UploadFileTask
FileSize = task.FileSize,
ContentType = task.ContentType.ToString(),
CheckSun = task.CheckSum.Value
,ChatType = task.ChatType
,TargetId = task.TargetId
}, cancellationToken);
return Result.Success(mapper.Map<UploadTaskResponse>(task));
}
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command)
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command, Guid userId)
{
var taskCache = await redis.GetAsync(command.SessionId);
if (taskCache is null)
{
return Result.Fail<CompleteUploadResult>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<CompleteUploadResult>(ResultCode.PERMISSION_DENIED);
}
var minPartSize = options.Value.Providers[options.Value.DefaultProviderCode].MinPartSizeBytes;
@@ -213,6 +280,11 @@ namespace FileService.Application.UploadFileTask
{
return Result.Fail<UploadProgressResponse>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<UploadProgressResponse>(ResultCode.PERMISSION_DENIED);
}
var response = new UploadProgressResponse
{
@@ -229,5 +301,54 @@ namespace FileService.Application.UploadFileTask
return Result.Success(response);
}
private bool CanReuse(Domain.Entities.UploadFile file, UploadTaskInitCommand command)
{
var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation);
if (file.IsPublic || publicUrl != null) return true;
if (file.OwnerId == command.UploaderId)
{
return string.Equals(file.ChatType, command.ChatType, StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.TargetId;
}
if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase))
{
return string.Equals(command.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.TargetId;
}
if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase))
{
return string.Equals(command.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.UploaderId && command.TargetId == file.OwnerId;
}
return false;
}
public async Task<Result<UploadTaskResponse>> GetStatusAsync(Guid taskId, Guid userId)
{
var task = await reposity.FindByIdAsync(taskId);
if (task is null)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if (task.UploaderId != userId)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PERMISSION_DENIED);
}
var response = mapper.Map<UploadTaskResponse>(task);
if (task.ResultFileId.HasValue)
{
var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value);
if (file != null)
{
response.File = mapper.Map<UploadFile.FileResponse>(file);
response.File.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
response.File.IsPublic = response.File.IsPublic || response.File.Url != null;
}
}
return Result.Success(response);
}
}
}
@@ -8,7 +8,7 @@ namespace FileService.Application.UploadFileTask
{
public record UploadTaskInitCommand(
Guid UploaderId,
Guid ConversationId, string FileName,
Guid ConversationId, string? ChatType, Guid? TargetId, string FileName,
long FileSize,string contentType,
string checkSum
)
@@ -17,7 +17,7 @@ namespace FileService.Application.UploadFileTask
{
return new Domain.Entities.UploadTask(
UploaderId,
ConversationId, FileName, FileSize, contentType,
ConversationId, ChatType, TargetId, FileName, FileSize, contentType,
null,new Domain.ValueObjects.CheckSum("md5", checkSum)
);
}
@@ -19,5 +19,8 @@ namespace FileService.Application.UploadFileTask
public StorageLocation StorageLocation { get; set; }
public string State { get; set; }
public string CheckSum { get; set; }
public Guid? ResultFileId { get; set; }
public string? FailureReason { get; set; }
public global::FileService.Application.UploadFile.FileResponse? File { get; set; }
}
}
+9 -1
View File
@@ -12,10 +12,14 @@ namespace FileService.Domain.Entities
public FileState State { get; private set; } = FileState.Uploaded;
public StorageLocation StorageLocation { get; private set; } = new StorageLocation();
public CheckSum CheckSum { get; private set; }
public Guid? SourceTaskId { get; private set; }
public string? ChatType { get; private set; }
public Guid? TargetId { get; private set; }
public bool IsPublic { get; private set; }
private UploadFile() { }
public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum, Guid? sourceTaskId = null, string? chatType = null, Guid? targetId = null, bool isPublic = false)
{
OwnerId = ownerId;
FileName = fileName;
@@ -23,6 +27,10 @@ namespace FileService.Domain.Entities
ContentType = contentType;
StorageLocation = storageLocation ?? new StorageLocation();
CheckSum = checkSum;
SourceTaskId = sourceTaskId;
ChatType = chatType?.ToUpperInvariant();
TargetId = targetId;
IsPublic = isPublic;
State = FileState.Uploaded;
}
+22 -4
View File
@@ -8,19 +8,25 @@ namespace FileService.Domain.Entities
{
public Guid UploaderId { get; private set; }
public Guid ConversationId { get; private set; }
public string? ChatType { get; private set; }
public Guid? TargetId { get; private set; }
public FileName FileName { get; private set; }
public long FileSize { get; private set; }
public ContentType ContentType { get; private set; }
public StorageLocation StorageLocation { get; private set; }
public UploadTaskState State { get; private set; }
public CheckSum CheckSum { get; private set; }
public Guid? ResultFileId { get; private set; }
public string? FailureReason { get; private set; }
private UploadTask() { }
public UploadTask(Guid uploaderId, Guid conversationId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
public UploadTask(Guid uploaderId, Guid conversationId, string? chatType, Guid? targetId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
{
UploaderId = uploaderId;
ConversationId = conversationId;
ChatType = chatType?.ToUpperInvariant();
TargetId = targetId;
FileName = fileName;
FileSize = fileSize;
ContentType = contentType;
@@ -33,16 +39,28 @@ namespace FileService.Domain.Entities
State = UploadTaskState.Uploading;
}
public void CompleteUpload(StorageLocation location)
public void StartMerging(StorageLocation location)
{
StorageLocation = location;
State = UploadTaskState.Merging;
FailureReason = null;
NotifyModified();
}
public void CompleteUpload(Guid fileId)
{
ResultFileId = fileId;
State = UploadTaskState.Completed;
FailureReason = null;
NotifyModified();
AddDomainEvent(new UploadTaskCompletedDomainEvent(this));
}
public void Fail()
public void Fail(string reason)
{
State = UploadTaskState.Failed;
FailureReason = reason.Length > 500 ? reason[..500] : reason;
NotifyModified();
}
}
}
@@ -19,5 +19,6 @@ namespace FileService.Domain.IReposities
/// 全局按 checksum 查询(用于跨用户秒传去重)
/// </summary>
Task<UploadFile?> FindByCheckSumGlobalAsync(string algorithm, string value);
Task<UploadFile?> FindBySourceTaskIdAsync(Guid taskId);
}
}
+2 -2
View File
@@ -12,9 +12,9 @@ namespace FileService.Domain.ValueObjects
public FileName(string value)
{
if(value.Length > 20)
if (string.IsNullOrWhiteSpace(value) || value.Length > 255)
{
throw new ArgumentException("文件名超出长度");
throw new ArgumentException("文件名不能为空且不能超过 255 个字符");
}
Value = value;
@@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs
{
builder.ToTable("upload_files");
builder.Property(x => x.FileName)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.FileName(b)
);
builder.Property(x => x.ContentType)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.ContentType(b)
@@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs
builder.ComplexProperty(x => x.CheckSum, c =>
{
c.Property(p => p.Value)
.HasMaxLength(128)
.HasColumnName("checksum_value");
c.Property(p => p.Algorithm)
.HasMaxLength(16)
.HasColumnName("checksum_algorithm");
});
builder.HasIndex(x => x.SourceTaskId).IsUnique();
builder.Property(x => x.ChatType).HasMaxLength(16);
builder.ComplexProperty(x => x.StorageLocation, c =>
{
c.Property(p => p.StorageProvider)
.HasMaxLength(64)
.HasColumnName("storage_provider");
c.Property(p => p.ObjectKey)
.HasMaxLength(1024)
.HasColumnName("storage_key");
c.Property(p => p.Region)
.HasMaxLength(128)
.HasColumnName("storage_region");
c.Property(p => p.Bucket)
.HasMaxLength(255)
.HasColumnName("storage_bucket");
});
}
@@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs
{
builder.ToTable("upload_tasks");
builder.Property(x => x.FileName)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.FileName(b)
);
builder.Property(x => x.ContentType)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.ContentType(b)
@@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs
builder.ComplexProperty(x => x.CheckSum, c =>
{
c.Property(p => p.Value)
.HasMaxLength(128)
.HasColumnName("checksum_value");
c.Property(p => p.Algorithm)
.HasMaxLength(16)
.HasColumnName("checksum_algorithm");
});
builder.Property(x => x.FailureReason).HasMaxLength(500);
builder.Property(x => x.ChatType).HasMaxLength(16);
builder.ComplexProperty(x => x.StorageLocation, c =>
{
c.Property(p => p.StorageProvider)
.HasMaxLength(64)
.HasColumnName("storage_provider");
c.Property(p => p.ObjectKey)
.HasMaxLength(1024)
.HasColumnName("storage_key");
c.Property(p => p.Region)
.HasMaxLength(128)
.HasColumnName("storage_region");
c.Property(p => p.Bucket)
.HasMaxLength(255)
.HasColumnName("storage_bucket");
});
}
@@ -0,0 +1,131 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace FileService.Infrastructure.Migrations
{
[DbContext(typeof(FileDbContext))]
[Migration("20260909000300_AsyncUploadResult")]
public partial class AsyncUploadResult : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
AlterStringColumns(migrationBuilder, narrowing: true);
migrationBuilder.AddColumn<string>(
name: "ChatType",
table: "upload_files",
type: "varchar(16)",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsPublic",
table: "upload_files",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "SourceTaskId",
table: "upload_files",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "TargetId",
table: "upload_files",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChatType",
table: "upload_tasks",
type: "varchar(16)",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FailureReason",
table: "upload_tasks",
type: "varchar(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ResultFileId",
table: "upload_tasks",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "TargetId",
table: "upload_tasks",
type: "char(36)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_upload_files_SourceTaskId",
table: "upload_files",
column: "SourceTaskId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_upload_files_checksum",
table: "upload_files",
columns: new[] { "checksum_algorithm", "checksum_value", "IsDeleted" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex("IX_upload_files_checksum", "upload_files");
migrationBuilder.DropIndex("IX_upload_files_SourceTaskId", "upload_files");
migrationBuilder.DropColumn("ChatType", "upload_files");
migrationBuilder.DropColumn("IsPublic", "upload_files");
migrationBuilder.DropColumn("SourceTaskId", "upload_files");
migrationBuilder.DropColumn("TargetId", "upload_files");
migrationBuilder.DropColumn("ChatType", "upload_tasks");
migrationBuilder.DropColumn("FailureReason", "upload_tasks");
migrationBuilder.DropColumn("ResultFileId", "upload_tasks");
migrationBuilder.DropColumn("TargetId", "upload_tasks");
AlterStringColumns(migrationBuilder, narrowing: false);
}
private static void AlterStringColumns(MigrationBuilder migrationBuilder, bool narrowing)
{
var columns = new (string Table, string Column, int Length, bool Nullable)[]
{
("upload_files", "FileName", 255, false),
("upload_files", "ContentType", 255, false),
("upload_files", "checksum_algorithm", 16, false),
("upload_files", "checksum_value", 128, false),
("upload_files", "storage_provider", 64, false),
("upload_files", "storage_bucket", 255, false),
("upload_files", "storage_key", 1024, false),
("upload_files", "storage_region", 128, true),
("upload_tasks", "FileName", 255, false),
("upload_tasks", "ContentType", 255, false),
("upload_tasks", "checksum_algorithm", 16, false),
("upload_tasks", "checksum_value", 128, false),
("upload_tasks", "storage_provider", 64, false),
("upload_tasks", "storage_bucket", 255, false),
("upload_tasks", "storage_key", 1024, false),
("upload_tasks", "storage_region", 128, true)
};
foreach (var (table, column, length, nullable) in columns)
{
migrationBuilder.AlterColumn<string>(
name: column,
table: table,
type: narrowing ? $"varchar({length})" : "longtext",
maxLength: narrowing ? length : null,
nullable: nullable,
oldClrType: typeof(string),
oldType: narrowing ? "longtext" : $"varchar({length})",
oldMaxLength: narrowing ? null : length,
oldNullable: nullable);
}
}
}
}
@@ -25,13 +25,18 @@ namespace FileService.Infrastructure.Migrations
modelBuilder.Entity("FileService.Domain.Entities.UploadFile", b =>
{
b.Property<string>("ChatType")
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<DateTimeOffset>("CreationTime")
.HasColumnType("datetime(6)");
@@ -41,7 +46,8 @@ namespace FileService.Infrastructure.Migrations
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
@@ -49,27 +55,38 @@ namespace FileService.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsPublic")
.HasColumnType("tinyint(1)");
b.Property<DateTimeOffset?>("ModificationTime")
.HasColumnType("datetime(6)");
b.Property<Guid>("OwnerId")
.HasColumnType("char(36)");
b.Property<Guid?>("SourceTaskId")
.HasColumnType("char(36)");
b.Property<int>("State")
.HasColumnType("int");
b.Property<Guid?>("TargetId")
.HasColumnType("char(36)");
b.ComplexProperty<Dictionary<string, object>>("CheckSum", "FileService.Domain.Entities.UploadFile.CheckSum#CheckSum", b1 =>
{
b1.IsRequired();
b1.Property<string>("Algorithm")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(16)
.HasColumnType("varchar(16)")
.HasColumnName("checksum_algorithm");
b1.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("checksum_value");
});
@@ -79,38 +96,50 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Bucket")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(255)
.HasColumnType("varchar(255)")
.HasColumnName("storage_bucket");
b1.Property<string>("ObjectKey")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("storage_key");
b1.Property<string>("Region")
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("storage_region");
b1.Property<string>("StorageProvider")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("storage_provider");
});
b.HasKey("Id");
b.HasIndex("SourceTaskId")
.IsUnique();
b.ToTable("upload_files", (string)null);
});
modelBuilder.Entity("FileService.Domain.Entities.UploadTask", b =>
{
b.Property<string>("ChatType")
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<Guid>("ConversationId")
.HasColumnType("char(36)");
@@ -123,20 +152,31 @@ namespace FileService.Infrastructure.Migrations
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<string>("FailureReason")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<DateTimeOffset?>("ModificationTime")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ResultFileId")
.HasColumnType("char(36)");
b.Property<int>("State")
.HasColumnType("int");
b.Property<Guid?>("TargetId")
.HasColumnType("char(36)");
b.Property<Guid>("UploaderId")
.HasColumnType("char(36)");
@@ -146,12 +186,14 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Algorithm")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(16)
.HasColumnType("varchar(16)")
.HasColumnName("checksum_algorithm");
b1.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("checksum_value");
});
@@ -161,21 +203,25 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Bucket")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(255)
.HasColumnType("varchar(255)")
.HasColumnName("storage_bucket");
b1.Property<string>("ObjectKey")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("storage_key");
b1.Property<string>("Region")
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("storage_region");
b1.Property<string>("StorageProvider")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("storage_provider");
});
@@ -42,5 +42,10 @@ namespace FileService.Infrastructure.Reposites
x.CheckSum.Value == value
);
}
public Task<UploadFile?> FindBySourceTaskIdAsync(Guid taskId)
{
return db.Files.FirstOrDefaultAsync(x => x.SourceTaskId == taskId);
}
}
}
@@ -132,6 +132,7 @@ namespace FileService.Infrastructure.Storage
var baseUrl = options.Value.Providers[options.Value.DefaultProviderCode].LocalUploadApiBaseUrl;
return new PresignedUrl(
baseUrl + $"local/parts/upload?sessionId={command.UploadSessionId}&partNumber={command.PartNumber}",
"POST",
new Dictionary<string, string>(),
ExpiresAt: DateTimeOffset.Now.Add(options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn)
);
@@ -1,6 +1,7 @@
using FileService.Application.UploadFile;
using FileService.Infrastructure;
using IM.ASPNETCore;
using IM.Commons;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -51,7 +52,8 @@ namespace FileService.WebApi.Controllers.File
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid id)
{
var res = await service.GetFileInfoAsync(id);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.GetFileInfoAsync(id, Guid.Parse(userId));
return Ok(res);
}
@@ -65,11 +67,15 @@ namespace FileService.WebApi.Controllers.File
var res = await service.OpenDownloadAsync(id, Guid.Parse(userId));
if (res.Data == null)
{
if (res.Code == (int)ResultCode.PERMISSION_DENIED)
{
return StatusCode(StatusCodes.Status403Forbidden, res);
}
return NotFound(res);
}
Response.Headers["Cache-Control"] = "private,max-age=86400";
return File(res.Data.Content, res.Data.ContentType);
return File(res.Data.Content, res.Data.ContentType, enableRangeProcessing: true);
}
}
}
@@ -5,8 +5,8 @@ namespace FileService.WebApi.Controllers.FileTask
{
public class CompleteTaskRequest
{
public string SessionId { get; set; }
public List<UploadPart> Parts { get; set; }
public string SessionId { get; set; } = string.Empty;
public List<UploadPart> Parts { get; set; } = [];
}
public class CompleteTaskRequestValidator : AbstractValidator<CompleteTaskRequest>
@@ -28,6 +28,8 @@ namespace FileService.WebApi.Controllers.FileTask
var res = await service.InitTaskAsync(new UploadTaskInitCommand(
UploaderId: Guid.Parse(userId),
ConversationId: request.ConversationId,
ChatType: request.ChatType,
TargetId: request.TargetId,
FileName: request.FileName,
FileSize: request.FileSize,
contentType: request.ContentType,
@@ -44,6 +46,13 @@ namespace FileService.WebApi.Controllers.FileTask
return Ok(res);
}
[HttpGet("status")]
public async Task<IActionResult> Status(Guid taskId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetStatusAsync(taskId, Guid.Parse(userId)));
}
[HttpGet("Getuploadurl")]
public async Task<IActionResult> GetUploadUrl(string sessionId, int partNum)
{
@@ -53,19 +62,20 @@ namespace FileService.WebApi.Controllers.FileTask
}
[HttpPost("complete")]
[UnitOfWork(typeof(FileDbContext))]
public async Task<IActionResult> Complete([FromBody] CompleteTaskRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.CompleteTaskAsync(new UploadTaskCompleteCommand(request.SessionId, Guid.Parse(userId), request.Parts));
return Ok(res);
return res.Succeeded ? Accepted(res) : Ok(res);
}
[HttpPost("local/parts/upload")]
public async Task<IActionResult> LocalUpload(string sessionId, int partNumber, IFormFile file)
{
//var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var stream = file.OpenReadStream();
var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length));
var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length), Guid.Parse(userId));
return Ok(res);
}
}
@@ -5,14 +5,30 @@ namespace FileService.WebApi.Controllers.FileTask
public class FileTaskInitRequest
{
public Guid ConversationId { get; set; }
public string FileName { get; set; }
public string? ChatType { get; set; }
public Guid? TargetId { get; set; }
public string FileName { get; set; } = string.Empty;
public long FileSize { get; set; }
public string ContentType { get; set; }
public string CheckSum { get; set; }
public string ContentType { get; set; } = string.Empty;
public string CheckSum { get; set; } = string.Empty;
}
public class FileTaskInitRequestValidator: AbstractValidator<FileTaskInitRequest>
{
public FileTaskInitRequestValidator()
{
RuleFor(x => x.FileName).NotEmpty().MaximumLength(255);
RuleFor(x => x.FileSize).GreaterThan(0);
RuleFor(x => x.ContentType).NotEmpty().MaximumLength(255);
RuleFor(x => x.CheckSum).NotEmpty().MaximumLength(128);
RuleFor(x => x.ChatType)
.Must(value => string.IsNullOrWhiteSpace(value) ||
value.Equals("PRIVATE", StringComparison.OrdinalIgnoreCase) ||
value.Equals("GROUP", StringComparison.OrdinalIgnoreCase))
.WithMessage("chatType 必须为 PRIVATE 或 GROUP");
RuleFor(x => x.TargetId)
.NotEmpty()
.When(x => !string.IsNullOrWhiteSpace(x.ChatType));
}
}
}
+11
View File
@@ -9,6 +9,17 @@ namespace FileService.WebApi
public void Initialize(IServiceCollection services)
{
services.AddScoped<UploadFileService>();
services.AddHttpClient<IGroupAccessService, GroupAccessService>((sp, client) =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
client.BaseAddress = new Uri(configuration["InternalServices:GroupServiceBaseUrl"]
?? "http://im-group-service:8080/");
var internalApiKey = configuration["InternalApiKey"];
if (!string.IsNullOrWhiteSpace(internalApiKey))
{
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", internalApiKey);
}
});
}
}
}
@@ -1,4 +1,8 @@
{
"InternalApiKey": "development-only-change-me",
"InternalServices": {
"GroupServiceBaseUrl": "http://localhost:5070/"
},
"Logging": {
"LogLevel": {
"Default": "Information",
+5 -1
View File
@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"InternalApiKey": "",
"InternalServices": {
"GroupServiceBaseUrl": "http://im-group-service:8080/"
}
}
@@ -49,5 +49,12 @@ namespace GroupService.Domain.Entities
{
GroupNickName = nickname;
}
public void Leave()
{
if (IsDeleted) return;
SoftDelete();
AddDomainEvent(new GroupMemberLeftDomainEvent(this));
}
}
}
@@ -0,0 +1,7 @@
using GroupService.Domain.Entities;
using MediatR;
namespace GroupService.Domain.Events
{
public record GroupMemberLeftDomainEvent(GroupMember Member) : INotification;
}
@@ -22,6 +22,7 @@ namespace GroupService.Domain.IReposities
/// <param name="userId"></param>
/// <returns></returns>
Task<IEnumerable<Group>> FindByMasterIdAsync(Guid userId);
Task<IEnumerable<Group>> FindByMemberIdAsync(Guid userId);
/// <summary>
/// 创建群聊
/// </summary>
@@ -8,5 +8,6 @@ namespace GroupService.Domain.IReposities
Task<GroupJoinRequest> FindByIdAsync(Guid id);
Task<IEnumerable<GroupJoinRequest?>> FindByGroupIdAsync(Guid groupId);
Task<IEnumerable<GroupJoinRequest>> FindByUserIdAsync(Guid userId);
Task<IEnumerable<GroupJoinRequest>> FindVisibleToUserAsync(Guid userId);
}
}
@@ -11,6 +11,9 @@ namespace GroupService.Infrastructure.Configs
builder.ToTable("group_join_requests");
builder.HasKey(x => x.Id);
builder.HasKey(x => new { x.GroupId, x.UserId });
builder.HasIndex(x => x.Id).IsUnique();
builder.HasIndex(x => new { x.GroupId, x.State, x.CreationTime });
builder.HasIndex(x => new { x.UserId, x.CreationTime });
builder.ComplexProperty(x => x.UserProfile, u =>
{
@@ -9,6 +9,8 @@ namespace GroupService.Infrastructure.Configs
public void Configure(EntityTypeBuilder<GroupMember> builder)
{
builder.ToTable("group_members");
builder.HasIndex(x => new { x.UserId, x.IsDeleted, x.GroupId });
builder.HasIndex(x => new { x.GroupId, x.IsDeleted, x.Role });
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace GroupService.Infrastructure.Migrations
{
[DbContext(typeof(GroupDbContext))]
[Migration("20260909000200_ApiAlignmentFixes")]
public partial class ApiAlignmentFixes : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_group_members_UserId_IsDeleted_GroupId",
table: "group_members",
columns: new[] { "UserId", "IsDeleted", "GroupId" });
migrationBuilder.CreateIndex(
name: "IX_group_members_GroupId_IsDeleted_Role",
table: "group_members",
columns: new[] { "GroupId", "IsDeleted", "Role" });
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_Id",
table: "group_join_requests",
column: "Id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_GroupId_State_CreationTime",
table: "group_join_requests",
columns: new[] { "GroupId", "State", "CreationTime" });
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_UserId_CreationTime",
table: "group_join_requests",
columns: new[] { "UserId", "CreationTime" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex("IX_group_members_UserId_IsDeleted_GroupId", "group_members");
migrationBuilder.DropIndex("IX_group_members_GroupId_IsDeleted_Role", "group_members");
migrationBuilder.DropIndex("IX_group_join_requests_Id", "group_join_requests");
migrationBuilder.DropIndex("IX_group_join_requests_GroupId_State_CreationTime", "group_join_requests");
migrationBuilder.DropIndex("IX_group_join_requests_UserId_CreationTime", "group_join_requests");
}
}
}
@@ -227,6 +227,13 @@ namespace GroupService.Infrastructure.Migrations
b.HasKey("GroupId", "UserId");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("GroupId", "State", "CreationTime");
b.HasIndex("UserId", "CreationTime");
b.ToTable("group_join_requests", (string)null);
});
@@ -266,6 +273,10 @@ namespace GroupService.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("GroupId", "IsDeleted", "Role");
b.HasIndex("UserId", "IsDeleted", "GroupId");
b.ToTable("group_members", (string)null);
});
#pragma warning restore 612, 618
@@ -36,5 +36,19 @@ namespace GroupService.Infrastructure.Reposities
x.UserId == userId || x.OperatorId == userId
).ToListAsync();
}
public async Task<IEnumerable<GroupJoinRequest>> FindVisibleToUserAsync(Guid userId)
{
var managedGroupIds = db.GroupMembers
.Where(member => member.UserId == userId &&
(member.Role == Domain.Enums.GroupMemberRole.Administrator ||
member.Role == Domain.Enums.GroupMemberRole.Master))
.Select(member => member.GroupId);
return await db.GroupJoinRequests
.Where(request => request.UserId == userId || managedGroupIds.Contains(request.GroupId))
.OrderByDescending(request => request.CreationTime)
.ToListAsync();
}
}
}
@@ -29,6 +29,18 @@ namespace GroupService.Infrastructure.Reposities
return await db.Groups.Where(x => x.GroupMaster == userId).ToListAsync();
}
public async Task<IEnumerable<Group>> FindByMemberIdAsync(Guid userId)
{
var groupIds = db.GroupMembers
.Where(member => member.UserId == userId)
.Select(member => member.GroupId);
return await db.Groups
.Where(group => groupIds.Contains(group.Id))
.OrderByDescending(group => group.ModificationTime ?? group.CreationTime)
.ToListAsync();
}
public async Task<IEnumerable<Group>> FindByNameAsync(string name)
{
return await db.Groups.Where(x => x.Name == name).ToListAsync();
@@ -0,0 +1,18 @@
using GroupService.Domain.Events;
using IM.Commons.IntegrationEvents;
using MassTransit;
using MediatR;
namespace GroupService.WebApi.Application.EventHandler
{
public class GroupMemberLeftHandler(IPublishEndpoint endpoint)
: INotificationHandler<GroupMemberLeftDomainEvent>
{
public Task Handle(GroupMemberLeftDomainEvent notification, CancellationToken cancellationToken)
{
return endpoint.Publish(
new GroupMemberLeftEvent(notification.Member.UserId, notification.Member.GroupId),
cancellationToken);
}
}
}
@@ -27,11 +27,11 @@ namespace GroupService.WebApi.Application.Group
public async Task<Result<List<GroupResponse>>> GetAllAsync(Guid userId)
{
var groups = await reposity.FindByMasterIdAsync(userId);
var groups = await reposity.FindByMemberIdAsync(userId);
return Result<List<GroupResponse>>.Success(mapper.Map<List<GroupResponse>>(groups));
}
public async Task<Result<GroupResponse>> GetByIdAsync(Guid groupId)
public async Task<Result<GroupResponse>> GetByIdAsync(Guid groupId, Guid userId)
{
var group = await reposity.FindByIdAsync(groupId);
if (group is null)
@@ -39,9 +39,36 @@ namespace GroupService.WebApi.Application.Group
return Result<GroupResponse>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (!await memberReposity.CheckMemberExistAsync(groupId, userId))
{
return Result<GroupResponse>.Fail(ResultCode.PERMISSION_DENIED);
}
return Result<GroupResponse>.Success(mapper.Map<GroupResponse>(group));
}
public async Task<Result<object>> DissolveAsync(Guid groupId, Guid userId)
{
var group = await reposity.FindByIdAsync(groupId);
if (group is null)
{
return Result.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (group.GroupMaster != userId)
{
return Result.Fail(ResultCode.PERMISSION_DENIED);
}
var members = await memberReposity.FindByGroupIdAsync(groupId);
foreach (var member in members)
{
member.Leave();
}
group.SoftDelete();
return Result.Success();
}
public async Task<Result<GroupResponse>> UpdateAsync(GroupUpdateCommand command)
{
var group = await reposity.FindByIdAsync(command.GroupId);
@@ -24,13 +24,17 @@ namespace GroupService.WebApi.Application.GroupMember
this.mapper = mapper;
}
public async Task<Result<List<GroupMemberResponse>>> GetByGroupIdAsync(Guid groupId)
public async Task<Result<List<GroupMemberResponse>>> GetByGroupIdAsync(Guid groupId, Guid userId)
{
var group = await groupReposity.FindByIdAsync(groupId);
if (group is null)
{
return Result<List<GroupMemberResponse>>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (!await reposity.CheckMemberExistAsync(groupId, userId))
{
return Result<List<GroupMemberResponse>>.Fail(ResultCode.PERMISSION_DENIED);
}
var members = await reposity.FindByGroupIdAsync(groupId);
return Result<List<GroupMemberResponse>>.Success(mapper.Map<List<GroupMemberResponse>>(members.ToList()));
@@ -78,14 +82,32 @@ namespace GroupService.WebApi.Application.GroupMember
}
var operatorMember = await reposity.FindOneByGroupIdAndUserIdAsync(member.GroupId, operatorId);
if (operatorMember is null || operatorMember.Role == Domain.Enums.GroupMemberRole.Normal)
if (operatorMember is null || operatorMember.Id == member.Id ||
member.Role == Domain.Enums.GroupMemberRole.Master ||
operatorMember.Role <= member.Role)
{
return Result.Fail(ResultCode.PERMISSION_DENIED);
}
member.SoftDelete();
member.Leave();
return Result.Success();
}
public async Task<Result<object>> LeaveAsync(Guid groupId, Guid userId)
{
var member = await reposity.FindOneByGroupIdAndUserIdAsync(groupId, userId);
if (member is null)
{
return Result.Fail(ResultCode.GROUP_MEMBER_NOT_FOUNT);
}
if (member.Role == Domain.Enums.GroupMemberRole.Master)
{
return Result.Fail<object>(ResultCode.PERMISSION_DENIED, "群主不能直接退群,请使用解散群接口");
}
member.Leave();
return Result.Success();
}
}
}
@@ -98,7 +98,7 @@ namespace GroupService.WebApi.Application.GroupRequest
}
public async Task<Result<List<GroupRequestResponse>>> GetListAsync(Guid userId)
{
var list = await reposity.FindByUserIdAsync(userId);
var list = await reposity.FindVisibleToUserAsync(userId);
return Result.Success(mapper.Map<List<GroupRequestResponse>>(list.ToList()));
}
@@ -32,7 +32,8 @@ namespace GroupService.WebApi.Controllers.Group
[ProducesDefaultResponseType(typeof(Result<GroupResponse>))]
public async Task<IActionResult> GetOne(Guid groupId)
{
return Ok(await service.GetByIdAsync(groupId));
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetByIdAsync(groupId, Guid.Parse(userId)));
}
[HttpPost]
@@ -49,5 +50,13 @@ namespace GroupService.WebApi.Controllers.Group
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.UpdateAsync(new GroupUpdateCommand(Guid.Parse(userId), request.GroupId, request.Avatar, request.GroupName, request.Description)));
}
[HttpPost]
[UnitOfWork(typeof(GroupDbContext))]
public async Task<IActionResult> Dissolve([FromQuery] Guid groupId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.DissolveAsync(groupId, Guid.Parse(userId)));
}
}
}
@@ -1,6 +1,8 @@
using GroupService.Infrastructure;
using GroupService.WebApi.Application.GroupMember;
using IM.ASPNETCore;
using IM.Commons;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
@@ -11,25 +13,36 @@ namespace GroupService.WebApi.Controllers.GroupMember
public class GroupMemberController : ControllerBase
{
private readonly GroupMemberService service;
private readonly IConfiguration configuration;
public GroupMemberController(GroupMemberService service)
public GroupMemberController(GroupMemberService service, IConfiguration configuration)
{
this.service = service;
this.configuration = configuration;
}
[HttpGet]
public async Task<IActionResult> CheckMember(Guid userId, Guid groupId)
{
var expectedKey = configuration["InternalApiKey"];
var suppliedKey = Request.Headers["X-Internal-Api-Key"].ToString();
if (string.IsNullOrWhiteSpace(expectedKey) || suppliedKey != expectedKey)
{
return Unauthorized(Result.Fail(ResultCode.AUTH_FAILED));
}
return Ok(await service.CheckMemberAsync(groupId, userId));
}
[HttpGet]
[Authorize]
public async Task<IActionResult> List(Guid groupId)
{
return Ok(await service.GetByGroupIdAsync(groupId));
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetByGroupIdAsync(groupId, Guid.Parse(userId)));
}
[HttpPost]
[Authorize]
[UnitOfWork(typeof(GroupDbContext))]
public async Task<IActionResult> Delete([FromQuery] Guid memberId)
{
@@ -37,6 +50,15 @@ namespace GroupService.WebApi.Controllers.GroupMember
return Ok(await service.DeleteAsync(memberId, Guid.Parse(userId)));
}
[HttpPost]
[Authorize]
[UnitOfWork(typeof(GroupDbContext))]
public async Task<IActionResult> Leave([FromQuery] Guid groupId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.LeaveAsync(groupId, Guid.Parse(userId)));
}
}
}
@@ -1,4 +1,5 @@
{
"InternalApiKey": "development-only-change-me",
"Logging": {
"LogLevel": {
"Default": "Information",
+2 -1
View File
@@ -5,5 +5,6 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"InternalApiKey": ""
}
+29 -2
View File
@@ -1,15 +1,19 @@
using IM.Commons;
using IM.DomainCommons;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace IM.ASPNETCore
{
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
public ExceptionMiddleware(RequestDelegate next)
private readonly ILogger<ExceptionMiddleware> logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
this.logger = logger;
}
public async Task InvokeAsync(HttpContext context)
@@ -22,12 +26,35 @@ namespace IM.ASPNETCore
{
await DomainExceptionHandlerAsync(context, ex);
}
catch (Exception ex)
{
await UnhandledExceptionHandlerAsync(context, ex);
}
}
public Task DomainExceptionHandlerAsync(HttpContext context, DomainException ex)
{
context.Response.ContentType = "application/json";
var result = Result<object>.Fail(ResultCode.PARAMETER_ERROR, ex.Message); // 包装成你的 Result
context.Response.StatusCode = StatusCodes.Status400BadRequest;
var result = Result<object>.Fail(ResultCode.PARAMETER_ERROR, ex.Message);
return context.Response.WriteAsJsonAsync(result);
}
private Task UnhandledExceptionHandlerAsync(HttpContext context, Exception ex)
{
var correlationId = context.TraceIdentifier;
logger.LogError(ex,
"Unhandled exception. CorrelationId: {CorrelationId}, Path: {Path}",
correlationId,
context.Request.Path);
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.Headers["X-Correlation-ID"] = correlationId;
var result = Result<object>.Fail(
ResultCode.SYSTEM_ERROR,
$"系统错误,关联编号:{correlationId}");
return context.Response.WriteAsJsonAsync(result);
}
}
@@ -0,0 +1,4 @@
namespace IM.Commons.IntegrationEvents
{
public record GroupMemberLeftEvent(Guid UserId, Guid GroupId);
}
@@ -20,6 +20,8 @@ namespace IM.Commons.IntegrationEvents
public string ContentType { get; set; }
public string CheckSun { get; set; }
public IReadOnlyList<UploadPart> Parts { get; set; }
public string? ChatType { get; set; }
public Guid? TargetId { get; set; }
}
public sealed record UploadPart(
int PartNumber,
+4
View File
@@ -28,6 +28,10 @@ namespace IM.InitCommon
c.Password(options.Password);
});
cfg.UseMessageRetry(retry => retry.Intervals(
TimeSpan.FromMilliseconds(200),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(5)));
cfg.ConfigureEndpoints(context);
});
});
+2 -2
View File
@@ -61,7 +61,7 @@ namespace IM.Jwt
context.HandleResponse();
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
var result = Result<object>.Fail(ResultCode.AUTH_FAILED);
await context.Response.WriteAsJsonAsync(result);
@@ -71,7 +71,7 @@ namespace IM.Jwt
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.StatusCode = StatusCodes.Status403Forbidden;
var result = Result<object>.Fail(ResultCode.PERMISSION_DENIED);
await context.Response.WriteAsJsonAsync(result);
}
+74
View File
@@ -0,0 +1,74 @@
# API 对齐数据库迁移运行手册
## 适用迁移
- MessageService`20260909000100_ApiAlignmentFixes`
- GroupService`20260909000200_ApiAlignmentFixes`
- FileService`20260909000300_AsyncUploadResult`
## 发布前检查
先完成三个库的可恢复备份,并在对应数据库执行:
```sql
-- GroupService:新增 Id 唯一索引前必须无重复。
SELECT Id, COUNT(*) AS duplicate_count
FROM group_join_requests
GROUP BY Id
HAVING COUNT(*) > 1;
-- FileService:收紧 longtext 前检查历史最大长度。
SELECT
MAX(CHAR_LENGTH(FileName)) AS max_file_name,
MAX(CHAR_LENGTH(ContentType)) AS max_content_type,
MAX(CHAR_LENGTH(checksum_algorithm)) AS max_checksum_algorithm,
MAX(CHAR_LENGTH(checksum_value)) AS max_checksum_value,
MAX(CHAR_LENGTH(storage_provider)) AS max_storage_provider,
MAX(CHAR_LENGTH(storage_bucket)) AS max_storage_bucket,
MAX(CHAR_LENGTH(storage_key)) AS max_storage_key,
MAX(CHAR_LENGTH(storage_region)) AS max_storage_region
FROM upload_files;
```
上面的长度必须分别不超过 `255/255/16/128/64/255/1024/128`。对 `upload_tasks` 执行同样检查。超长值应先人工确认和修正,不要依赖数据库静默截断。
## 执行迁移
在仓库根目录设置目标数据库连接字符串后执行。不要把真实密码写入仓库或命令记录。
```powershell
$env:DefaultDB_ConnStr = '<MessageService MySQL connection string>'
dotnet ef database update --project MessageService.Infrastructure --startup-project MessageService.WebApi --context MessageDbContext
$env:DefaultDB_ConnStr = '<GroupService MySQL connection string>'
dotnet ef database update --project GroupService.Infrastructure --startup-project GroupService.WebApi --context GroupDbContext
$env:DefaultDB_ConnStr = '<FileService MySQL connection string>'
dotnet ef database update --project FileService.Infrastructure --startup-project FileService.WebApi --context FileDbContext
```
建议顺序为 Message → Group → File,随后发布后端,再发布最终前端包。
## 数据兼容说明
- 所有新业务列均可空或有安全默认值,不删除历史记录。
- 历史文件的 `IsPublic` 默认 `false`,无法确认作用域的旧文件因此只允许所有者读取。
- 新上传文件会写入 `SourceTaskId/ChatType/TargetId/ResultFileId`;不要批量猜测旧文件作用域。
- 群退出、群解散和会话隐藏使用软删除。
## 回滚
只有在已经回滚依赖新字段/接口的前后端版本后,才允许回滚数据库迁移:
```powershell
$env:DefaultDB_ConnStr = '<MessageService MySQL connection string>'
dotnet ef database update 20260423115234_InitMessageDb --project MessageService.Infrastructure --startup-project MessageService.WebApi --context MessageDbContext
$env:DefaultDB_ConnStr = '<GroupService MySQL connection string>'
dotnet ef database update 20260429103435_removeGroupRequestOperatorProfile --project GroupService.Infrastructure --startup-project GroupService.WebApi --context GroupDbContext
$env:DefaultDB_ConnStr = '<FileService MySQL connection string>'
dotnet ef database update 20260509073447_InitFileDb --project FileService.Infrastructure --startup-project FileService.WebApi --context FileDbContext
```
FileService 回滚会删除新作用域和任务结果列,并把收紧的字符串列恢复为 `longtext`;回滚前应另行导出这些新列的数据。
+10 -11
View File
@@ -57,24 +57,22 @@ namespace MessageService.Domain.Entities
AddDomainEvent(new ConversationCreatedDomainEvent(this));
}
public void Update(long? LastReadSequenceId = default, int? unreadCount = default, string? lastMsg = default)
public void SetLastReadSequence(long sequenceId)
{
if (LastReadSequenceId != null)
{
LastReadSequenceId = LastReadSequenceId.Value;
this.NotifyModified();
}
if (unreadCount != null)
{
UnreadCount += unreadCount.Value;
LastReadSequenceId = sequenceId;
NotifyModified();
}
if (lastMsg != null)
public void IncrementUnread()
{
LastMessage = lastMsg;
UnreadCount += 1;
NotifyModified();
}
public void UpdateLastMessage(string lastMessage)
{
LastMessage = lastMessage;
NotifyModified();
}
@@ -86,6 +84,7 @@ namespace MessageService.Domain.Entities
UnreadCount = 0;
if (lastReadSequenceId.HasValue)
LastReadSequenceId = lastReadSequenceId.Value;
NotifyModified();
}
public void UpdateProfile(string name, string avatar)
+11 -11
View File
@@ -91,10 +91,10 @@ namespace MessageService.Domain.Entities
/// <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)
public static Message BuildImg(MessageCreateContext ctx, string? url,
int width, int height, string thumb, long sequenceId, Guid? fileId = null)
{
var content = new MessageContent("[图片]", new ImageBody(url, width, height, thumb));
var content = new MessageContent("[图片]", new ImageBody(url, width, height, thumb, fileId));
return new Message(ctx, sequenceId, MessageType.Image, content);
}
/// <summary>
@@ -107,10 +107,10 @@ namespace MessageService.Domain.Entities
/// <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)
public static Message BuildVideo(MessageCreateContext ctx, string? url,
int width, int height, string thumb, long sequenceId, Guid? fileId = null)
{
var content = new MessageContent("[视频]", new VideoBody(url, width, height, thumb));
var content = new MessageContent("[视频]", new VideoBody(url, width, height, thumb, fileId));
return new Message(ctx, sequenceId, MessageType.Video, content);
}
/// <summary>
@@ -121,10 +121,10 @@ namespace MessageService.Domain.Entities
/// <param name="duration">持续时间</param>
/// <param name="sequenceId"></param>
/// <returns></returns>
public static Message BuildVoice(MessageCreateContext ctx, string url,
int duration, long sequenceId)
public static Message BuildVoice(MessageCreateContext ctx, string? url,
int duration, long sequenceId, Guid? fileId = null)
{
var content = new MessageContent("[音频]", new VoiceBody(url, duration));
var content = new MessageContent("[音频]", new VoiceBody(url, duration, fileId));
return new Message(ctx, sequenceId, MessageType.Voice, content);
}
/// <summary>
@@ -137,11 +137,11 @@ namespace MessageService.Domain.Entities
/// <param name="format">文件格式</param>
/// <param name="sequenceId"></param>
/// <returns></returns>
public static Message BuildFile(MessageCreateContext ctx, string url,
public static Message BuildFile(MessageCreateContext ctx, Guid fileId, string? url,
string name, long size,
string format, long sequenceId)
{
var content = new MessageContent("[文件]", new FileBody(url, name, size, format));
var content = new MessageContent("[文件]", new FileBody(fileId, url, name, size, format));
return new Message(ctx, sequenceId, MessageType.File, content);
}
/// <summary>
@@ -1,8 +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);
public record ImageBody(string? Url, int Width, int Height, string Thumb, Guid? FileId = null);
public record VideoBody(string? Url, int Width, int Height, string Thumb, Guid? FileId = null);
public record VoiceBody(string? Url, int Duration, Guid? FileId = null);
public record FileBody(Guid FileId, string? Url, string FileName, long Size, string Format);
}
@@ -11,6 +11,7 @@ namespace MessageService.Infrastructure.Configs
builder.ToTable("conversations");
builder.HasKey(x => x.Id);
builder.HasIndex(x => x.UserId);
builder.HasIndex(x => new { x.UserId, x.ChatType, x.TargetId, x.IsDeleted });
}
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace MessageService.Infrastructure.Migrations
{
[DbContext(typeof(MessageDbContext))]
[Migration("20260909000100_ApiAlignmentFixes")]
public partial class ApiAlignmentFixes : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_conversations_UserId_ChatType_TargetId_IsDeleted",
table: "conversations",
columns: new[] { "UserId", "ChatType", "TargetId", "IsDeleted" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_conversations_UserId_ChatType_TargetId_IsDeleted",
table: "conversations");
}
}
}
@@ -76,6 +76,8 @@ namespace MessageService.Infrastructure.Migrations
b.HasIndex("UserId");
b.HasIndex("UserId", "ChatType", "TargetId", "IsDeleted");
b.ToTable("conversations", (string)null);
});
@@ -26,30 +26,32 @@ namespace MessageService.Infrastructure.Reposities
public async Task<(IEnumerable<Message> messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit)
{
var query = db.Messages.Where(x => x.StreamKey == streamKey);
List<Message> messages = [];
List<Message> fetched;
if (direction == 0) // Before: 找比锚点小的,按倒序排
{
if (cusor.HasValue)
query = query.Where(m => m.SequenceId < cusor.Value);
var list = await query
fetched = await query
.OrderByDescending(m => m.SequenceId) // 最新消息在最前
.Take(limit + 1)
.ToListAsync();
messages = [.. list.OrderBy(s => s.SequenceId)];
}
else
{
if (cusor is null)
return (messages, false);
return (Array.Empty<Message>(), false);
messages = await query.OrderBy(o => o.SequenceId)
fetched = await query
.Where(m => m.SequenceId > cusor.Value)
.OrderBy(o => o.SequenceId)
.Take(limit + 1)
.ToListAsync();
}
return (messages, messages.Count > limit);
var hasMore = fetched.Count > limit;
var messages = fetched.Take(limit).OrderBy(s => s.SequenceId).ToList();
return (messages, hasMore);
}
}
}
@@ -8,7 +8,7 @@ namespace MessageService.WebApi.Application.Conversation
public ConversationMapperConfig()
{
CreateMap<Domain.Entities.Conversation, ConversationResponse>()
.ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.ModificationTime))
.ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime))
;
}
}
@@ -31,6 +31,6 @@ namespace MessageService.WebApi.Application.Dtos
/// 最后一条最新消息
/// </summary>
public string LastMessage { get; set; }
public DateTime DateTime { get; set; }
public DateTimeOffset DateTime { get; set; }
}
}
@@ -6,7 +6,7 @@ using MessageService.Infrastructure;
namespace MessageService.WebApi.Application.EventHandlers
{
public class ConversationAddHandler : IConsumer<GroupMemberJoinedEvent>,
IConsumer<FriendAddedEvent>
IConsumer<FriendAddedEvent>, IConsumer<GroupMemberLeftEvent>
{
private readonly IConversationReposity reposity;
@@ -21,6 +21,11 @@ namespace MessageService.WebApi.Application.EventHandlers
public async Task Consume(ConsumeContext<GroupMemberJoinedEvent> context)
{
var @event = context.Message;
var existing = await reposity.FindByTargetIdAsync(@event.GroupId);
if (existing.Any(x => x.UserId == @event.UserId && x.ChatType == Domain.Enums.ChatType.GROUP))
{
return;
}
reposity.Create(new Domain.Entities.Conversation(
userId: @event.UserId,
targetId: @event.GroupId,
@@ -32,12 +37,17 @@ namespace MessageService.WebApi.Application.EventHandlers
lastMessage: string.Empty
));
await messageDb.SaveChangesAsync();
await messageDb.SaveChangesAsync(context.CancellationToken);
}
public async Task Consume(ConsumeContext<FriendAddedEvent> context)
{
var @event = context.Message;
var existing = await reposity.FindByUserIdAsync(@event.OwnerId);
if (existing.Any(x => x.TargetId == @event.TargetId && x.ChatType == Domain.Enums.ChatType.PRIVATE))
{
return;
}
reposity.Create(new Domain.Entities.Conversation(
userId: @event.OwnerId,
targetId: @event.TargetId,
@@ -49,7 +59,18 @@ namespace MessageService.WebApi.Application.EventHandlers
lastMessage: string.Empty
));
await messageDb.SaveChangesAsync();
await messageDb.SaveChangesAsync(context.CancellationToken);
}
public async Task Consume(ConsumeContext<GroupMemberLeftEvent> context)
{
var conversations = await reposity.FindByTargetIdAsync(context.Message.GroupId);
foreach (var conversation in conversations.Where(x =>
x.UserId == context.Message.UserId && x.ChatType == Domain.Enums.ChatType.GROUP))
{
conversation.SoftDelete();
}
await messageDb.SaveChangesAsync(context.CancellationToken);
}
}
}
@@ -24,20 +24,24 @@ namespace MessageService.WebApi.Application.EventHandlers
{
var message = notification.Message;
if(message.ChatType == Domain.Enums.ChatType.PRIVATE)
var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey);
foreach (var conversation in conversations)
{
var list = await reposity.FindByStreamKeyAsync(message.StreamKey);
var owner = list.First(x => x.UserId == message.SenderId);
var target = list.First(x => x.UserId == message.TargetId);
owner.Update(message.SequenceId, 0, message.Content.Fallback);
target.Update(target.LastReadSequenceId, target.UnreadCount + 1, message.Content.Fallback);
messageDb.Conversations.UpdateRange(owner,target);
await messageDb.SaveChangesAsync(cancellationToken);
conversation.UpdateLastMessage(message.Content.Fallback);
if (conversation.UserId == message.SenderId)
{
conversation.SetLastReadSequence(message.SequenceId);
}
else
{
conversation.IncrementUnread();
}
}
await endpoint.Publish(message.ToIntegrationEvent());
messageDb.Conversations.UpdateRange(conversations);
await messageDb.SaveChangesAsync(cancellationToken);
await endpoint.Publish(message.ToIntegrationEvent(), cancellationToken);
}
public async Task Handle(MessageWithdrawDomainEvent notification, CancellationToken cancellationToken)
@@ -52,13 +52,16 @@ namespace MessageService.WebApi.Application.Message
{
MessageType.Text => Domain.Entities.Message.BuildTxt(ctx, command.Text!, sequenceId),
MessageType.Image => Domain.Entities.Message.BuildImg(ctx, command.Url!,
command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId),
MessageType.Image => Domain.Entities.Message.BuildImg(ctx, command.Url,
command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId, command.FileId),
MessageType.Video => Domain.Entities.Message.BuildVideo(ctx, command.Url!,
command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId),
MessageType.Video => Domain.Entities.Message.BuildVideo(ctx, command.Url,
command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId, command.FileId),
MessageType.Voice => Domain.Entities.Message.BuildVoice(ctx, command.Url!, command.Duration ?? 0, sequenceId),
MessageType.Voice => Domain.Entities.Message.BuildVoice(ctx, command.Url, command.Duration ?? 0, sequenceId, command.FileId),
MessageType.File => Domain.Entities.Message.BuildFile(ctx, command.FileId!.Value, command.Url,
command.FileName!, command.FileSize!.Value, command.FileFormat!, sequenceId),
_ => null
};
@@ -109,6 +112,11 @@ namespace MessageService.WebApi.Application.Message
public async Task<Result<GetMessagesResponse>> GetMessagesAsync(GetMessageCommand command)
{
if (command.direction is not 0 and not 1 || command.limit is < 1 or > 100)
{
return Result.Fail<GetMessagesResponse>(ResultCode.PARAMETER_ERROR);
}
var conversation = await conversationReposity.FindByIdAsync(command.conversationId);
if(conversation is null || conversation.UserId != command.userId)
@@ -22,8 +22,12 @@ namespace MessageService.WebApi.Application.Message
public int? Height { get; init; }
public string? Thumb { get; init; }
public int? Duration { get; init; }
public Guid? FileId { get; init; }
public string? FileName { get; init; }
public long? FileSize { get; init; }
public string? FileFormat { get; init; }
public SendMsgCommand(Guid senderId, Guid targetId, ChatType chatType, MessageType msgType, Guid clientMsgId, Guid? quoteMessageId, Dictionary<string, string>? ext, string? text, string? url, int? width, int? height, string? thumb, int? duration)
public SendMsgCommand(Guid senderId, Guid targetId, ChatType chatType, MessageType msgType, Guid clientMsgId, Guid? quoteMessageId, Dictionary<string, string>? ext, string? text, string? url, int? width, int? height, string? thumb, int? duration, Guid? fileId, string? fileName, long? fileSize, string? fileFormat)
{
SenderId = senderId;
TargetId = targetId;
@@ -38,6 +42,10 @@ namespace MessageService.WebApi.Application.Message
Height = height;
Thumb = thumb;
Duration = duration;
FileId = fileId;
FileName = fileName;
FileSize = fileSize;
FileFormat = fileFormat;
}
}
}
@@ -23,6 +23,10 @@ namespace MessageService.WebApi.Controllers.Message
public int? Height { get; init; }
public string? Thumb { get; init; }
public int? Duration { get; init; }
public Guid? FileId { get; init; }
public string? FileName { get; init; }
public long? FileSize { get; init; }
public string? FileFormat { get; init; }
public SendMsgCommand ToCommand(Guid senderId)
{
@@ -39,7 +43,11 @@ namespace MessageService.WebApi.Controllers.Message
Width,
Height,
Thumb,
Duration
Duration,
FileId,
FileName,
FileSize,
FileFormat
);
}
}
@@ -63,9 +71,19 @@ namespace MessageService.WebApi.Controllers.Message
// 图片/视频/语音消息:url 必填
When(r => r.MsgType == MessageType.Image || r.MsgType == MessageType.Video
|| r.MsgType == MessageType.Voice || r.MsgType == MessageType.File, () =>
|| r.MsgType == MessageType.Voice, () =>
{
RuleFor(r => r.Url).NotEmpty().WithMessage("媒体消息的 url 字段不能为空");
RuleFor(r => r)
.Must(request => !string.IsNullOrWhiteSpace(request.Url) || request.FileId.HasValue)
.WithMessage("媒体消息必须提供 url 或 fileId");
});
When(r => r.MsgType == MessageType.File, () =>
{
RuleFor(r => r.FileId).NotNull().NotEmpty().WithMessage("文件消息的 fileId 字段不能为空");
RuleFor(r => r.FileName).NotEmpty().WithMessage("文件消息的 fileName 字段不能为空");
RuleFor(r => r.FileSize).NotNull().GreaterThanOrEqualTo(0).WithMessage("文件消息的 fileSize 字段不合法");
RuleFor(r => r.FileFormat).NotEmpty().WithMessage("文件消息的 fileFormat 字段不能为空");
});
}
}
+9 -2
View File
@@ -20,9 +20,16 @@ namespace MessageService.WebApi
services.AddScoped<ConversationService>();
services.AddScoped<SquenceService>();
services.AddScoped<IContactIntegrationService, ContactIntegrationService>();
services.AddHttpClient<IGroupMemberIntegrationService, GroupMemberIntegrationService>(c =>
services.AddHttpClient<IGroupMemberIntegrationService, GroupMemberIntegrationService>((sp, c) =>
{
c.BaseAddress = new Uri("http://im-group-service:8080/");
var configuration = sp.GetRequiredService<IConfiguration>();
c.BaseAddress = new Uri(configuration["InternalServices:GroupServiceBaseUrl"]
?? "http://im-group-service:8080/");
var internalApiKey = configuration["InternalApiKey"];
if (!string.IsNullOrWhiteSpace(internalApiKey))
{
c.DefaultRequestHeaders.Add("X-Internal-Api-Key", internalApiKey);
}
});
services.AddGrpcClient<ContactInternal.ContactInternalClient>((sp, o) =>
{
@@ -1,4 +1,8 @@
{
"InternalApiKey": "development-only-change-me",
"InternalServices": {
"GroupServiceBaseUrl": "http://localhost:5070/"
},
"Logging": {
"LogLevel": {
"Default": "Information",
+5 -1
View File
@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"InternalApiKey": "",
"InternalServices": {
"GroupServiceBaseUrl": "http://im-group-service:8080/"
}
}
+5
View File
@@ -145,6 +145,7 @@ services:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required}
networks:
- im-net
@@ -173,6 +174,7 @@ services:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required}
networks:
- im-net
@@ -189,12 +191,15 @@ services:
condition: service_started
rabbitmq:
condition: service_started
group-service:
condition: service_started
ports:
- "5005:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
CONSUL_URL: http://consul:8500
InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required}
networks:
- im-net