using MyHomePage.Api.Common; using MyHomePage.Api.Models.Dtos; using MyHomePage.Api.Models.Entities; using SqlSugar; namespace MyHomePage.Api.Services; /// public class BookmarkService : IBookmarkService { /// 判定「用户未指定图标」的默认值集合。匹配其中之一则视为未指定,触发 favicon 自动抓取。 private static readonly HashSet DefaultIconNames = new(StringComparer.OrdinalIgnoreCase) { "link", "globe", "bookmark", "", null! // null/empty 也算未指定 }; private readonly ISqlSugarClient _db; private readonly SyncLogHelper _sync; private readonly FaviconService _favicon; public BookmarkService(ISqlSugarClient db, SyncLogHelper sync, FaviconService favicon) { _db = db; _sync = sync; _favicon = favicon; } /// public async Task> ListAsync(int? categoryId = null) { var query = _db.Queryable().Where(b => !b.IsDeleted); if (categoryId.HasValue) query = query.Where(b => b.CategoryId == categoryId.Value); var list = await query .OrderBy(b => b.Sort) .OrderBy(b => b.Id) .ToListAsync(); return list.Select(ToDto).ToList(); } /// public async Task GetByIdAsync(int id) { var b = await _db.Queryable().InSingleAsync(id); return b is null || b.IsDeleted ? null : ToDto(b); } /// public async Task CreateAsync(BookmarkUpsertRequest request) { Validate(request); // 校验分类存在 var catExists = await _db.Queryable().AnyAsync(c => c.Id == request.CategoryId); if (!catExists) throw new BusinessException("分类不存在", 400); var now = DateTime.UtcNow; var entity = new Bookmark { CategoryId = request.CategoryId, Title = request.Title.Trim(), Url = request.Url.Trim(), Description = request.Description?.Trim(), Icon = request.Icon, IconType = request.IconType ?? "lucide", IconUrl = request.IconUrl, ColorBg = NormalizeColor(request.ColorBg), Sort = request.Sort, IsDeleted = false, CreatedAt = now, UpdatedAt = now }; entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync(); // P31:未指定图标时自动抓取网站 favicon(失败静默用默认,不影响主记录创建) await MaybeFetchFaviconAsync(entity); await _sync.WriteAsync("bookmark", entity.Id, "create"); return ToDto(entity); } /// public async Task UpdateAsync(int id, BookmarkUpsertRequest request) { Validate(request); var entity = await _db.Queryable().InSingleAsync(id) ?? throw new BusinessException("链接不存在", 404); entity.CategoryId = request.CategoryId; entity.Title = request.Title.Trim(); entity.Url = request.Url.Trim(); entity.Description = request.Description?.Trim(); entity.Icon = request.Icon; entity.IconType = request.IconType ?? "lucide"; entity.IconUrl = request.IconUrl; entity.ColorBg = NormalizeColor(request.ColorBg); // P28 修复:原代码漏了 ColorBg,导致 PUT 后仍是旧值 entity.Sort = request.Sort; entity.UpdatedAt = DateTime.UtcNow; await _db.Updateable(entity).ExecuteCommandAsync(); // P31:URL 变了 或 图标从「自定义」回到「未指定」时,重新触发 favicon 抓取 if (IsIconUnspecified(entity)) { await MaybeFetchFaviconAsync(entity); } await _sync.WriteAsync("bookmark", id, "update"); return ToDto(entity); } /// /// P31:判定链接是否「未指定图标」(即需要自动抓 favicon 的状态): /// - iconUrl 为空(用户没上传图片) /// - iconType 为 lucide 或 null(即非 image / 非 emoji) /// - icon 字段是默认值("link" / "globe" / "bookmark" / 空) /// private static bool IsIconUnspecified(Bookmark b) { if (!string.IsNullOrEmpty(b.IconUrl)) return false; if (b.IconType == "image" || b.IconType == "emoji") return false; var name = (b.Icon ?? "").Trim(); return DefaultIconNames.Contains(name); } /// /// P31:抓取并写入 favicon。失败静默(不影响主流程)。 /// 成功后:entity.IconType = "favicon",entity.IconUrl = /uploads/yyyy/MM/dd/favicons/xxx.ext /// private async Task MaybeFetchFaviconAsync(Bookmark entity) { if (!IsIconUnspecified(entity)) return; try { var iconUrl = await _favicon.FetchAndSaveAsync(entity.Url); if (!string.IsNullOrEmpty(iconUrl)) { entity.IconType = "favicon"; entity.IconUrl = iconUrl; entity.Icon = null; entity.UpdatedAt = DateTime.UtcNow; await _db.Updateable(entity) .UpdateColumns(it => new { it.IconType, it.IconUrl, it.Icon, it.UpdatedAt }) .ExecuteCommandAsync(); } } catch { // 静默吞掉异常(favicon 抓取失败不影响链接创建/更新) } } /// public async Task DeleteAsync(int id) { var entity = await _db.Queryable().InSingleAsync(id) ?? throw new BusinessException("链接不存在", 404); entity.IsDeleted = true; entity.UpdatedAt = DateTime.UtcNow; await _db.Updateable(entity).ExecuteCommandAsync(); await _sync.WriteAsync("bookmark", id, "delete"); } private static void Validate(BookmarkUpsertRequest req) { if (string.IsNullOrWhiteSpace(req.Title)) throw new BusinessException("标题不能为空", 400); if (string.IsNullOrWhiteSpace(req.Url)) throw new BusinessException("URL 不能为空", 400); if (req.Title.Length > 128) throw new BusinessException("标题不能超过 128 字符", 400); if (req.Url.Length > 512) throw new BusinessException("URL 过长", 400); if (!Uri.TryCreate(req.Url, UriKind.Absolute, out _)) throw new BusinessException("URL 格式不正确", 400); } private static BookmarkDto ToDto(Bookmark b) => BookmarkDto.FromEntity(b); /// /// 规范化颜色:空串视为 null;仅保留 #hex / rgb(...) / hsl(...) 格式。无效则置 null。 /// 长度上限 32(够 rgb / hsl / 短 hex / 长 hex)。 /// private static string? NormalizeColor(string? color) { if (string.IsNullOrWhiteSpace(color)) return null; var c = color.Trim(); if (c.Length > 32) return null; if (c.StartsWith('#') && (c.Length == 4 || c.Length == 7 || c.Length == 9)) return c; if (c.StartsWith("rgb", StringComparison.OrdinalIgnoreCase) && c.EndsWith(')')) return c; if (c.StartsWith("hsl", StringComparison.OrdinalIgnoreCase) && c.EndsWith(')')) return c; return null; } }