初始提交:浏览器首页 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 模式)
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user