add:
更新
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.vs/
|
||||
uploads/
|
||||
+7
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public class UploadPartDto
|
||||
{
|
||||
public int PartNumber { get; set; }
|
||||
public string? ETag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user