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