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 }; } }