更新
This commit is contained in:
2026-02-23 18:52:32 +08:00
parent d429560511
commit 123fa6a7aa
81 changed files with 5952 additions and 407 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -22,5 +22,9 @@
"Port": 5672,
"Username": "test",
"Password": "123456"
},
"FileUploadOptions": {
"DefaultStorage": "Local",
"ChunkSize": 10,
}
}
@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+dc6ecf224df4e8714171e8b5d23afaa90b3a1f81")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+d42956051108811cdd7be6a07af6b11e1d7f0d15")]
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
1b9e709aa84e0b4f6260cd10cf25bfc3a30c60e75a3966fc7d4cdf489eae898b
fa24b386648cc4dba48ae5e3f91e5303b0dfd0971bba62bc27ca1580a5064337
Binary file not shown.
@@ -1 +1 @@
6e6df2b3d9fe8d3830882bef146134864f65ca58bc5ea4bac684eaec55cfd628
b2b545acb4173028f4d41b8d8c0aea03ecbcecb4eeabe997ca466c2e265beff6
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -96,7 +96,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.304/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.311/PortableRuntimeIdentifierGraph.json"
}
}
},
@@ -199,6 +199,10 @@
"target": "Package",
"version": "[2.3.2, )"
},
"SixLabors.ImageSharp": {
"target": "Package",
"version": "[3.1.12, )"
},
"StackExchange.Redis": {
"target": "Package",
"version": "[2.9.32, )"
@@ -231,7 +235,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.304/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.311/PortableRuntimeIdentifierGraph.json"
}
}
}
@@ -7,7 +7,7 @@
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\nanxun\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.14.1</NuGetToolVersion>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.14.2</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\nanxun\.nuget\packages\" />
+34 -1
View File
@@ -1679,6 +1679,22 @@
}
}
},
"SixLabors.ImageSharp/3.1.12": {
"type": "package",
"compile": {
"lib/net6.0/SixLabors.ImageSharp.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net6.0/SixLabors.ImageSharp.dll": {
"related": ".xml"
}
},
"build": {
"build/_._": {}
}
},
"StackExchange.Redis/2.9.32": {
"type": "package",
"dependencies": {
@@ -3027,6 +3043,7 @@
"Newtonsoft.Json": "13.0.4",
"Pomelo.EntityFrameworkCore.MySql": "8.0.3",
"RedLock.net": "2.3.2",
"SixLabors.ImageSharp": "3.1.12",
"StackExchange.Redis": "2.9.32",
"Swashbuckle.AspNetCore": "6.6.2",
"System.IdentityModel.Tokens.Jwt": "8.14.0"
@@ -5363,6 +5380,22 @@
"runtimes/ubuntu.16.10-x64/native/System.Security.Cryptography.Native.OpenSsl.so"
]
},
"SixLabors.ImageSharp/3.1.12": {
"sha512": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==",
"type": "package",
"path": "sixlabors.imagesharp/3.1.12",
"files": [
".nupkg.metadata",
".signature.p7s",
"LICENSE",
"build/SixLabors.ImageSharp.props",
"lib/net6.0/SixLabors.ImageSharp.dll",
"lib/net6.0/SixLabors.ImageSharp.xml",
"sixlabors.imagesharp.128.png",
"sixlabors.imagesharp.3.1.12.nupkg.sha512",
"sixlabors.imagesharp.nuspec"
]
},
"StackExchange.Redis/2.9.32": {
"sha512": "j5Rjbf7gWz5izrn0UWQy9RlQY4cQDPkwJfVqATnVsOa/+zzJrps12LOgacMsDl/Vit2f01cDiDkG/Rst8v2iGw==",
"type": "package",
@@ -8962,7 +8995,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.304/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.311/PortableRuntimeIdentifierGraph.json"
}
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"version": 2,
"dgSpecHash": "ueA0djhC8vQ=",
"dgSpecHash": "E2DnflEnEuk=",
"success": true,
"projectFilePath": "C:\\Users\\nanxun\\Documents\\IM\\backend\\IMTest\\IMTest.csproj",
"expectedPackageFiles": [
@@ -97,6 +97,7 @@
"C:\\Users\\nanxun\\.nuget\\packages\\runtime.ubuntu.14.04-x64.runtime.native.system.security.cryptography.openssl\\4.3.0\\runtime.ubuntu.14.04-x64.runtime.native.system.security.cryptography.openssl.4.3.0.nupkg.sha512",
"C:\\Users\\nanxun\\.nuget\\packages\\runtime.ubuntu.16.04-x64.runtime.native.system.security.cryptography.openssl\\4.3.0\\runtime.ubuntu.16.04-x64.runtime.native.system.security.cryptography.openssl.4.3.0.nupkg.sha512",
"C:\\Users\\nanxun\\.nuget\\packages\\runtime.ubuntu.16.10-x64.runtime.native.system.security.cryptography.openssl\\4.3.0\\runtime.ubuntu.16.10-x64.runtime.native.system.security.cryptography.openssl.4.3.0.nupkg.sha512",
"C:\\Users\\nanxun\\.nuget\\packages\\sixlabors.imagesharp\\3.1.12\\sixlabors.imagesharp.3.1.12.nupkg.sha512",
"C:\\Users\\nanxun\\.nuget\\packages\\stackexchange.redis\\2.9.32\\stackexchange.redis.2.9.32.nupkg.sha512",
"C:\\Users\\nanxun\\.nuget\\packages\\swashbuckle.aspnetcore\\6.6.2\\swashbuckle.aspnetcore.6.6.2.nupkg.sha512",
"C:\\Users\\nanxun\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\6.6.2\\swashbuckle.aspnetcore.swagger.6.6.2.nupkg.sha512",
+2 -1
View File
@@ -1,3 +1,4 @@
bin/
obj/
.vs/
.vs/
uploads/
@@ -17,11 +17,13 @@ namespace IM_API.Application.EventHandlers.MessageCreatedHandler
private readonly IHubContext<ChatHub> _hub;
private readonly IMapper _mapper;
private readonly IUserService _userService;
public SignalREventHandler(IHubContext<ChatHub> hub, IMapper mapper,IUserService userService)
public SignalREventHandler(IHubContext<ChatHub> hub, IMapper mapper,
IUserService userService)
{
_hub = hub;
_mapper = mapper;
_userService = userService;
}
public async Task Consume(ConsumeContext<MessageCreatedEvent> context)
@@ -35,6 +37,10 @@ namespace IM_API.Application.EventHandlers.MessageCreatedHandler
var senderinfo = await _userService.GetUserInfoAsync(@event.MsgSenderId);
messageBaseVo.SenderName = senderinfo.NickName;
messageBaseVo.SenderAvatar = senderinfo.Avatar ?? "";
if (messageBaseVo.Type != MessageMsgType.Text)
{
messageBaseVo.Content = UrlTools.ProcessMessageUrl(messageBaseVo.Content, @event.BaseUrl);
}
await _hub.Clients.Group(@event.StreamKey).SendAsync("ReceiveMessage", new HubResponse<MessageBaseVo>("Event", messageBaseVo));
}
catch (Exception ex)
@@ -0,0 +1,21 @@
using IM_API.Domain.Events;
using IM_API.Interface.Services;
using MassTransit;
namespace IM_API.Application.EventHandlers.UploadEventHandler
{
public class MergeEventHandler : IConsumer<UploadMergeEvent>
{
private readonly IStorageService _storage;
public MergeEventHandler(IStorageService storage)
{
_storage = storage;
}
public async Task Consume(ConsumeContext<UploadMergeEvent> context)
{
var @event = context.Message;
await _storage.MergeAsync(@event.TaskId, @event.ObjectName, @event.ChunckCount, @event.Parts);
}
}
}
+2
View File
@@ -7,6 +7,7 @@ using IM_API.Application.EventHandlers.GroupRequestHandler;
using IM_API.Application.EventHandlers.GroupRequestUpdateHandler;
using IM_API.Application.EventHandlers.MessageCreatedHandler;
using IM_API.Application.EventHandlers.RequestFriendHandler;
using IM_API.Application.EventHandlers.UploadEventHandler;
using IM_API.Configs.Options;
using IM_API.Domain.Events;
using MassTransit;
@@ -37,6 +38,7 @@ namespace IM_API.Configs
x.AddConsumer<RequestDbHandler>();
x.AddConsumer<SignalRHandler>();
x.AddConsumer<RequestUpdateSignalrHandler>();
x.AddConsumer<MergeEventHandler>();
x.UsingRabbitMq((ctx,cfg) =>
{
cfg.Host(options.Host, "/", h =>
+32
View File
@@ -4,9 +4,12 @@ using IM_API.Dtos;
using IM_API.Dtos.Auth;
using IM_API.Dtos.Friend;
using IM_API.Dtos.Group;
using IM_API.Dtos.Message;
using IM_API.Dtos.User;
using IM_API.Models;
using IM_API.Models.Upload;
using IM_API.Tools;
using IM_API.VOs;
using IM_API.VOs.Conversation;
using IM_API.VOs.Message;
@@ -171,6 +174,35 @@ namespace IM_API.Configs
.ForMember(dest => dest.AuhorityEnum, opt => opt.MapFrom(src => GroupAuhority.REQUIRE_CONSENT))
.ForMember(dest => dest.StatusEnum, opt => opt.MapFrom(src => GroupStatus.Normal))
;
//上传任务模型转换
CreateMap<CreateUploadTaskDto, UploadTask>()
.ForMember(dest => dest.FileName, opt => opt.MapFrom(src => src.FileName))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => UploadStatus.Created))
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => Guid.NewGuid()))
.ForMember(dest => dest.FileSize, opt => opt.MapFrom(src => src.FileSize))
.ForMember(dest => dest.FileHash, opt => opt.MapFrom(src => src.FileHash))
.ForMember(dest => dest.ContentType, opt => opt.MapFrom(src => src.ContentType))
.ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => DateTime.UtcNow))
;
CreateMap<UploadTask, CreateUploadTaskVo>()
.ForMember(dest => dest.TaskId, opt => opt.MapFrom(src => src.Id))
.ForMember(dest => dest.ChunkSize, opt => opt.MapFrom(src => src.ChunkSize))
.ForMember(dest => dest.TotalChunks, opt => opt.MapFrom(src => src.TotalChunks))
.ForMember(dest => dest.Concurrency, opt => opt.MapFrom(src => 5))
.ForMember(dest => dest.Skip, opt => opt.MapFrom(src => false))
.ForMember(dest => dest.Url, opt => opt.MapFrom(src => src.ObjectName))
;
CreateMap<UploadTask, ImageDto>()
.ForMember(dest => dest.Url, opt => opt.MapFrom(src => src.ObjectName))
.ForMember(dest => dest.FileId, opt => opt.MapFrom(src => src.Id))
.ForMember(dest => dest.Provider, opt => opt.MapFrom(src => src.StorageProvider))
.ForMember(dest => dest.Format, opt => opt.MapFrom(src => src.ContentType))
.ForMember(dest => dest.Size, opt => opt.MapFrom(src => src.FileSize));
CreateMap<ImageDto, VideoDto>();
}
}
}
@@ -0,0 +1,8 @@
namespace IM_API.Configs.Options
{
public class FileUploadOptions
{
public string DefaultStorage { get; set; }
public int ChunkSize { get; set; }
}
}
@@ -29,7 +29,8 @@ namespace IM_API.Configs
services.AddScoped<IGroupService, GroupService>();
services.AddScoped<ISequenceIdService, SequenceIdService>();
services.AddScoped<ICacheService, RedisCacheService>();
services.AddScoped<IEventBus, InMemoryEventBus>();
services.AddScoped<IStorageService, LocalStorageService>();
services.AddScoped<IUploadTaskService, UploadTaskService>();
services.AddSingleton<IJWTService, JWTService>();
services.AddSingleton<IRefreshTokenService, RedisRefreshTokenService>();
services.AddSingleton<IDistributedLockFactory>(sp =>
@@ -3,6 +3,8 @@ using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Dtos.Message;
using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Tools;
using IM_API.VOs.Message;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -19,12 +21,12 @@ namespace IM_API.Controllers
{
private readonly IMessageSevice _messageService;
private readonly ILogger<MessageController> _logger;
private readonly IEventBus _eventBus;
public MessageController(IMessageSevice messageService, ILogger<MessageController> logger, IEventBus eventBus)
public MessageController(IMessageSevice messageService,
ILogger<MessageController> logger)
{
_messageService = messageService;
_logger = logger;
_eventBus = eventBus;
}
[HttpPost]
[ProducesResponseType(typeof(BaseResponse<MessageBaseVo>), StatusCodes.Status200OK)]
@@ -32,15 +34,23 @@ namespace IM_API.Controllers
{
var userIdstr = User.FindFirstValue(ClaimTypes.NameIdentifier);
MessageBaseVo messageBaseVo = new MessageBaseVo();
var handledMessage = await _messageService.HandleFileMessageContentAsync(dto);
if(dto.ChatType == Models.ChatType.PRIVATE)
{
messageBaseVo = await _messageService.SendPrivateMessageAsync(int.Parse(userIdstr), dto.ReceiverId, dto);
messageBaseVo = await _messageService.SendPrivateMessageAsync(int.Parse(userIdstr), dto.ReceiverId, handledMessage);
}
else
{
messageBaseVo = await _messageService.SendGroupMessageAsync(int.Parse(userIdstr), dto.ReceiverId, dto);
messageBaseVo = await _messageService.SendGroupMessageAsync(int.Parse(userIdstr), dto.ReceiverId, handledMessage);
}
return Ok(new BaseResponse<MessageBaseVo>(messageBaseVo));
if (messageBaseVo.Type != MessageMsgType.Text)
{
var request = HttpContext?.Request;
var baseUrl = $"{request.Scheme}://{request.Host}";
messageBaseVo.Content = UrlTools.ProcessMessageUrl(messageBaseVo.Content, baseUrl);
}
return Ok(new BaseResponse<MessageBaseVo>(messageBaseVo));
}
[HttpGet]
[ProducesResponseType(typeof(BaseResponse<List<MessageBaseVo>>), StatusCodes.Status200OK)]
@@ -0,0 +1,124 @@
using IM_API.Dtos;
using IM_API.Interface.Services;
using IM_API.Models.Upload;
using IM_API.Tools;
using IM_API.VOs;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Storage;
using StackExchange.Redis;
using System.Text;
using System.Text.Json;
using IDatabase = StackExchange.Redis.IDatabase;
namespace IM_API.Controllers
{
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class UploadController : ControllerBase
{
private readonly IWebHostEnvironment _env;
private readonly IStorageService _storage;
private readonly IDatabase _redis;
public UploadController(IWebHostEnvironment env, IStorageService storage, IConnectionMultiplexer connectionMultiplexer)
{
_env = env;
_storage = storage;
_redis = connectionMultiplexer.GetDatabase();
}
[HttpPost("local/{taskId}/parts/{partNumber}")]
[ProducesResponseType(typeof(BaseResponse<object?>), StatusCodes.Status200OK)]
public async Task<IActionResult> LocalUpload(Guid taskId, int partNumber, IFormFile file)
{
var baseDir = Path.Combine(_env.ContentRootPath, "uploads"); // 项目根目录下 uploads
Directory.CreateDirectory(baseDir);
var path = Path.Combine(baseDir, "temp", taskId.ToString(), $"{partNumber}.part.tmp");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var stream = System.IO.File.Create(path);
await file.CopyToAsync(stream);
await _redis.SetAddAsync(RedisKeys.GetUploadPartKey(taskId), partNumber);
return Ok(new BaseResponse<object?>());
}
[HttpPost("CreateTask")]
[ProducesResponseType(typeof(BaseResponse<CreateUploadTaskVo>), StatusCodes.Status200OK)]
public async Task<IActionResult> CreateUpload(CreateUploadTaskDto dto)
{
var vo = await _storage.InitTaskAsync(dto);
return Ok(new BaseResponse<CreateUploadTaskVo>(vo));
}
[HttpPost("CreatePart")]
public async Task<IActionResult> CreatePart(Guid taskId, int partNum)
{
var vo = await _storage.CreatePartInstructionAsync(taskId, partNum);
return Ok(new BaseResponse<UploadPartInstructionVo>(vo));
}
[HttpPost("CompleteTask")]
public async Task<IActionResult> CompleteTask([FromQuery]Guid taskId, [FromBody]List<UploadPartDto> dtos)
{
var taskIdRes = await _storage.CompleteAsync(taskId, dtos);
return Ok(new BaseResponse<string>(data: taskIdRes.ToString()));
}
[HttpGet("events/{taskId}")]
[AllowAnonymous]
public async Task Events(Guid taskId)
{
Response.Headers.Add("Content-Type", "text/event-stream");
Response.Headers.Add("Cache-Control", "no-cache");
Response.Headers.Add("Connection", "keep-alive");
var lastProgress = -1;
while (!HttpContext.RequestAborted.IsCancellationRequested)
{
var hash = await _redis.HashGetAllAsync(RedisKeys.MergeStatus(taskId));
if (hash.Length == 0)
{
await Task.Delay(1000);
continue;
}
var status = hash.FirstOrDefault(x => x.Name == "status").Value;
var progress = hash.FirstOrDefault(x => x.Name == "progress").Value;
var url = hash.FirstOrDefault(x => x.Name == "url").Value;
// 避免重复发送
if (progress != lastProgress)
{
var data = new
{
status = status.ToString(),
progress = progress.ToString(),
url = (string)url
};
await Response.WriteAsync($"data: {JsonSerializer.Serialize(data)}\n\n");
await Response.Body.FlushAsync();
// 完成后关闭 SSE
if (status == "Completed")
break;
await Task.Delay(1000); // 每秒检查一次
}
}
}
[HttpPost("upload/{hash}")]
public async Task<IActionResult> UploadSmallFile(IFormFile file,string hash)
{
using var stream = file.OpenReadStream();
var res = await _storage.UploadSmallFileAsync(stream, file.FileName, file.ContentType, file.Length, hash);
return Ok(new BaseResponse<UploadTask>(res));
}
}
}
@@ -16,6 +16,7 @@ namespace IM_API.Domain.Events
public DateTimeOffset MessageCreated { get; set; }
public string StreamKey { get; set; }
public Guid ClientMsgId { get; set; }
public string BaseUrl { get; set; }
@@ -0,0 +1,13 @@
using IM_API.Dtos;
namespace IM_API.Domain.Events
{
public record UploadMergeEvent : DomainEvent
{
public override string EventType => "IM.FILES_UPLOAD_MERGE";
public Guid TaskId { get; init; }
public List<UploadPartDto> Parts { get; init; }
public int ChunckCount { get; set; }
public string ObjectName { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace IM_API.Dtos
{
public class CreateUploadTaskDto
{
public string FileName { get; set; } = default!;
public long FileSize { get; set; }
public string ContentType { get; set; } = default!;
public string FileHash { get; set; } = default!;
}
}
@@ -0,0 +1,26 @@
namespace IM_API.Dtos.Message
{
public class RequestMessageType
{
public Guid FileId { get; set; }
public long Size { get; set; }
}
public class BaseMessageType: RequestMessageType
{
public string Url { get; set; }
public string Provider { get; set; }
public string Format { get; set; }
public string Text { get; set; }
}
public class ImageDto() : BaseMessageType
{
public string Thumb { get; set; }
public int W { get; set; }
public int H { get; set; }
}
public class VideoDto() : ImageDto
{
public int Duration { get; set; }
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ namespace IM_API.Dtos
public Guid MsgId { get; init; }
public int SenderId { get; init; }
public int ReceiverId { get; init; }
public string Content { get; init; } = default!;
public string Content { get; set; } = default!;
public DateTimeOffset TimeStamp { get; init; }
public MessageBaseDto() { }
}
+8
View File
@@ -0,0 +1,8 @@
namespace IM_API.Dtos
{
public class UploadPartDto
{
public int PartNumber { get; set; }
public string? ETag { get; set; }
}
}
+1
View File
@@ -28,6 +28,7 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="RedLock.net" Version="2.3.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="StackExchange.Redis" Version="2.9.32" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
@@ -48,6 +48,6 @@ namespace IM_API.Interface.Services
Task<bool> MarkConversationAsReadAsync(int userId,int? userBId,int? groupId);
Task<bool> RecallMessageAsync(int userId,int messageId);
Task<MessageBaseDto> HandleFileMessageContentAsync(MessageBaseDto dto);
}
}
@@ -0,0 +1,40 @@
using IM_API.Dtos;
using IM_API.Models.Upload;
using IM_API.VOs;
namespace IM_API.Interface.Services
{
public interface IStorageService
{
string ProviderName { get; }
UploadMode Mode { get; }
/// <summary>
/// 初始化上传任务
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
Task<CreateUploadTaskVo> InitTaskAsync(CreateUploadTaskDto dto);
/// <summary>
/// 创建分片任务
/// </summary>
/// <param name="taskId">文件上传任务ID</param>
/// <param name="partNumer"></param>
/// <returns></returns>
Task<UploadPartInstructionVo> CreatePartInstructionAsync(Guid taskId, int partNumer);
Task<Guid> CompleteAsync(
Guid taskId,
List<UploadPartDto> parts
);
Task MergeAsync(Guid taskId, string objectName, int totalChunks, List<UploadPartDto> parts);
Task<UploadTask> UploadSmallFileAsync(Stream stream, string fileName, string fileType, long size, string hash);
string GetDownloadUrl(string objectname);
}
public enum UploadMode
{
Proxy, // 本地 / 后端中转
Direct // 云直传
}
}
@@ -0,0 +1,12 @@
using IM_API.Models.Upload;
namespace IM_API.Interface.Services
{
public interface IUploadTaskService
{
Task AddAsync(UploadTask task);
Task<UploadTask?> GetTaskAsync(Guid taskId);
Task<UploadTask?> GetTaskAsync(string hash);
Task UpdateStatusAsync(Guid taskId, UploadStatus status);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class adduploadtask : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UploadTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FileName = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
FileSize = table.Column<long>(type: "bigint", nullable: false),
FileHash = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
ContentType = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
ChunkSize = table.Column<int>(type: "int", nullable: false),
TotalChunks = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
StorageProvider = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
ObjectName = table.Column<string>(type: "longtext", nullable: false, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
ProviderUploadId = table.Column<string>(type: "longtext", nullable: true, collation: "latin1_swedish_ci")
.Annotation("MySql:CharSet", "latin1"),
CreatedAt = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PRIMARY", x => x.Id);
})
.Annotation("MySql:CharSet", "latin1")
.Annotation("Relational:Collation", "latin1_swedish_ci");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UploadTasks");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IM_API.Migrations
{
/// <inheritdoc />
public partial class updateuploadtask : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(
name: "UploadTasks",
newName: "upload_tasks");
migrationBuilder.AlterTable(
name: "upload_tasks")
.Annotation("MySql:CharSet", "utf8mb4")
.Annotation("Relational:Collation", "utf8mb4_general_ci")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "StorageProvider",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "ProviderUploadId",
table: "upload_tasks",
type: "longtext",
nullable: true,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "ObjectName",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "FileName",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "FileHash",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "upload_tasks",
type: "longtext",
nullable: false,
collation: "utf8mb4_general_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "latin1")
.OldAnnotation("Relational:Collation", "latin1_swedish_ci");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(
name: "upload_tasks",
newName: "UploadTasks");
migrationBuilder.AlterTable(
name: "UploadTasks")
.Annotation("MySql:CharSet", "latin1")
.Annotation("Relational:Collation", "latin1_swedish_ci")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "StorageProvider",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "ProviderUploadId",
table: "UploadTasks",
type: "longtext",
nullable: true,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "ObjectName",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "FileName",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "FileHash",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "UploadTasks",
type: "longtext",
nullable: false,
collation: "latin1_swedish_ci",
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "latin1")
.OldAnnotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("Relational:Collation", "utf8mb4_general_ci");
}
}
}
@@ -768,6 +768,59 @@ namespace IM_API.Migrations
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
});
modelBuilder.Entity("IM_API.Models.Upload.UploadTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("ChunkSize")
.HasColumnType("int");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("FileHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<string>("ObjectName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("ProviderUploadId")
.HasColumnType("longtext");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("StorageProvider")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("TotalChunks")
.HasColumnType("int");
b.HasKey("Id")
.HasName("PRIMARY");
b.ToTable("upload_tasks", (string)null);
MySqlEntityTypeBuilderExtensions.HasCharSet(b, "utf8mb4");
MySqlEntityTypeBuilderExtensions.UseCollation(b, "utf8mb4_general_ci");
});
modelBuilder.Entity("IM_API.Models.User", b =>
{
b.Property<int>("Id")
+13
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using IM_API.Models.Upload;
using Microsoft.EntityFrameworkCore;
namespace IM_API.Models;
@@ -18,6 +19,7 @@ public partial class ImContext : DbContext
public virtual DbSet<Device> Devices { get; set; }
public virtual DbSet<File> Files { get; set; }
public virtual DbSet<UploadTask> UploadTasks { get; set; }
public virtual DbSet<Friend> Friends { get; set; }
@@ -208,6 +210,17 @@ public partial class ImContext : DbContext
.HasConstraintName("files_ibfk_1");
});
modelBuilder.Entity<UploadTask>(entity =>
{
entity.HasKey(e => e.Id).HasName("PRIMARY");
entity
.ToTable("upload_tasks")
.HasCharSet("utf8mb4")
.UseCollation("utf8mb4_general_ci");
});
modelBuilder.Entity<Friend>(entity =>
{
entity.HasKey(e => e.Id).HasName("PRIMARY");
@@ -0,0 +1,10 @@
namespace IM_API.Models.Upload
{
public enum UploadStatus
{
Created,
Uploading,
Completed,
Aborted
}
}
@@ -0,0 +1,24 @@
namespace IM_API.Models.Upload
{
public class UploadTask
{
public Guid Id { get; set; }
public string FileName { get; set; } = default!;
public long FileSize { get; set; }
public string FileHash { get; set; }
public string ContentType { get; set; } = default!;
public int ChunkSize { get; set; }
public int TotalChunks { get; set; }
public UploadStatus Status { get; set; }
public string StorageProvider { get; set; } = default!;
public string ObjectName { get; set; } = default!;
public string? ProviderUploadId { get; set; } // OSS/S3 UploadId
public DateTimeOffset CreatedAt { get; set; }
}
}
+21 -1
View File
@@ -7,6 +7,7 @@ using IM_API.Models;
using IM_API.Tools;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;
using StackExchange.Redis;
using System.Text;
@@ -42,7 +43,9 @@ namespace IM_API
});
builder.Services.AddRabbitMQ(configuration.GetSection("RabbitMqOptions").Get<RabbitMQOptions>());
builder.Services.AddHttpContextAccessor();
builder.Services.AddAllService(configuration);
builder.Services.AddSignalR().AddJsonProtocol(options =>
@@ -134,6 +137,23 @@ namespace IM_API
var app = builder.Build();
string uploadPath = Path.Combine(Directory.GetCurrentDirectory(), "Uploads","files");
// 2. 如果文件夹不存在则创建,防止程序启动报错
if (!Directory.Exists(uploadPath))
{
Directory.CreateDirectory(uploadPath);
}
// 3. 配置静态文件映射
app.UseStaticFiles(new StaticFileOptions
{
// 指定物理磁盘路径
FileProvider = new PhysicalFileProvider(uploadPath),
// 指定浏览器访问的虚拟前缀(例如:http://localhost:5000/files/1.jpg
RequestPath = "/uploads/files"
});
app.UseCors();
// Configure the HTTP request pipeline.
@@ -0,0 +1,236 @@
using AutoMapper;
using IM_API.Configs.Options;
using IM_API.Domain.Events;
using IM_API.Dtos;
using IM_API.Exceptions;
using IM_API.Interface.Services;
using IM_API.Models.Upload;
using IM_API.Tools;
using IM_API.VOs;
using MassTransit;
using Microsoft.EntityFrameworkCore.Storage;
using StackExchange.Redis;
using System.Security.AccessControl;
using System.Security.Claims;
using System.Threading.Tasks;
using IDatabase = StackExchange.Redis.IDatabase;
namespace IM_API.Services
{
public class LocalStorageService : IStorageService
{
private readonly IMapper _mapper;
private readonly IHttpContextAccessor _httpContext;
private FileUploadOptions _options;
private readonly IUploadTaskService _uploadTaskService;
private readonly IDatabase _redis;
private readonly IHostEnvironment _env;
private readonly ILogger<LocalStorageService> _logger;
private readonly IPublishEndpoint _endpoint;
public LocalStorageService(IMapper mapper, IHttpContextAccessor httpContextAccessor,
IConfiguration configuration, IUploadTaskService uploadTaskService,
IConnectionMultiplexer connectionMultiplexer, IHostEnvironment hostEnvironment
, ILogger<LocalStorageService> logger, IPublishEndpoint publishEndpoint)
{
_mapper = mapper;
_httpContext = httpContextAccessor;
_options = configuration.GetSection("FileUploadOptions").Get<FileUploadOptions>()!;
_uploadTaskService = uploadTaskService;
_redis = connectionMultiplexer.GetDatabase();
_env = hostEnvironment;
_logger = logger;
_endpoint = publishEndpoint;
}
public UploadMode Mode => UploadMode.Proxy;
public string ProviderName => "Local";
public async Task<Guid> CompleteAsync(Guid taskId, List<UploadPartDto> parts)
{
var task = await _uploadTaskService.GetTaskAsync(taskId);
if(task is null)
throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
var partsToCheck = Enumerable.Range(1, task.TotalChunks)
.Select(i => (RedisValue)i).ToArray();
var results = await _redis.SetContainsAsync(RedisKeys.GetUploadPartKey(taskId), partsToCheck);
// 3. 快速判断是否全部存在
bool isAllUploaded = results.All(exists => exists);
if (!isAllUploaded) throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
await _endpoint.Publish(new UploadMergeEvent
{
AggregateId = taskId.ToString(),
OccurredAt = DateTime.UtcNow,
EventId = Guid.NewGuid(),
OperatorId = 0,
Parts = parts,
TaskId = taskId,
ChunckCount = task.TotalChunks,
ObjectName = task.ObjectName
});
return taskId;
}
public async Task MergeAsync(Guid taskId, string objectName, int totalChunks, List<UploadPartDto> parts)
{
var baseDir = Path.Combine(_env.ContentRootPath, "uploads");
var tempPath = Path.Combine(baseDir, "temp", taskId.ToString()); // 项目根目录下 uploads // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字)
var finalPath = Path.Combine(baseDir, "files", objectName);
var finalDir = Path.GetDirectoryName(finalPath);
Directory.CreateDirectory(finalDir);
try
{
using (var finalStream = new FileStream(finalPath, FileMode.Create))
{
for (var i = 1; i <= totalChunks; i++)
{
var progress = (i * 100.0 / totalChunks);
if (i % 5 == 0 || i == totalChunks)
{
await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[]
{
new("status", "processing"),
new("progress", progress.ToString("F2"))
});
}
var chunkPath = Path.Combine(tempPath, $"{i}.part.tmp");
if (!File.Exists(chunkPath))
throw new BaseException(CodeDefine.CHUNKE_NOT_FOUND);
using (var chunkStream = new FileStream(chunkPath, FileMode.Open))
{
await chunkStream.CopyToAsync(finalStream);
}
}
Directory.Delete(tempPath, true);
await _redis.KeyDeleteAsync(RedisKeys.GetUploadPartKey(taskId));
await _uploadTaskService.UpdateStatusAsync(taskId, UploadStatus.Completed);
await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[]
{
new("status", "Completed"),
new("progress", "100"),
new("url", objectName)
});
}
}
catch (Exception e) when (e is not BaseException)
{
_logger.LogError(e, e.Message);
throw new BaseException(CodeDefine.CHUNKE_COMBINE_FAIL);
}
}
public async Task<UploadPartInstructionVo> CreatePartInstructionAsync(Guid taskId, int partNumer)
{
if (await _redis.SetContainsAsync(RedisKeys.GetUploadPartKey(taskId), partNumer)){
return new UploadPartInstructionVo
{
PartNumber = partNumer,
Skip = true,
Headers = new Dictionary<string, string>()
};
}
var request = _httpContext.HttpContext!.Request;
var scheme = request.Scheme; // http 或 https
var host = request.Host.Value; // localhost:5000 或域名
var baseUrl = $"{scheme}://{host}/api/upload/local/{taskId}/parts/{partNumer}";
var headers = new Dictionary<string, string>();
headers.Add("Content-Type", "multipart/form-data");
return new UploadPartInstructionVo
{
Method = "POST",
PartNumber = partNumer,
Skip = false,
Url = baseUrl,
Headers = headers
};
}
public async Task<UploadTask> UploadSmallFileAsync(Stream stream, string fileName, string fileType, long size, string hash)
{
var taskOld = await _uploadTaskService.GetTaskAsync(hash);
if (taskOld is not null) return taskOld;
var userId = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
var objectname = ObjectNameGenerator.Generate(new ObjectNameContext
{
ContentType = fileType,
FileName = fileName,
UserId = int.Parse(userId)
});
var path = GetDownloadUrl(objectname);
// 4. 将 Stream 写入本地文件
using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
{
await stream.CopyToAsync(fileStream);
}
var task = new UploadTask
{
CreatedAt = DateTime.UtcNow,
ChunkSize = (int)size,
ContentType = fileType,
FileHash = hash,
FileName = fileName,
FileSize = size,
Id = Guid.NewGuid(),
ObjectName = objectname,
ProviderUploadId = Guid.NewGuid().ToString(),
Status = UploadStatus.Completed,
StorageProvider = ProviderName,
TotalChunks = 1
};
await _uploadTaskService.AddAsync(task);
return task;
}
public string GetDownloadUrl(string objectname)
{
var baseDir = Path.Combine(_env.ContentRootPath, "uploads"); // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字)
var finalPath = Path.Combine(baseDir, "files", objectname);
var finalDir = Path.GetDirectoryName(finalPath);
Directory.CreateDirectory(finalDir);
return finalPath;
}
public async Task<CreateUploadTaskVo> InitTaskAsync(CreateUploadTaskDto dto)
{
var userId = _httpContext.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
UploadTask task = _mapper.Map<UploadTask>(dto);
var taskOld = await _uploadTaskService.GetTaskAsync(dto.FileHash);
if(taskOld != null)
{
var t = _mapper.Map<CreateUploadTaskVo>(taskOld);
t.Skip = false;
if (taskOld.Status == UploadStatus.Completed)
{
t.Skip = true;
}
return t;
task = taskOld;
}
task.ObjectName = ObjectNameGenerator.Generate(new ObjectNameContext
{
ContentType = task.ContentType,
FileName = task.FileName,
UserId = int.Parse(userId)
});
task.StorageProvider = ProviderName;
task.ProviderUploadId = Guid.NewGuid().ToString();
task.ChunkSize = _options.ChunkSize;
task.TotalChunks = (int)Math.Ceiling((double)task.FileSize / _options.ChunkSize);
await _uploadTaskService.AddAsync(task);
return _mapper.Map<CreateUploadTaskVo>(task);
}
}
}
+50 -3
View File
@@ -10,9 +10,11 @@ using IM_API.Tools;
using IM_API.VOs.Message;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using StackExchange.Redis;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using static MassTransit.Monitoring.Performance.BuiltInCounters;
using static Microsoft.EntityFrameworkCore.DbLoggerCategory;
@@ -28,10 +30,13 @@ namespace IM_API.Services
private readonly IPublishEndpoint _endpoint;
private readonly ISequenceIdService _sequenceIdService;
private readonly IUserService _userService;
private readonly IUploadTaskService _uploadService;
private readonly IHttpContextAccessor _httpContextAccessor;
public MessageService(
ImContext context, ILogger<MessageService> logger, IMapper mapper,
IPublishEndpoint publishEndpoint, ISequenceIdService sequenceIdService,
IUserService userService
IUserService userService, IUploadTaskService uploadTaskService,
IHttpContextAccessor httpContextAccessor
)
{
_context = context;
@@ -41,6 +46,8 @@ namespace IM_API.Services
_endpoint = publishEndpoint;
_sequenceIdService = sequenceIdService;
_userService = userService;
_uploadService = uploadTaskService;
_httpContextAccessor = httpContextAccessor;
}
public async Task<List<MessageBaseVo>> GetMessagesAsync(int userId,MessageQueryDto dto)
@@ -91,6 +98,12 @@ namespace IM_API.Services
foreach (var item in messages)
{
if(item.Type != MessageMsgType.Text)
{
var request = _httpContextAccessor.HttpContext?.Request;
var baseUrl = $"{request.Scheme}://{request.Host}";
item.Content = UrlTools.ProcessMessageUrl(item.Content, baseUrl);
}
if(userDict.TryGetValue(item.SenderId, out var user))
{
item.SenderName = user.NickName;
@@ -143,7 +156,10 @@ namespace IM_API.Services
var message = _mapper.Map<Message>(dto);
message.StreamKey = StreamKeyBuilder.Group(groupId);
message.SequenceId = await _sequenceIdService.GetNextSquenceIdAsync(message.StreamKey);
await _endpoint.Publish(_mapper.Map<MessageCreatedEvent>(message));
var publishData = _mapper.Map<MessageCreatedEvent>(message);
var request = _httpContextAccessor.HttpContext?.Request;
publishData.BaseUrl = $"{request.Scheme}://{request.Host}";
await _endpoint.Publish(publishData);
return _mapper.Map<MessageBaseVo>(message);
}
@@ -156,9 +172,40 @@ namespace IM_API.Services
var message = _mapper.Map<Message>(dto);
message.StreamKey = StreamKeyBuilder.Private(senderId, receiverId);
message.SequenceId = await _sequenceIdService.GetNextSquenceIdAsync(message.StreamKey);
await _endpoint.Publish(_mapper.Map<MessageCreatedEvent>(message));
var publishData = _mapper.Map<MessageCreatedEvent>(message);
var request = _httpContextAccessor.HttpContext?.Request;
publishData.BaseUrl = $"{request.Scheme}://{request.Host}";
await _endpoint.Publish(publishData);
return _mapper.Map<MessageBaseVo>(message);
}
#endregion
public async Task<MessageBaseDto> HandleFileMessageContentAsync(MessageBaseDto dto)
{
if(dto.Type == MessageMsgType.Text)
{
return dto;
}
var dic = JsonConvert.DeserializeObject<Dictionary<string, object>>(dto.Content);
if (dic == null || !dic.TryGetValue("fileId", out var fileIdObj))
throw new BaseException(CodeDefine.PARAMETER_ERROR);
var fileInfo = await _uploadService.GetTaskAsync(new Guid(fileIdObj.ToString()));
if (fileInfo is null)
throw new BaseException(CodeDefine.FILE_NOT_FOUND);
dic["url"] = fileInfo.ObjectName;
dic["provider"] = fileInfo.StorageProvider;
dic["size"] = fileInfo.FileSize;
dto.Content = JsonConvert.SerializeObject(dic);
return dto;
}
}
}
@@ -0,0 +1,43 @@
using IM_API.Interface.Services;
using IM_API.Models;
using IM_API.Models.Upload;
using Microsoft.EntityFrameworkCore;
namespace IM_API.Services
{
public class UploadTaskService : IUploadTaskService
{
private readonly ImContext _context;
public UploadTaskService(ImContext context)
{
_context = context;
}
public async Task AddAsync(UploadTask task)
{
_context.UploadTasks.Add(task);
await _context.SaveChangesAsync();
}
public async Task<UploadTask?> GetTaskAsync(Guid taskId)
{
return await _context.UploadTasks.FirstOrDefaultAsync(x => x.Id == taskId);
}
public async Task<UploadTask?> GetTaskAsync(string hash)
{
return await _context.UploadTasks.FirstOrDefaultAsync(x => x.FileHash == hash);
}
public async Task UpdateStatusAsync(Guid taskId, UploadStatus status)
{
var task = await _context.UploadTasks.FirstOrDefaultAsync(x => x.Id == taskId);
if (task != null)
{
task.Status = status;
_context.UploadTasks.Update(task);
await _context.SaveChangesAsync();
}
}
}
}
+6
View File
@@ -105,5 +105,11 @@
// 3.9 会话相关错误(3100 ~ 3199
/// <summary>发送时异常</summary>
public static CodeDefine CONVERSATION_NOT_FOUND = new CodeDefine(3100, "会话不存在");
// 3.9 文件相关错误(3200 ~ 3299
/// <summary>分片不存在异常</summary>
public static CodeDefine CHUNKE_NOT_FOUND = new CodeDefine(3201, "分片不存在");
/// <summary>分片合并异常</summary>
public static CodeDefine CHUNKE_COMBINE_FAIL = new CodeDefine(3202, "分片合并失败");
}
}
@@ -0,0 +1,51 @@
namespace IM_API.Tools
{
public static class ObjectNameGenerator
{
public static string Generate(ObjectNameContext ctx)
{
var ext = GetExtension(ctx.FileName, ctx.ContentType);
var shortId = Guid.NewGuid().ToString("N")[..12];
var parts = new List<string>
{
ctx.Biz,
ctx.Now.Year.ToString(),
ctx.Now.Month.ToString("D2")
};
if (ctx.UserId.HasValue)
{
parts.Add(ctx.UserId.Value.ToString());
}
parts.Add($"{shortId}{ext}");
return string.Join("/", parts);
}
private static string GetExtension(string fileName, string contentType)
{
var ext = Path.GetExtension(fileName);
if (!string.IsNullOrWhiteSpace(ext))
return ext.ToLowerInvariant();
return contentType switch
{
"image/jpeg" => ".jpg",
"image/png" => ".png",
"video/mp4" => ".mp4",
_ => ".bin"
};
}
}
public class ObjectNameContext
{
public string Biz { get; init; } = "IM";
public long? UserId { get; init; }
public string FileName { get; init; } = default!;
public string ContentType { get; init; } = default!;
public DateTimeOffset Now { get; init; } = DateTimeOffset.UtcNow;
}
}
+8 -5
View File
@@ -2,10 +2,13 @@
{
public static class RedisKeys
{
public static string GetUserinfoKey(string userId) => $"user::uinfo::{userId}";
public static string GetUserinfoKeyByUsername(string username) => $"user::uinfobyid::{username}";
public static string GetSequenceIdKey(string streamKey) => $"chat::seq::{streamKey}";
public static string GetSequenceIdLockKey(string streamKey) => $"lock::seq::{streamKey}";
public static string GetConnectionIdKey(string userId) => $"signalr::user::con::{userId}";
public static string GetUserinfoKey(string userId) => $"user:uinfo:{userId}";
public static string GetUserinfoKeyByUsername(string username) => $"user:uinfobyid:{username}";
public static string GetSequenceIdKey(string streamKey) => $"chat:seq:{streamKey}";
public static string GetSequenceIdLockKey(string streamKey) => $"lock:seq:{streamKey}";
public static string GetConnectionIdKey(string userId) => $"signalr:user:con:{userId}";
public static string GetUploadPartKey(Guid taskId) => $"upload:task:{taskId}:parts";
public static string MergeStatus(Guid taskId) => $"upload:task:{taskId}:merge";
}
}
+64
View File
@@ -0,0 +1,64 @@
using SixLabors.ImageSharp;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace IM_API.Tools
{
public static class UrlTools
{
public static string GetFullUrl(string objectName, string provider, string? baseUrl)
{
return provider switch
{
"Local" => $"{baseUrl}/uploads/files/{objectName}",
_ => "http://baidu.com",
};
}
public static async Task<(int width, int height)> GetImageWH(string url)
{
using var httpClient = new HttpClient();
var stream = await httpClient.GetStreamAsync(url);
var info = await Image.IdentifyAsync(stream);
return (info.Width, info.Height);
}
public static string ProcessMessageUrl(string contentJson, string? localBaseUrl)
{
// 1. 解析 JSON 文档(比反序列化快得多)
using var doc = JsonDocument.Parse(contentJson);
var root = doc.RootElement;
// 2. 获取 Provider 字段
string provider = root.GetProperty("provider").GetString();
// 3. 根据 Provider 决定前缀
string prefix = GetFullUrl("", provider, localBaseUrl);
// 4. 重新组装(如果只是为了给前端看,建议直接返回带前缀的对象或字符串)
// 这里推荐用 JsonNode 方便修改并返回字符串
var node = JsonNode.Parse(contentJson);
node["url"] = $"{prefix}{node["url"]}";
node["thumb"] = $"{prefix}{node["thumb"]}";
return node.ToJsonString();
}
public static Stream Base64ToStream(string base64String)
{
if (string.IsNullOrEmpty(base64String))
throw new ArgumentNullException(nameof(base64String));
// 1. 自动处理可能存在的 Base64 Data URL 前缀
string base64Data = base64String.Contains(",")
? base64String.Split(',')[1]
: base64String;
// 2. 解码为字节数组
byte[] bytes = Convert.FromBase64String(base64Data);
// 3. 包装进 MemoryStream
// 注意:这里直接把 Position 设为 0,符合“方法a”产生即用的原则
return new MemoryStream(bytes);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using IM_API.Interface.Services;
namespace IM_API.VOs
{
public class CreateUploadTaskVo
{
public Guid TaskId { get; set; }
public int ChunkSize { get; set; }
public int TotalChunks { get; set; }
public int Concurrency { get; set; } = 4;
public string? Url { get; set; }
public bool Skip { get; set; }
}
}
@@ -0,0 +1,14 @@
namespace IM_API.VOs
{
public class UploadPartInstructionVo
{
public bool Skip { get; set; }
public int PartNumber { get; set; }
public string Method { get; set; } = "PUT";
public string Url { get; set; } = default!;
public Dictionary<string, string> Headers { get; set; } = new();
}
}
+5 -1
View File
@@ -14,7 +14,7 @@
"RefreshTokenDays": 30
},
"ConnectionStrings": {
"DefaultConnection": "Server=frp-era.com;Port=26582;Database=IM;User=product;Password=12345678;",
"DefaultConnection": "Server=192.168.5.100;Port=3306;Database=IM;User=product;Password=12345678;",
"Redis": "192.168.5.100:6379"
},
"RabbitMQOptions": {
@@ -22,5 +22,9 @@
"Port": 5672,
"Username": "test",
"Password": "123456"
},
"FileUploadOptions": {
"DefaultStorage": "Local",
"ChunkSize": 5000000,
}
}