diff --git a/Pages/Error.cshtml.cs b/Pages/Error.cshtml.cs index e06372f..7125375 100644 --- a/Pages/Error.cshtml.cs +++ b/Pages/Error.cshtml.cs @@ -12,6 +12,9 @@ public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + /// + /// 填充请求追踪 ID。 + /// public void OnGet() { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; diff --git a/Pages/Folder.cshtml.cs b/Pages/Folder.cshtml.cs index d880968..4b1a7d3 100644 --- a/Pages/Folder.cshtml.cs +++ b/Pages/Folder.cshtml.cs @@ -29,8 +29,12 @@ public string BackUrl => Url.Page("Index", null, new { keyword = Keyword, p = ReturnPage }) ?? Url.Page("Index") ?? "/"; + /// + /// 加载指定文件夹详情与图片列表。 + /// public async Task OnGetAsync(Guid id, string? keyword, int? p) { + // 归一化查询参数,便于返回时保持状态。 Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim(); ReturnPage = p is > 1 ? p : null; @@ -43,6 +47,7 @@ if (!string.IsNullOrWhiteSpace(Folder.Keywords)) { + // 展示用关键词列表。 Keywords = Folder.Keywords .Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToArray(); @@ -59,6 +64,7 @@ return; } + // 构建图片列表与缩略图 URL。 var images = Directory.GetFiles(folderPath) .Where(path => ImageExtensions.Contains(Path.GetExtension(path))) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) @@ -74,11 +80,15 @@ Images = images; } + /// + /// 返回指定图片的原图文件。 + /// public async Task OnGetImageAsync(Guid id, string file) { if (string.IsNullOrWhiteSpace(_rootPath) || string.IsNullOrWhiteSpace(file)) return NotFound(); + // 仅允许安全文件名与图片扩展名。 var safeFile = Path.GetFileName(file); if (string.IsNullOrWhiteSpace(safeFile)) return NotFound(); @@ -94,6 +104,7 @@ if (!System.IO.File.Exists(filePath)) return NotFound(); + // 根据扩展名获取 Content-Type。 var provider = new FileExtensionContentTypeProvider(); if (!provider.TryGetContentType(filePath, out var contentType)) contentType = "application/octet-stream"; @@ -101,11 +112,15 @@ return PhysicalFile(filePath, contentType); } + /// + /// 返回指定图片的缩略图,必要时会尝试生成。 + /// public async Task OnGetThumbnailAsync(Guid id, string file) { if (string.IsNullOrWhiteSpace(file)) return NotFound(); + // 仅允许安全文件名与图片扩展名。 var safeFile = Path.GetFileName(file); if (string.IsNullOrWhiteSpace(safeFile)) return NotFound(); @@ -123,6 +138,7 @@ if (!System.IO.File.Exists(thumbPath)) { + // 缩略图不存在时尝试生成。 await _thumbnailService.EnsureThumbnailAsync(folder.Id, folder.FolderName, safeFile, HttpContext.RequestAborted); } diff --git a/Pages/Index.cshtml.cs b/Pages/Index.cshtml.cs index e21e06d..cde49a3 100644 --- a/Pages/Index.cshtml.cs +++ b/Pages/Index.cshtml.cs @@ -36,14 +36,19 @@ public int TotalPages { get; private set; } = 1; public int PageSize { get; } = 30; + /// + /// 加载首页列表与分页数据。 + /// public async Task OnGetAsync(string? keyword, int p = 1) { + // 归一化检索关键词与页码。 Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim(); PageNumber = Math.Max(1, p); var query = _dbContext.FanboxFolders.AsNoTracking(); if (!string.IsNullOrWhiteSpace(Keyword)) { + // 关键词模糊匹配数据库字段。 var pattern = $"%{Keyword}%"; query = query.Where(x => x.Keywords != null && EF.Functions.Like(x.Keywords, pattern)); } @@ -73,6 +78,7 @@ var folderPath = Path.Combine(_rootPath, folder.FolderName); if (Directory.Exists(folderPath)) { + // 优先以 0.* 图片作为封面。 coverFile = Directory.EnumerateFiles(folderPath) .Select(Path.GetFileName) .FirstOrDefault(name => @@ -98,6 +104,9 @@ Folders = cards; } + /// + /// 触发同步任务,将新的目录写入数据库。 + /// public async Task OnPostUpdateAsync() { if (string.IsNullOrWhiteSpace(_rootPath) || string.IsNullOrWhiteSpace(_author)) @@ -112,6 +121,9 @@ return RedirectToPage(); } + /// + /// 为指定文件夹生成缺失缩略图并返回数量。 + /// public async Task OnGetEnsureThumbnailsAsync(Guid id) { var folder = await _dbContext.FanboxFolders.AsNoTracking() @@ -125,11 +137,15 @@ return new JsonResult(new { created }); } + /// + /// 返回指定图片的原图文件。 + /// public async Task OnGetImageAsync(Guid id, string file) { if (string.IsNullOrWhiteSpace(_rootPath) || string.IsNullOrWhiteSpace(file)) return NotFound(); + // 仅允许安全文件名与图片扩展名。 var safeFile = Path.GetFileName(file); if (string.IsNullOrWhiteSpace(safeFile)) return NotFound(); @@ -145,6 +161,7 @@ if (!System.IO.File.Exists(filePath)) return NotFound(); + // 根据扩展名获取 Content-Type。 var provider = new FileExtensionContentTypeProvider(); if (!provider.TryGetContentType(filePath, out var contentType)) contentType = "application/octet-stream"; diff --git a/Program.cs b/Program.cs index 7318a08..d8abc96 100644 --- a/Program.cs +++ b/Program.cs @@ -3,7 +3,7 @@ var builder = WebApplication.CreateBuilder(args); -// Add services to the container. +// 注册应用所需服务。 builder.Services.AddRazorPages(); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); @@ -12,20 +12,22 @@ var app = builder.Build(); -// Configure the HTTP request pipeline. +// 配置 HTTP 请求管线。 if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + // 非开发环境启用 HSTS,默认 30 天。 app.UseHsts(); } +// 强制 HTTPS 重定向。 app.UseHttpsRedirection(); app.UseRouting(); app.UseAuthorization(); +// 映射静态资源与 Razor Pages 路由。 app.MapStaticAssets(); app.MapRazorPages() .WithStaticAssets(); diff --git a/Services/AppDbContext.cs b/Services/AppDbContext.cs index 8c2da2f..dcff66a 100644 --- a/Services/AppDbContext.cs +++ b/Services/AppDbContext.cs @@ -5,16 +5,23 @@ public sealed class AppDbContext : DbContext { + /// + /// 初始化应用数据库上下文。 + /// public AppDbContext(DbContextOptions options) : base(options) { } public DbSet FanboxFolders => Set(); + /// + /// 配置模型映射与字段约束。 + /// protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + // FanboxFolder 表结构与字段限制。 var entity = modelBuilder.Entity(); entity.ToTable("FanboxFolders"); entity.HasKey(x => x.Id); diff --git a/Services/SyncService.cs b/Services/SyncService.cs index 691eb37..829cebc 100644 --- a/Services/SyncService.cs +++ b/Services/SyncService.cs @@ -8,11 +8,15 @@ { private readonly AppDbContext _dbContext = dbContext; + /// + /// 扫描指定作者的 Fanbox 目录并同步新文件夹到数据库。 + /// public async Task SyncFanboxFoldersAsync( string root, string author, CancellationToken cancellationToken = default) { + // 入口参数校验,确保根路径与作者信息有效。 if (string.IsNullOrWhiteSpace(root)) throw new ArgumentException("Root path is required.", nameof(root)); if (string.IsNullOrWhiteSpace(author)) @@ -21,6 +25,7 @@ var inserted = 0; var pending = new List(); + // 预加载已有关键词,后续用于关键词复用与命中。 var existingKeywords = await LoadExistingKeywordsAsync(cancellationToken); foreach (var dir in Directory.GetDirectories(root)) @@ -28,10 +33,12 @@ cancellationToken.ThrowIfCancellationRequested(); var folderName = Path.GetFileName(dir); + // 约定:以 yyyy-MM-dd- 开头的目录才参与同步。 if (folderName.Length < 11 || folderName[10] != '-') continue; var datePart = folderName.Substring(0, 10); + // 解析目录名前 10 位日期,失败则跳过。 if (!DateTime.TryParseExact( datePart, "yyyy-MM-dd", @@ -41,15 +48,19 @@ continue; var title = folderName.Substring(11); + // 组合标题与已有关键词,生成本次关键词列表。 var keywordsStr = BuildKeywords(title, existingKeywords); + // 统计目录内文件数量,用于展示/校验。 var fileCount = Directory.GetFiles(dir).Length; + // 数据库中已存在相同作者 + 日期 + 标题时跳过。 var exists = await _dbContext.FanboxFolders.AsNoTracking().AnyAsync( x => x.Author == author && x.Date == date && x.Title == title, cancellationToken); if (exists) continue; + // 待插入列表先暂存,最后一次性写入。 pending.Add(new FanboxFolder { FolderName = folderName, @@ -64,16 +75,22 @@ if (pending.Count == 0) return 0; + // 批量写入并返回实际插入数量。 _dbContext.FanboxFolders.AddRange(pending); inserted = await _dbContext.SaveChangesAsync(cancellationToken); return inserted; } + /// + /// 从标题中抽取关键词,并结合已有关键词集合进行补全。 + /// private static string? BuildKeywords(string title, IReadOnlyCollection existingKeywords) { + // 使用 HashSet 去重,保持关键词唯一性。 var hit = new HashSet(StringComparer.Ordinal); + // 统一替换常见分隔符,便于分词。 var cleaned = title .Replace("【", " ") .Replace("】", " ") @@ -87,12 +104,14 @@ .Replace("_", " ") .Replace("-", " "); + // 以空格切分并过滤短词,避免噪声。 foreach (var w in cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { if (w.Length >= 2) hit.Add(w); } + // 如果标题包含已有关键词,则补充命中。 foreach (var k in existingKeywords) { if (title.Contains(k, StringComparison.Ordinal)) @@ -102,6 +121,9 @@ return hit.Count > 0 ? string.Join(",", hit) : null; } + /// + /// 从数据库中加载并去重全部历史关键词。 + /// private async Task> LoadExistingKeywordsAsync(CancellationToken cancellationToken) { var keywordLists = await _dbContext.FanboxFolders.AsNoTracking() @@ -110,6 +132,7 @@ .Distinct() .ToListAsync(cancellationToken); + // 逐条拆分关键词字符串,聚合到唯一集合。 var keywords = new HashSet(StringComparer.Ordinal); foreach (var list in keywordLists) { diff --git a/Services/ThumbnailService.cs b/Services/ThumbnailService.cs index 3ceb9a7..f01296d 100644 --- a/Services/ThumbnailService.cs +++ b/Services/ThumbnailService.cs @@ -15,6 +15,9 @@ private readonly string? _thumbnailRoot; private readonly ILogger _logger; + /// + /// 初始化缩略图服务,并确保缩略图根目录存在。 + /// public ThumbnailService(IConfiguration configuration, ILogger logger) { _rootPath = configuration["Fanbox:RootPath"]; @@ -23,6 +26,9 @@ TryEnsureRoot(); } + /// + /// 获取指定文件的缩略图路径。 + /// public string? GetThumbnailPath(Guid folderId, string fileName) { if (string.IsNullOrWhiteSpace(fileName)) @@ -30,6 +36,7 @@ return null; } + // 规避目录遍历,只保留安全文件名。 var safeName = Path.GetFileName(fileName); if (string.IsNullOrWhiteSpace(safeName)) { @@ -46,6 +53,9 @@ return Path.Combine(dir, thumbName); } + /// + /// 获取原始文件路径。 + /// public string? GetOriginalPath(string folderName, string fileName) { if (string.IsNullOrWhiteSpace(_rootPath)) @@ -62,6 +72,9 @@ return Path.Combine(_rootPath, folderName, safeName); } + /// + /// 为指定文件夹生成缺失的缩略图,返回新建数量。 + /// public async Task EnsureFolderThumbnailsAsync(Guid folderId, string folderName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_rootPath)) @@ -75,6 +88,7 @@ return 0; } + // 过滤图片文件名并按文件名排序,确保显示一致。 var files = Directory.GetFiles(folderPath) .Where(path => ImageExtensions.Contains(Path.GetExtension(path))) .Select(Path.GetFileName) @@ -87,6 +101,7 @@ } var created = 0; + // 根据 CPU 数量限制并发,避免大量图片同时解码。 var maxConcurrency = Math.Max(1, Environment.ProcessorCount); using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency); var tasks = new List(files.Length); @@ -95,6 +110,7 @@ { cancellationToken.ThrowIfCancellationRequested(); + // 已存在则跳过。 var thumbPath = GetThumbnailPath(folderId, fileName); if (thumbPath is null || File.Exists(thumbPath)) { @@ -122,6 +138,9 @@ return created; } + /// + /// 为单个文件生成缩略图,返回是否存在或成功生成。 + /// public Task EnsureThumbnailAsync(Guid folderId, string folderName, string fileName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_rootPath)) @@ -152,6 +171,7 @@ try { + // 确保缩略图目录存在,再创建文件。 Directory.CreateDirectory(Path.GetDirectoryName(thumbPath) ?? _thumbnailRoot); return TryCreateThumbnail(originalPath, thumbPath); } @@ -163,6 +183,9 @@ }, cancellationToken); } + /// + /// 读取原图并按最短边缩放生成 JPEG 缩略图。 + /// private static bool TryCreateThumbnail(string sourcePath, string thumbPath) { using var bitmap = SKBitmap.Decode(sourcePath); @@ -178,6 +201,7 @@ SKBitmap? resized = null; var target = bitmap; + // 仅在需要时缩放,避免不必要的重采样。 if (scale < 1f) { resized = bitmap.Resize(new SKImageInfo(targetWidth, targetHeight), SKFilterQuality.Medium); @@ -197,6 +221,9 @@ return true; } + /// + /// 确保缩略图根目录存在。 + /// private void TryEnsureRoot() { try