fix: align backend APIs and upload flow
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"InternalApiKey": ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;回滚前应另行导出这些新列的数据。
|
||||
@@ -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)
|
||||
|
||||
@@ -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 字段不能为空");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +5,9 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"InternalApiKey": "",
|
||||
"InternalServices": {
|
||||
"GroupServiceBaseUrl": "http://im-group-service:8080/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user