Files
MyHomepage/backend/Services/UploadService.cs
T
g82tt 68be41e7a2 初始提交:浏览器首页 MyHomePage 全栈项目
# 项目概述
个人浏览器首页导航应用,支持书签分类管理、搜索引擎快捷搜索、
必应每日壁纸轮播、前后端分离部署,适配 1Panel 服务器(Docker 模式)。

# 技术栈
- 前端:Vue 3 + TypeScript + Vite + Pinia + Capacitor(Android 打包)
- 后端:.NET 8 + SqlSugar(多数据库) + SQLite/MySQL + Swashbuckle
- 部署:1Panel 应用商店自定义应用(Docker Compose 模式)

# 项目结构
- backend/    .NET 8 API 后端(8 个 Controller + 15 个 Service)
- frontend/   Vue 3 前端(19 个组件 + 9 个 API 模块 + 5 个 Store)
- docker/     Docker 部署文件(后端镜像 + Nginx 反代)
- docs/       部署手册(1Panel 实战版)
- scripts/    E2E 测试脚本

# 已实现功能
- 书签管理:增删改查 + 树形分类 + 拖拽排序 + 主色自适应
- 搜索引擎:8 个内置引擎 + 自定义引擎 + favicon 自动抓取
- 必应壁纸:每日轮播 + 多分辨率自动选择 + 1.6MP 质量优先
- 全局设置:主题/行为/数据/工具 4 分类 + 跨设备同步
- 文件上传:图标/书签/通用(容器持久化 + 跨域 URL 拼接)
- 同步:基于变更日志的设备间数据同步
- 跨域部署:前后端分离 + runtime config.json 无需重新编译

# 进度记录
- 已完成 P0~P52 共 53 个开发节点(详细见 说明文档.md)
- 当前版本:v1.0 部署就绪

# 部署文档
- README.md:项目说明 + 快速开始
- 说明文档.md:完整开发进度(中文)
- docs/DEPLOY.md:1Panel 部署手册(Docker 模式)
2026-07-05 05:09:56 +08:00

145 lines
6.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.Options;
using MyHomePage.Api.Common;
using MyHomePage.Api.Infrastructure.Configuration;
using MyHomePage.Api.Models.Dtos;
namespace MyHomePage.Api.Services;
/// <inheritdoc />
public class UploadService : IUploadService
{
/// <summary>允许的图片扩展名白名单(与 SaveAsync 共用)</summary>
private static readonly string[] AllowedExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico" };
/// <summary>Content-Type 到扩展名的兜底映射</summary>
private static readonly Dictionary<string, string> 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<UploadService> _logger;
/// <summary>P51 诊断:是否已记录过 upload root(避免每次保存重复 log</summary>
private bool _rootLogged;
public UploadService(
IOptions<UploadOptions> options,
IWebHostEnvironment env,
ILogger<UploadService> logger)
{
_options = options.Value;
_env = env;
_logger = logger;
}
/// <inheritdoc />
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;
}
/// <summary>P51 诊断兼容:保留旧方法名(Program.cs 启动期若想强制 log 可调)</summary>
public string EnsureRootWithLog() => EnsureRoot();
/// <inheritdoc />
public async Task<UploadResultDto> 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);
}
/// <inheritdoc />
public async Task<UploadResultDto> 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);
}
/// <summary>实际写文件的内部流程(SaveAsync / SaveStreamAsync 共用)</summary>
private async Task<UploadResultDto> 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
};
}
}