using Microsoft.Extensions.Options;
using MyHomePage.Api.Common;
using MyHomePage.Api.Infrastructure.Configuration;
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
///
public class UploadService : IUploadService
{
/// 允许的图片扩展名白名单(与 SaveAsync 共用)
private static readonly string[] AllowedExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico" };
/// Content-Type 到扩展名的兜底映射
private static readonly Dictionary ContentTypeToExt = new(StringComparer.OrdinalIgnoreCase)
{
["image/png"] = ".png",
["image/jpeg"] = ".jpg",
["image/jpg"] = ".jpg",
["image/gif"] = ".gif",
["image/webp"] = ".webp",
["image/svg+xml"] = ".svg",
["image/x-icon"] = ".ico",
["image/vnd.microsoft.icon"] = ".ico",
["image/ico"] = ".ico",
};
private readonly UploadOptions _options;
private readonly IWebHostEnvironment _env;
private readonly ILogger _logger;
/// P51 诊断:是否已记录过 upload root(避免每次保存重复 log)
private bool _rootLogged;
public UploadService(
IOptions options,
IWebHostEnvironment env,
ILogger logger)
{
_options = options.Value;
_env = env;
_logger = logger;
}
///
public string EnsureRoot()
{
var root = Path.IsPathRooted(_options.Path)
? _options.Path
: Path.Combine(_env.ContentRootPath, _options.Path);
Directory.CreateDirectory(root);
// P51 诊断:第一次调用时 log 一次实际 root(暴露容器内 /uploads 路径覆盖问题)
if (!_rootLogged)
{
_rootLogged = true;
_logger.LogInformation(
"Upload root resolved: Path='{Path}' (IsRooted={IsRooted}) → Actual='{Actual}' | env={Env}",
_options.Path, Path.IsPathRooted(_options.Path), root, _env.EnvironmentName);
}
return root;
}
/// P51 诊断兼容:保留旧方法名(Program.cs 启动期若想强制 log 可调)
public string EnsureRootWithLog() => EnsureRoot();
///
public async Task SaveAsync(IFormFile file)
{
if (file is null || file.Length == 0) throw new BusinessException("文件为空", 400);
if (file.Length > _options.MaxSizeBytes) throw new BusinessException($"文件大小超过限制({_options.MaxSizeBytes / 1024 / 1024}MB)", 400);
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) throw new BusinessException("文件必须包含扩展名", 400);
if (Array.IndexOf(AllowedExtensions, ext) < 0) throw new BusinessException("仅支持图片格式", 400);
await using var stream = file.OpenReadStream();
return await SaveStreamInternalAsync(stream, file.FileName, file.ContentType, ext, subDir: null);
}
///
public async Task SaveStreamAsync(Stream stream, string fileName, string contentType, string? subDir = null)
{
if (stream is null) throw new BusinessException("数据流为空", 400);
// 推断扩展名:优先文件名 → 兜底 content-type
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext))
{
if (string.IsNullOrEmpty(contentType)) throw new BusinessException("无法推断文件扩展名", 400);
if (!ContentTypeToExt.TryGetValue(contentType, out ext))
throw new BusinessException($"不支持的内容类型:{contentType}", 400);
}
if (Array.IndexOf(AllowedExtensions, ext) < 0) throw new BusinessException("仅支持图片格式", 400);
return await SaveStreamInternalAsync(stream, fileName, contentType, ext, subDir);
}
/// 实际写文件的内部流程(SaveAsync / SaveStreamAsync 共用)
private async Task SaveStreamInternalAsync(Stream stream, string fileName, string contentType, string ext, string? subDir)
{
var root = EnsureRoot();
// 按日期分目录:2026/07/04[/favicons]
var datePath = DateTime.UtcNow.ToString("yyyy/MM/dd");
var relativeDir = string.IsNullOrEmpty(subDir) ? datePath : Path.Combine(datePath, subDir);
var dir = Path.Combine(root, relativeDir);
var name = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{Guid.NewGuid():N}{ext}";
var fullPath = Path.Combine(dir, name);
// P51 诊断:把 IO 异常(容器权限/路径/磁盘满)原样抛出 + 完整上下文日志
try
{
Directory.CreateDirectory(dir);
await using (var fs = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
await stream.CopyToAsync(fs);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Upload save failed: file={File}, contentType={ContentType}, ext={Ext}, subDir={SubDir}, " +
"UploadOptions.Path={OptPath} (IsRooted={IsRooted}), ContentRoot={ContentRoot}, env={Env}, " +
"computed root={Root}, dir={Dir}, fullPath={FullPath}",
fileName, contentType, ext, subDir,
_options.Path, Path.IsPathRooted(_options.Path), _env.ContentRootPath, _env.EnvironmentName,
root, dir, fullPath);
throw new BusinessException($"文件保存失败: {ex.GetType().Name}: {ex.Message}", 500);
}
var relative = Path.Combine(relativeDir, name).Replace('\\', '/');
var url = (_options.BaseUrl ?? "/uploads").TrimEnd('/') + "/" + relative;
_logger.LogInformation("Upload saved: {Path} ({ContentType})", fullPath, contentType);
return new UploadResultDto
{
Path = relative,
Url = url,
FileName = fileName,
Size = new FileInfo(fullPath).Length
};
}
}