From b21bb490fadbbaadecc96cb6cdae80058c21bfbf Mon Sep 17 00:00:00 2001 From: lincube Date: Wed, 4 Mar 2026 16:43:10 +0800 Subject: [PATCH] 0.3.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 减少工程复杂度 --- .github/CODEOWNERS | 1 - .github/FIX_REPORT.md | 8 +- .github/VERSION_SYNC_INFO.md | 1 - .github/WORKFLOWS_GUIDE.md | 5 +- .github/workflows/build.yml | 3 - .github/workflows/release.yml | 9 +- ...untainDesktop.RecommendationBackend.csproj | 10 - .../Models/RecommendationDataModels.cs | 54 -- .../Program.cs | 92 --- .../Properties/launchSettings.json | 23 - .../README.md | 33 - .../Services/IRecommendationDataService.cs | 83 -- .../Services/RecommendationDataService.cs | 729 ------------------ .../appsettings.Development.json | 11 - .../appsettings.json | 22 - LanMountainDesktop.sln | 6 - LanMountainDesktop/Localization/en-US.json | 2 +- LanMountainDesktop/Localization/zh-CN.json | 2 +- .../Services/IRecommendationDataService.cs | 635 +-------------- .../Services/RecommendationDataService.cs | 358 +++++++++ .../Components/DailyArtworkWidget.axaml.cs | 15 +- .../Components/DailyPoetryWidget.axaml.cs | 13 +- .../DesktopComponentRuntimeRegistry.cs | 11 +- .../Components/IDesktopComponentWidget.cs | 5 + .../Views/MainWindow.ComponentSystem.cs | 12 +- LanMountainDesktop/Views/MainWindow.axaml.cs | 5 + run.md | 18 +- 27 files changed, 422 insertions(+), 1744 deletions(-) delete mode 100644 LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj delete mode 100644 LanMountainDesktop.RecommendationBackend/Models/RecommendationDataModels.cs delete mode 100644 LanMountainDesktop.RecommendationBackend/Program.cs delete mode 100644 LanMountainDesktop.RecommendationBackend/Properties/launchSettings.json delete mode 100644 LanMountainDesktop.RecommendationBackend/README.md delete mode 100644 LanMountainDesktop.RecommendationBackend/Services/IRecommendationDataService.cs delete mode 100644 LanMountainDesktop.RecommendationBackend/Services/RecommendationDataService.cs delete mode 100644 LanMountainDesktop.RecommendationBackend/appsettings.Development.json delete mode 100644 LanMountainDesktop.RecommendationBackend/appsettings.json create mode 100644 LanMountainDesktop/Services/RecommendationDataService.cs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3252a4d..8727244 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,7 +12,6 @@ # Backend Services /LanMountainDesktop/Services/ @ -/LanMountainDesktop.RecommendationBackend/ @ # Documentation /docs/ @ diff --git a/.github/FIX_REPORT.md b/.github/FIX_REPORT.md index b72c5c1..495e2ad 100644 --- a/.github/FIX_REPORT.md +++ b/.github/FIX_REPORT.md @@ -17,7 +17,6 @@ The current working directory does not contain a project or solution file. ### 1. 创建解决方案文件 ✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含: - `LanMountainDesktop/LanMountainDesktop.csproj` -- `LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj` ### 2. 验证本地构建工作 ✅ 本地测试通过: @@ -36,12 +35,11 @@ The current working directory does not contain a project or solution file. ## 📋 解决方案文件内容 -包含两个项目的标准 Visual Studio 解决方案格式: +包含主桌面项目的标准 Visual Studio 解决方案格式: ``` LanMountainDesktop.sln -├── LanMountainDesktop (Desktop UI - Avalonia) -└── LanMountainDesktop.RecommendationBackend (Web API - ASP.NET Core) +└── LanMountainDesktop (Desktop UI - Avalonia) ``` --- @@ -55,7 +53,7 @@ LanMountainDesktop.sln git add LanMountainDesktop.sln # 2. 提交 -git commit -m "Add solution file for multi-project structure" +git commit -m "Add solution file for desktop project" # 3. 推送 git push origin main diff --git a/.github/VERSION_SYNC_INFO.md b/.github/VERSION_SYNC_INFO.md index f584443..6de0e51 100644 --- a/.github/VERSION_SYNC_INFO.md +++ b/.github/VERSION_SYNC_INFO.md @@ -56,7 +56,6 @@ sed -i "s/.*<\/Version>/$VERSION<\/Version>/" file.csproj 自动更新的文件: 1. `LanMountainDesktop/LanMountainDesktop.csproj` -2. `LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj` ## ✅ 使用流程 diff --git a/.github/WORKFLOWS_GUIDE.md b/.github/WORKFLOWS_GUIDE.md index e21f82e..f501a98 100644 --- a/.github/WORKFLOWS_GUIDE.md +++ b/.github/WORKFLOWS_GUIDE.md @@ -10,7 +10,7 @@ This document describes the CI/CD workflows configured for LanMountainDesktop. T **Trigger:** Every push/PR to main branches, or manual dispatch **What it does:** -- Builds both LanMountainDesktop and RecommendationBackend in Debug and Release modes +- Builds LanMountainDesktop in Debug and Release modes - Runs unit tests (if available) - Uploads build artifacts for inspection - Runs on Windows (windows-latest) @@ -110,14 +110,12 @@ dotnet restore # Build (like CI does) dotnet build LanMountainDesktop/LanMountainDesktop.csproj -dotnet build LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj # Format code locally (required by CI) dotnet format # Run tests dotnet test LanMountainDesktop/LanMountainDesktop.csproj -dotnet test LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj # Alternative: Use local build scripts (Linux/macOS) ./scripts/build.sh --rid linux-x64 --version 1.0.0 @@ -244,7 +242,6 @@ Consider adding: - Multi-platform builds (Linux, macOS) - Installer generation (.exe, .msi) - Automated changelog generation -- Docker images for backend ## References diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37db630..10d96cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,6 @@ jobs: name: build-windows-${{ matrix.configuration }} path: | LanMountainDesktop/bin/${{ matrix.configuration }}/ - LanMountainDesktop.RecommendationBackend/bin/${{ matrix.configuration }}/ retention-days: 7 build-linux: @@ -81,7 +80,6 @@ jobs: name: build-linux path: | LanMountainDesktop/bin/Release/ - LanMountainDesktop.RecommendationBackend/bin/Release/ retention-days: 7 build-macos: @@ -112,5 +110,4 @@ jobs: name: build-macos path: | LanMountainDesktop/bin/Release/ - LanMountainDesktop.RecommendationBackend/bin/Release/ retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 281e747..13c2097 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,8 +65,7 @@ jobs: run: | $VERSION = "${{ needs.prepare.outputs.version }}" $csprojFiles = @( - "LanMountainDesktop/LanMountainDesktop.csproj", - "LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj" + "LanMountainDesktop/LanMountainDesktop.csproj" ) foreach ($csprojPath in $csprojFiles) { @@ -147,9 +146,6 @@ jobs: VERSION="${{ needs.prepare.outputs.version }}" echo "Updating version in LanMountainDesktop.csproj to $VERSION" sed -i "s/.*<\/Version>/$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj - - echo "Updating version in LanMountainDesktop.RecommendationBackend.csproj to $VERSION" - sed -i "s/.*<\/Version>/$VERSION<\/Version>/" LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj - name: Restore run: dotnet restore ${{ env.Solution_Name }} @@ -235,9 +231,6 @@ jobs: VERSION="${{ needs.prepare.outputs.version }}" echo "Updating version in LanMountainDesktop.csproj to $VERSION" sed -i '' "s/.*<\/Version>/$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj - - echo "Updating version in LanMountainDesktop.RecommendationBackend.csproj to $VERSION" - sed -i '' "s/.*<\/Version>/$VERSION<\/Version>/" LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj - name: Restore run: dotnet restore ${{ env.Solution_Name }} diff --git a/LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj b/LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj deleted file mode 100644 index d9b1e7d..0000000 --- a/LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - net10.0 - enable - enable - 1.0.0 - - - diff --git a/LanMountainDesktop.RecommendationBackend/Models/RecommendationDataModels.cs b/LanMountainDesktop.RecommendationBackend/Models/RecommendationDataModels.cs deleted file mode 100644 index 0597b61..0000000 --- a/LanMountainDesktop.RecommendationBackend/Models/RecommendationDataModels.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace LanMountainDesktop.RecommendationBackend.Models; - -public sealed record DailyQuoteSnapshot( - string Provider, - string Content, - string? Author, - string? Source, - DateTimeOffset FetchedAt); - -public sealed record DailyPoetrySnapshot( - string Provider, - string Content, - string? Origin, - string? Author, - string? Category, - DateTimeOffset FetchedAt); - -public sealed record DailyMovieRecommendation( - string Provider, - string Title, - string? Rating, - string? Description, - string? Url, - string? CoverUrl, - DateTimeOffset FetchedAt); - -public sealed record DailyArtworkSnapshot( - string Provider, - string Title, - string? Artist, - string? Year, - string? Museum, - string? ArtworkUrl, - string? ImageUrl, - DateTimeOffset FetchedAt); - -public sealed record HotSearchEntry( - string Provider, - int Rank, - string Title, - string? HotValue, - string? Summary, - string? Url); - -public sealed record RecommendationFeedSnapshot( - DateTimeOffset FetchedAt, - DailyQuoteSnapshot? DailyQuote, - DailyPoetrySnapshot? DailyPoetry, - DailyMovieRecommendation? DailyMovie, - DailyArtworkSnapshot? DailyArtwork, - IReadOnlyList HotSearches); diff --git a/LanMountainDesktop.RecommendationBackend/Program.cs b/LanMountainDesktop.RecommendationBackend/Program.cs deleted file mode 100644 index aa55e3a..0000000 --- a/LanMountainDesktop.RecommendationBackend/Program.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using LanMountainDesktop.RecommendationBackend.Services; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddSingleton(serviceProvider => -{ - var options = builder.Configuration.GetSection("Recommendation").Get(); - return new RecommendationDataService(options); -}); - -var app = builder.Build(); - -app.MapGet("/health", () => Results.Ok(new -{ - service = "LanMountainDesktop.RecommendationBackend", - status = "ok", - timestamp = DateTimeOffset.UtcNow -})); - -app.MapGet( - "/api/recommendation/daily-quote", - async (IRecommendationDataService service, string? locale, bool forceRefresh = false, CancellationToken cancellationToken = default) => - { - var result = await service.GetDailyQuoteAsync(new DailyQuoteQuery(locale, forceRefresh), cancellationToken); - return result.Success ? Results.Ok(result) : Results.BadRequest(result); - }); - -app.MapGet( - "/api/recommendation/daily-poetry", - async (IRecommendationDataService service, string? locale, bool forceRefresh = false, CancellationToken cancellationToken = default) => - { - var result = await service.GetDailyPoetryAsync(new DailyPoetryQuery(locale, forceRefresh), cancellationToken); - return result.Success ? Results.Ok(result) : Results.BadRequest(result); - }); - -app.MapGet( - "/api/recommendation/daily-movie", - async (IRecommendationDataService service, string? locale, int candidateCount = 20, bool forceRefresh = false, CancellationToken cancellationToken = default) => - { - var result = await service.GetDailyMovieAsync( - new DailyMovieQuery(locale, candidateCount <= 0 ? 20 : candidateCount, forceRefresh), - cancellationToken); - return result.Success ? Results.Ok(result) : Results.BadRequest(result); - }); - -app.MapGet( - "/api/recommendation/daily-artwork", - async (IRecommendationDataService service, string? locale, int candidateCount = 50, bool forceRefresh = false, CancellationToken cancellationToken = default) => - { - var result = await service.GetDailyArtworkAsync( - new DailyArtworkQuery(locale, candidateCount <= 0 ? 50 : candidateCount, forceRefresh), - cancellationToken); - return result.Success ? Results.Ok(result) : Results.BadRequest(result); - }); - -app.MapGet( - "/api/recommendation/hot-search", - async (IRecommendationDataService service, string? provider, int limit = 10, bool forceRefresh = false, CancellationToken cancellationToken = default) => - { - var result = await service.GetHotSearchAsync( - new HotSearchQuery(provider ?? "Baidu", limit <= 0 ? 10 : limit, forceRefresh), - cancellationToken); - return result.Success ? Results.Ok(result) : Results.BadRequest(result); - }); - -app.MapGet( - "/api/recommendation/feed", - async (IRecommendationDataService service, string? locale, int hotSearchLimit = 10, bool forceRefresh = false, CancellationToken cancellationToken = default) => - { - var result = await service.GetFeedAsync( - new RecommendationFeedQuery(locale, hotSearchLimit <= 0 ? 10 : hotSearchLimit, forceRefresh), - cancellationToken); - return result.Success ? Results.Ok(result) : Results.BadRequest(result); - }); - -app.MapPost( - "/api/recommendation/cache/clear", - (IRecommendationDataService service) => - { - service.ClearCache(); - return Results.Ok(new - { - success = true, - message = "Recommendation cache cleared.", - timestamp = DateTimeOffset.UtcNow - }); - }); - -app.MapGet("/", () => Results.Redirect("/health")); - -app.Run(); diff --git a/LanMountainDesktop.RecommendationBackend/Properties/launchSettings.json b/LanMountainDesktop.RecommendationBackend/Properties/launchSettings.json deleted file mode 100644 index dd2c8cf..0000000 --- a/LanMountainDesktop.RecommendationBackend/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5196", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7181;http://localhost:5196", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/LanMountainDesktop.RecommendationBackend/README.md b/LanMountainDesktop.RecommendationBackend/README.md deleted file mode 100644 index f1df2c3..0000000 --- a/LanMountainDesktop.RecommendationBackend/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# LanMountainDesktop Recommendation Backend - -信息推荐后端,提供统一抓取与聚合接口,当前覆盖: -- 每日一言 -- 每日诗词 -- 每日电影推荐 -- 每日名画 -- 百度热搜 - -## 启动 - -```bash -dotnet run --project LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj -``` - -默认监听地址以 `dotnet` 输出为准(通常是 `http://localhost:5xxx` 或 `https://localhost:7xxx`)。 - -## 接口 - -- `GET /health` -- `GET /api/recommendation/daily-quote?locale=zh-CN&forceRefresh=false` -- `GET /api/recommendation/daily-poetry?locale=zh-CN&forceRefresh=false` -- `GET /api/recommendation/daily-movie?candidateCount=20&forceRefresh=false` -- `GET /api/recommendation/daily-artwork?candidateCount=50&forceRefresh=false` -- `GET /api/recommendation/hot-search?provider=Baidu&limit=10&forceRefresh=false` -- `GET /api/recommendation/feed?locale=zh-CN&hotSearchLimit=10&forceRefresh=false` -- `POST /api/recommendation/cache/clear` - -## 设计说明 - -- 服务实现风格与现有天气服务一致:`Options + Query + QueryResult + Service`。 -- 所有抓取接口都带有统一错误返回:`errorCode` + `errorMessage`。 -- 提供内存缓存,降低上游请求频率与组件刷新开销。 diff --git a/LanMountainDesktop.RecommendationBackend/Services/IRecommendationDataService.cs b/LanMountainDesktop.RecommendationBackend/Services/IRecommendationDataService.cs deleted file mode 100644 index cf814d6..0000000 --- a/LanMountainDesktop.RecommendationBackend/Services/IRecommendationDataService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using LanMountainDesktop.RecommendationBackend.Models; - -namespace LanMountainDesktop.RecommendationBackend.Services; - -public sealed record DailyQuoteQuery( - string? Locale = null, - bool ForceRefresh = false); - -public sealed record DailyPoetryQuery( - string? Locale = null, - bool ForceRefresh = false); - -public sealed record DailyMovieQuery( - string? Locale = null, - int CandidateCount = 20, - bool ForceRefresh = false); - -public sealed record DailyArtworkQuery( - string? Locale = null, - int CandidateCount = 50, - bool ForceRefresh = false); - -public sealed record HotSearchQuery( - string Provider = "Baidu", - int Limit = 10, - bool ForceRefresh = false); - -public sealed record RecommendationFeedQuery( - string? Locale = null, - int HotSearchLimit = 10, - bool ForceRefresh = false); - -public sealed record RecommendationQueryResult( - bool Success, - T? Data, - string? ErrorCode = null, - string? ErrorMessage = null) -{ - public static RecommendationQueryResult Ok(T data) - { - return new RecommendationQueryResult(true, data); - } - - public static RecommendationQueryResult Fail(string errorCode, string errorMessage) - { - return new RecommendationQueryResult(false, default, errorCode, errorMessage); - } -} - -public interface IRecommendationInfoService -{ - Task> GetDailyQuoteAsync( - DailyQuoteQuery query, - CancellationToken cancellationToken = default); - - Task> GetDailyPoetryAsync( - DailyPoetryQuery query, - CancellationToken cancellationToken = default); - - Task> GetDailyMovieAsync( - DailyMovieQuery query, - CancellationToken cancellationToken = default); - - Task> GetDailyArtworkAsync( - DailyArtworkQuery query, - CancellationToken cancellationToken = default); - - Task>> GetHotSearchAsync( - HotSearchQuery query, - CancellationToken cancellationToken = default); -} - -public interface IRecommendationDataService : IRecommendationInfoService -{ - Task> GetFeedAsync( - RecommendationFeedQuery query, - CancellationToken cancellationToken = default); - - void ClearCache(); -} diff --git a/LanMountainDesktop.RecommendationBackend/Services/RecommendationDataService.cs b/LanMountainDesktop.RecommendationBackend/Services/RecommendationDataService.cs deleted file mode 100644 index 0fc982d..0000000 --- a/LanMountainDesktop.RecommendationBackend/Services/RecommendationDataService.cs +++ /dev/null @@ -1,729 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using LanMountainDesktop.RecommendationBackend.Models; - -namespace LanMountainDesktop.RecommendationBackend.Services; - -public sealed record RecommendationApiOptions -{ - public string DailyQuoteUrl { get; init; } = "https://v1.hitokoto.cn/?encode=json&charset=utf-8"; - - public string DailyPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json"; - - public string DoubanHotMovieUrlTemplate { get; init; } = - "https://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&page_limit={0}&page_start=0"; - - public string BaiduHotSearchUrl { get; init; } = "https://top.baidu.com/board?tab=realtime"; - - public string ArtInstituteArtworkApiTemplate { get; init; } = - "https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link"; - - public string ArtInstituteImageUrlTemplate { get; init; } = - "https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg"; - - public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(15); - - public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8); - - public int DefaultMovieCandidateCount { get; init; } = 20; - - public int DefaultHotSearchLimit { get; init; } = 10; - - public int DefaultArtworkCandidateCount { get; init; } = 50; -} - -public sealed class RecommendationDataService : IRecommendationDataService, IDisposable -{ - private sealed record CacheEntry(object Value, DateTimeOffset ExpireAt); - - private sealed record MovieCandidate( - string Title, - string? Rating, - string? Url, - string? CoverUrl); - - private sealed record ArtworkCandidate( - string Title, - string? Artist, - string? Year, - string? ArtworkUrl, - string? ImageId); - - private static readonly Regex HtmlTagRegex = new("<[^>]+>", RegexOptions.Compiled); - private static readonly Regex HotSearchSplitRegex = new("]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex RankRegex = new("]*>\\s*(?\\d+)\\s*", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex TitleRegex = new("]*>\\s*(?.*?)\\s*", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); - private static readonly Regex UrlRegex = new("https?://[^\"]+)\"\\s+class=\"title_[^\"]*\"", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex HotValueRegex = new("]*>\\s*(?[\\d,]+)\\s*", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex SummaryRegex = new("]*>\\s*(?.*?)(?:)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); - - private readonly RecommendationApiOptions _options; - private readonly HttpClient _httpClient; - private readonly bool _ownsHttpClient; - private readonly object _cacheGate = new(); - private readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase); - - public RecommendationDataService( - RecommendationApiOptions? options = null, - HttpClient? httpClient = null) - { - _options = options ?? new RecommendationApiOptions(); - if (httpClient is null) - { - _httpClient = new HttpClient - { - Timeout = _options.RequestTimeout - }; - _ownsHttpClient = true; - } - else - { - _httpClient = httpClient; - _ownsHttpClient = false; - } - } - - public void Dispose() - { - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } - } - - public void ClearCache() - { - lock (_cacheGate) - { - _cache.Clear(); - } - } - - public async Task> GetDailyQuoteAsync( - DailyQuoteQuery query, - CancellationToken cancellationToken = default) - { - var normalizedQuery = query ?? new DailyQuoteQuery(); - var locale = string.IsNullOrWhiteSpace(normalizedQuery.Locale) ? "zh-CN" : normalizedQuery.Locale.Trim(); - var cacheKey = $"daily_quote|{locale}"; - - if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out DailyQuoteSnapshot cached)) - { - return RecommendationQueryResult.Ok(cached); - } - - string responseText; - try - { - responseText = await FetchTextAsync(new Uri(_options.DailyQuoteUrl, UriKind.Absolute), cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("network_error", ex.Message); - } - - try - { - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement; - - var content = ReadString(root, "hitokoto") ?? ReadString(root, "content"); - if (string.IsNullOrWhiteSpace(content)) - { - return RecommendationQueryResult.Fail("parse_error", "Quote content is empty."); - } - - var snapshot = new DailyQuoteSnapshot( - Provider: "Hitokoto", - Content: content.Trim(), - Author: ReadString(root, "from_who") ?? ReadString(root, "creator"), - Source: ReadString(root, "from"), - FetchedAt: DateTimeOffset.UtcNow); - - SetCache(cacheKey, snapshot); - return RecommendationQueryResult.Ok(snapshot); - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("parse_error", ex.Message); - } - } - - public async Task> GetDailyPoetryAsync( - DailyPoetryQuery query, - CancellationToken cancellationToken = default) - { - var normalizedQuery = query ?? new DailyPoetryQuery(); - var locale = string.IsNullOrWhiteSpace(normalizedQuery.Locale) ? "zh-CN" : normalizedQuery.Locale.Trim(); - var cacheKey = $"daily_poetry|{locale}"; - - if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out DailyPoetrySnapshot cached)) - { - return RecommendationQueryResult.Ok(cached); - } - - string responseText; - try - { - responseText = await FetchTextAsync(new Uri(_options.DailyPoetryUrl, UriKind.Absolute), cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("network_error", ex.Message); - } - - try - { - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement; - - var content = ReadString(root, "content"); - if (string.IsNullOrWhiteSpace(content)) - { - return RecommendationQueryResult.Fail("parse_error", "Poetry content is empty."); - } - - var snapshot = new DailyPoetrySnapshot( - Provider: "JinriShici", - Content: content.Trim(), - Origin: ReadString(root, "origin"), - Author: ReadString(root, "author"), - Category: ReadString(root, "category"), - FetchedAt: DateTimeOffset.UtcNow); - - SetCache(cacheKey, snapshot); - return RecommendationQueryResult.Ok(snapshot); - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("parse_error", ex.Message); - } - } - - public async Task> GetDailyMovieAsync( - DailyMovieQuery query, - CancellationToken cancellationToken = default) - { - var normalizedQuery = query ?? new DailyMovieQuery(); - var candidateCount = Math.Clamp( - normalizedQuery.CandidateCount > 0 ? normalizedQuery.CandidateCount : _options.DefaultMovieCandidateCount, - 5, - 50); - var localDate = GetChinaLocalDate(); - var cacheKey = $"daily_movie|{localDate:yyyyMMdd}|{candidateCount}"; - - if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out DailyMovieRecommendation cached)) - { - return RecommendationQueryResult.Ok(cached); - } - - var requestUrl = string.Format( - CultureInfo.InvariantCulture, - _options.DoubanHotMovieUrlTemplate, - candidateCount); - - string responseText; - try - { - responseText = await FetchTextAsync( - new Uri(requestUrl, UriKind.Absolute), - cancellationToken, - request => - { - request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"); - request.Headers.TryAddWithoutValidation("Referer", "https://movie.douban.com/"); - }); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("network_error", ex.Message); - } - - try - { - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement; - if (!root.TryGetProperty("subjects", out var subjects) || subjects.ValueKind != JsonValueKind.Array) - { - return RecommendationQueryResult.Fail("parse_error", "Movie list is missing."); - } - - var candidates = new List(); - foreach (var item in subjects.EnumerateArray()) - { - var title = ReadString(item, "title"); - if (string.IsNullOrWhiteSpace(title)) - { - continue; - } - - candidates.Add(new MovieCandidate( - Title: title.Trim(), - Rating: ReadString(item, "rate"), - Url: ReadString(item, "url"), - CoverUrl: ReadString(item, "cover"))); - } - - if (candidates.Count == 0) - { - return RecommendationQueryResult.Fail("empty_result", "No movie candidates were returned."); - } - - var indexSeed = localDate.Year * 1000 + localDate.DayOfYear; - var selected = candidates[Math.Abs(indexSeed) % candidates.Count]; - - var snapshot = new DailyMovieRecommendation( - Provider: "Douban", - Title: selected.Title, - Rating: selected.Rating, - Description: "豆瓣热门电影每日推荐", - Url: selected.Url, - CoverUrl: selected.CoverUrl, - FetchedAt: DateTimeOffset.UtcNow); - - SetCache(cacheKey, snapshot); - return RecommendationQueryResult.Ok(snapshot); - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("parse_error", ex.Message); - } - } - - public async Task> GetDailyArtworkAsync( - DailyArtworkQuery query, - CancellationToken cancellationToken = default) - { - var normalizedQuery = query ?? new DailyArtworkQuery(); - var candidateCount = Math.Clamp( - normalizedQuery.CandidateCount > 0 ? normalizedQuery.CandidateCount : _options.DefaultArtworkCandidateCount, - 10, - 100); - var localDate = GetChinaLocalDate(); - var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100); - var cacheKey = $"daily_artwork|{localDate:yyyyMMdd}|p{page}|n{candidateCount}"; - - if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out DailyArtworkSnapshot cached)) - { - return RecommendationQueryResult.Ok(cached); - } - - var requestUrl = string.Format( - CultureInfo.InvariantCulture, - _options.ArtInstituteArtworkApiTemplate, - page, - candidateCount); - - string responseText; - try - { - responseText = await FetchTextAsync( - new Uri(requestUrl, UriKind.Absolute), - cancellationToken, - request => request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0")); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("network_error", ex.Message); - } - - try - { - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement; - if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array) - { - return RecommendationQueryResult.Fail("parse_error", "Artwork list is missing."); - } - - var candidates = new List(); - foreach (var item in dataArray.EnumerateArray()) - { - var title = ReadString(item, "title"); - var imageId = ReadString(item, "image_id"); - if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId)) - { - continue; - } - - var artist = ReadString(item, "artist_title"); - if (string.IsNullOrWhiteSpace(artist)) - { - artist = ReadFirstNonEmptyLine(ReadString(item, "artist_display")); - } - - candidates.Add(new ArtworkCandidate( - Title: title.Trim(), - Artist: artist, - Year: ReadString(item, "date_display"), - ArtworkUrl: ReadString(item, "api_link"), - ImageId: imageId.Trim())); - } - - if (candidates.Count == 0) - { - return RecommendationQueryResult.Fail("empty_result", "No artwork candidates were returned."); - } - - var indexSeed = localDate.Year * 1000 + localDate.DayOfYear; - var selected = candidates[Math.Abs(indexSeed) % candidates.Count]; - var imageUrl = BuildArtworkImageUrl(selected.ImageId); - - var snapshot = new DailyArtworkSnapshot( - Provider: "ArtInstituteOfChicago", - Title: selected.Title, - Artist: selected.Artist, - Year: selected.Year, - Museum: "The Art Institute of Chicago", - ArtworkUrl: selected.ArtworkUrl, - ImageUrl: imageUrl, - FetchedAt: DateTimeOffset.UtcNow); - - SetCache(cacheKey, snapshot); - return RecommendationQueryResult.Ok(snapshot); - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("parse_error", ex.Message); - } - } - - public async Task>> GetHotSearchAsync( - HotSearchQuery query, - CancellationToken cancellationToken = default) - { - var normalizedQuery = query ?? new HotSearchQuery(); - var provider = string.IsNullOrWhiteSpace(normalizedQuery.Provider) - ? "Baidu" - : normalizedQuery.Provider.Trim(); - var limit = Math.Clamp( - normalizedQuery.Limit > 0 ? normalizedQuery.Limit : _options.DefaultHotSearchLimit, - 1, - 50); - var cacheKey = $"hot_search|{provider}|{limit}"; - - if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out IReadOnlyList cached)) - { - return RecommendationQueryResult>.Ok(cached); - } - - if (!string.Equals(provider, "Baidu", StringComparison.OrdinalIgnoreCase)) - { - return RecommendationQueryResult>.Fail( - "unsupported_provider", - $"Unsupported hot search provider: {provider}"); - } - - string responseText; - try - { - responseText = await FetchTextAsync( - new Uri(_options.BaiduHotSearchUrl, UriKind.Absolute), - cancellationToken, - request => request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0")); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return RecommendationQueryResult>.Fail("network_error", ex.Message); - } - - try - { - var entries = ParseBaiduHotSearch(responseText, limit); - if (entries.Count == 0) - { - return RecommendationQueryResult>.Fail("parse_error", "No hot search entries found."); - } - - SetCache(cacheKey, entries); - return RecommendationQueryResult>.Ok(entries); - } - catch (Exception ex) - { - return RecommendationQueryResult>.Fail("parse_error", ex.Message); - } - } - - public async Task> GetFeedAsync( - RecommendationFeedQuery query, - CancellationToken cancellationToken = default) - { - var normalizedQuery = query ?? new RecommendationFeedQuery(); - var quoteTask = GetDailyQuoteAsync( - new DailyQuoteQuery(normalizedQuery.Locale, normalizedQuery.ForceRefresh), - cancellationToken); - var poetryTask = GetDailyPoetryAsync( - new DailyPoetryQuery(normalizedQuery.Locale, normalizedQuery.ForceRefresh), - cancellationToken); - var movieTask = GetDailyMovieAsync( - new DailyMovieQuery(normalizedQuery.Locale, ForceRefresh: normalizedQuery.ForceRefresh), - cancellationToken); - var artworkTask = GetDailyArtworkAsync( - new DailyArtworkQuery(normalizedQuery.Locale, ForceRefresh: normalizedQuery.ForceRefresh), - cancellationToken); - var hotTask = GetHotSearchAsync( - new HotSearchQuery(Limit: normalizedQuery.HotSearchLimit, ForceRefresh: normalizedQuery.ForceRefresh), - cancellationToken); - - await Task.WhenAll(quoteTask, poetryTask, movieTask, artworkTask, hotTask); - - var quote = quoteTask.Result; - var poetry = poetryTask.Result; - var movie = movieTask.Result; - var artwork = artworkTask.Result; - var hot = hotTask.Result; - - if (!quote.Success && !poetry.Success && !movie.Success && !artwork.Success && !hot.Success) - { - return RecommendationQueryResult.Fail( - "upstream_unavailable", - "All upstream recommendation providers failed."); - } - - var snapshot = new RecommendationFeedSnapshot( - FetchedAt: DateTimeOffset.UtcNow, - DailyQuote: quote.Success ? quote.Data : null, - DailyPoetry: poetry.Success ? poetry.Data : null, - DailyMovie: movie.Success ? movie.Data : null, - DailyArtwork: artwork.Success ? artwork.Data : null, - HotSearches: hot.Success && hot.Data is not null ? hot.Data : Array.Empty()); - - return RecommendationQueryResult.Ok(snapshot); - } - - private async Task FetchTextAsync( - Uri requestUri, - CancellationToken cancellationToken, - Action? configureRequest = null) - { - using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - configureRequest?.Invoke(request); - - using var response = await _httpClient.SendAsync(request, cancellationToken); - var content = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(content, 180)}"); - } - - return content; - } - - private IReadOnlyList ParseBaiduHotSearch(string html, int limit) - { - var parts = HotSearchSplitRegex.Split(html); - var entries = new List(limit); - - for (var i = 1; i < parts.Length; i++) - { - var chunk = parts[i]; - var title = DecodeHtml(ExtractGroupValue(TitleRegex, chunk, "value")); - var url = DecodeHtml(ExtractGroupValue(UrlRegex, chunk, "value")); - var hotValue = DecodeHtml(ExtractGroupValue(HotValueRegex, chunk, "value")); - var summary = DecodeHtml(ExtractGroupValue(SummaryRegex, chunk, "value")); - var rankText = ExtractGroupValue(RankRegex, chunk, "value"); - - if (string.IsNullOrWhiteSpace(title)) - { - continue; - } - - if (!int.TryParse(rankText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rank)) - { - rank = entries.Count + 1; - } - - entries.Add(new HotSearchEntry( - Provider: "Baidu", - Rank: rank, - Title: title, - HotValue: string.IsNullOrWhiteSpace(hotValue) ? null : hotValue, - Summary: string.IsNullOrWhiteSpace(summary) ? null : summary, - Url: string.IsNullOrWhiteSpace(url) ? null : url)); - - if (entries.Count >= limit) - { - break; - } - } - - var uniqueEntries = entries - .GroupBy(item => item.Title, StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(item => item.Rank) - .ThenBy(item => item.Title, StringComparer.OrdinalIgnoreCase) - .Take(limit) - .ToList(); - - for (var i = 0; i < uniqueEntries.Count; i++) - { - var item = uniqueEntries[i]; - uniqueEntries[i] = item with { Rank = i + 1 }; - } - - return uniqueEntries; - } - - private static string? ExtractGroupValue(Regex regex, string input, string groupName) - { - var match = regex.Match(input); - if (!match.Success) - { - return null; - } - - return match.Groups[groupName].Value; - } - - private static string? DecodeHtml(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - var decoded = WebUtility.HtmlDecode(value); - decoded = HtmlTagRegex.Replace(decoded, " "); - return string.Join(" ", decoded.Split([' ', '\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries)); - } - - private string? BuildArtworkImageUrl(string? imageId) - { - if (string.IsNullOrWhiteSpace(imageId)) - { - return null; - } - - return string.Format( - CultureInfo.InvariantCulture, - _options.ArtInstituteImageUrlTemplate, - imageId.Trim()); - } - - private static string? ReadFirstNonEmptyLine(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - return text - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) - .Select(line => line.Trim()) - .FirstOrDefault(line => !string.IsNullOrWhiteSpace(line)); - } - - private bool TryGetCached(string cacheKey, out T value) - { - lock (_cacheGate) - { - if (_cache.TryGetValue(cacheKey, out var entry)) - { - if (entry.ExpireAt > DateTimeOffset.UtcNow && entry.Value is T typedValue) - { - value = typedValue; - return true; - } - - _cache.Remove(cacheKey); - } - } - - value = default!; - return false; - } - - private void SetCache(string cacheKey, object value) - { - var expireAt = DateTimeOffset.UtcNow.Add(_options.CacheDuration); - lock (_cacheGate) - { - _cache[cacheKey] = new CacheEntry(value, expireAt); - } - } - - private static string? ReadString(JsonElement? node, params string[] path) - { - if (!node.HasValue) - { - return null; - } - - var target = path.Length == 0 ? node : TryGetNode(node.Value, path); - if (!target.HasValue) - { - return null; - } - - return target.Value.ValueKind switch - { - JsonValueKind.String => target.Value.GetString(), - JsonValueKind.Number => target.Value.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - _ => null - }; - } - - private static JsonElement? TryGetNode(JsonElement node, params string[] path) - { - var current = node; - foreach (var segment in path) - { - if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next)) - { - return null; - } - - current = next; - } - - return current; - } - - private static DateOnly GetChinaLocalDate() - { - var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8)); - return DateOnly.FromDateTime(now.Date); - } - - private static string Truncate(string? text, int maxLength) - { - if (string.IsNullOrEmpty(text)) - { - return string.Empty; - } - - return text.Length <= maxLength - ? text - : $"{text[..maxLength]}..."; - } -} diff --git a/LanMountainDesktop.RecommendationBackend/appsettings.Development.json b/LanMountainDesktop.RecommendationBackend/appsettings.Development.json deleted file mode 100644 index 9dc1ca9..0000000 --- a/LanMountainDesktop.RecommendationBackend/appsettings.Development.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Recommendation": { - "CacheDuration": "00:05:00" - } -} diff --git a/LanMountainDesktop.RecommendationBackend/appsettings.json b/LanMountainDesktop.RecommendationBackend/appsettings.json deleted file mode 100644 index 351fcf9..0000000 --- a/LanMountainDesktop.RecommendationBackend/appsettings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Recommendation": { - "DailyQuoteUrl": "https://v1.hitokoto.cn/?encode=json&charset=utf-8", - "DailyPoetryUrl": "https://v1.jinrishici.com/all.json", - "DoubanHotMovieUrlTemplate": "https://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&page_limit={0}&page_start=0", - "BaiduHotSearchUrl": "https://top.baidu.com/board?tab=realtime", - "ArtInstituteArtworkApiTemplate": "https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link", - "ArtInstituteImageUrlTemplate": "https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg", - "CacheDuration": "00:15:00", - "RequestTimeout": "00:00:08", - "DefaultMovieCandidateCount": 20, - "DefaultHotSearchLimit": 10, - "DefaultArtworkCandidateCount": 50 - }, - "AllowedHosts": "*" -} diff --git a/LanMountainDesktop.sln b/LanMountainDesktop.sln index ac0f32a..b04ea4e 100644 --- a/LanMountainDesktop.sln +++ b/LanMountainDesktop.sln @@ -5,8 +5,6 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.RecommendationBackend", "LanMountainDesktop.RecommendationBackend\LanMountainDesktop.RecommendationBackend.csproj", "{00000002-0000-0000-0000-000000000002}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -17,9 +15,5 @@ Global {00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU {00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU {00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU - {00000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index d4e9169..9759a68 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -246,7 +246,7 @@ "artwork.widget.loading_subtitle": "Fetching today's masterpiece", "artwork.widget.fetch_failed": "Artwork fetch failed", "artwork.widget.fallback_title": "Daily Artwork", - "artwork.widget.fallback_artist": "Recommendation backend unavailable", + "artwork.widget.fallback_artist": "Recommendation service unavailable", "artwork.widget.fallback_year": "Try again later", "artwork.widget.unknown_artist": "Unknown artist", "music.widget.unsupported": "Music control is not supported on this platform", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 9412990..f83273b 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -246,7 +246,7 @@ "artwork.widget.loading_subtitle": "正在获取今日名画", "artwork.widget.fetch_failed": "名画获取失败", "artwork.widget.fallback_title": "每日名画", - "artwork.widget.fallback_artist": "推荐后端不可用", + "artwork.widget.fallback_artist": "推荐服务不可用", "artwork.widget.fallback_year": "稍后重试", "artwork.widget.unknown_artist": "未知作者", "music.widget.unsupported": "当前平台不支持音乐控制", diff --git a/LanMountainDesktop/Services/IRecommendationDataService.cs b/LanMountainDesktop/Services/IRecommendationDataService.cs index 7fc3d28..148d2e9 100644 --- a/LanMountainDesktop/Services/IRecommendationDataService.cs +++ b/LanMountainDesktop/Services/IRecommendationDataService.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Text.Json; +using System; using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.Models; @@ -35,14 +30,8 @@ public sealed record RecommendationQueryResult( } } -public sealed record RecommendationBackendOptions +public sealed record RecommendationApiOptions { - public string BaseUrl { get; init; } = "http://127.0.0.1:5057"; - - public string DailyArtworkPath { get; init; } = "/api/recommendation/daily-artwork"; - - public string DailyPoetryPath { get; init; } = "/api/recommendation/daily-poetry"; - public string JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json"; public string ArtInstituteArtworkApiTemplate { get; init; } = @@ -70,623 +59,3 @@ public interface IRecommendationInfoService void ClearCache(); } - -public sealed class RecommendationBackendService : IRecommendationInfoService, IDisposable -{ - private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt); - private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt); - private sealed record ArtworkCandidate( - string Title, - string? Artist, - string? Year, - string? ArtworkUrl, - string? ImageId); - - private readonly RecommendationBackendOptions _options; - private readonly HttpClient _httpClient; - private readonly bool _ownsHttpClient; - private readonly object _cacheGate = new(); - private DailyArtworkCacheEntry? _dailyArtworkCache; - private DailyPoetryCacheEntry? _dailyPoetryCache; - - public RecommendationBackendService( - RecommendationBackendOptions? options = null, - HttpClient? httpClient = null) - { - _options = options ?? new RecommendationBackendOptions(); - if (httpClient is null) - { - _httpClient = new HttpClient - { - Timeout = _options.RequestTimeout - }; - _ownsHttpClient = true; - } - else - { - _httpClient = httpClient; - _ownsHttpClient = false; - } - } - - public void Dispose() - { - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } - } - - public void ClearCache() - { - lock (_cacheGate) - { - _dailyArtworkCache = null; - _dailyPoetryCache = null; - } - } - - public async Task> GetDailyPoetryAsync( - DailyPoetryQuery query, - CancellationToken cancellationToken = default) - { - var normalizedQuery = query ?? new DailyPoetryQuery(); - if (!normalizedQuery.ForceRefresh && TryGetDailyPoetryFromCache(out var cached)) - { - return RecommendationQueryResult.Ok(cached); - } - - var uri = BuildDailyPoetryUri(normalizedQuery.Locale, normalizedQuery.ForceRefresh); - string responseText; - - try - { - using var response = await _httpClient.GetAsync(uri, cancellationToken); - responseText = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) - { - return await TryDirectPoetryFallbackAsync( - normalizedQuery, - "http_error", - $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}", - cancellationToken); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return await TryDirectPoetryFallbackAsync( - normalizedQuery, - "network_error", - ex.Message, - cancellationToken); - } - - try - { - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement; - - var success = ReadBool(root, "success"); - if (!success.GetValueOrDefault()) - { - return await TryDirectPoetryFallbackAsync( - normalizedQuery, - ReadString(root, "errorCode") ?? "upstream_error", - ReadString(root, "errorMessage") ?? "Recommendation backend returned an unsuccessful response.", - cancellationToken); - } - - if (!root.TryGetProperty("data", out var dataNode) || dataNode.ValueKind != JsonValueKind.Object) - { - return await TryDirectPoetryFallbackAsync( - normalizedQuery, - "parse_error", - "Daily poetry payload is missing.", - cancellationToken); - } - - var content = ReadString(dataNode, "content"); - if (string.IsNullOrWhiteSpace(content)) - { - return await TryDirectPoetryFallbackAsync( - normalizedQuery, - "parse_error", - "Poetry content is missing.", - cancellationToken); - } - - var snapshot = new DailyPoetrySnapshot( - Provider: ReadString(dataNode, "provider") ?? "RecommendationBackend", - Content: content.Trim(), - Origin: ReadString(dataNode, "origin"), - Author: ReadString(dataNode, "author"), - Category: ReadString(dataNode, "category"), - FetchedAt: ParseDateTimeOffset(ReadString(dataNode, "fetchedAt")) ?? DateTimeOffset.UtcNow); - - SetDailyPoetryCache(snapshot); - return RecommendationQueryResult.Ok(snapshot); - } - catch (Exception ex) - { - return await TryDirectPoetryFallbackAsync( - normalizedQuery, - "parse_error", - ex.Message, - cancellationToken); - } - } - - public async Task> GetDailyArtworkAsync( - DailyArtworkQuery query, - CancellationToken cancellationToken = default) - { - var normalizedQuery = query ?? new DailyArtworkQuery(); - if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached)) - { - return RecommendationQueryResult.Ok(cached); - } - - var uri = BuildDailyArtworkUri(normalizedQuery.Locale, normalizedQuery.ForceRefresh); - string responseText; - - try - { - using var response = await _httpClient.GetAsync(uri, cancellationToken); - responseText = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) - { - return await TryDirectFallbackAsync( - normalizedQuery, - "http_error", - $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}", - cancellationToken); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return await TryDirectFallbackAsync( - normalizedQuery, - "network_error", - ex.Message, - cancellationToken); - } - - try - { - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement; - - var success = ReadBool(root, "success"); - if (!success.GetValueOrDefault()) - { - return await TryDirectFallbackAsync( - normalizedQuery, - ReadString(root, "errorCode") ?? "upstream_error", - ReadString(root, "errorMessage") ?? "Recommendation backend returned an unsuccessful response.", - cancellationToken); - } - - if (!root.TryGetProperty("data", out var dataNode) || dataNode.ValueKind != JsonValueKind.Object) - { - return await TryDirectFallbackAsync( - normalizedQuery, - "parse_error", - "Daily artwork payload is missing.", - cancellationToken); - } - - var title = ReadString(dataNode, "title"); - if (string.IsNullOrWhiteSpace(title)) - { - return await TryDirectFallbackAsync( - normalizedQuery, - "parse_error", - "Artwork title is missing.", - cancellationToken); - } - - var snapshot = new DailyArtworkSnapshot( - Provider: ReadString(dataNode, "provider") ?? "RecommendationBackend", - Title: title.Trim(), - Artist: ReadString(dataNode, "artist"), - Year: ReadString(dataNode, "year"), - Museum: ReadString(dataNode, "museum"), - ArtworkUrl: ReadString(dataNode, "artworkUrl"), - ImageUrl: ReadString(dataNode, "imageUrl"), - FetchedAt: ParseDateTimeOffset(ReadString(dataNode, "fetchedAt")) ?? DateTimeOffset.UtcNow); - - SetDailyArtworkCache(snapshot); - return RecommendationQueryResult.Ok(snapshot); - } - catch (Exception ex) - { - return await TryDirectFallbackAsync( - normalizedQuery, - "parse_error", - ex.Message, - cancellationToken); - } - } - - private async Task> TryDirectFallbackAsync( - DailyArtworkQuery query, - string errorCode, - string errorMessage, - CancellationToken cancellationToken) - { - var fallback = await GetDailyArtworkDirectAsync(query, cancellationToken); - if (fallback.Success && fallback.Data is not null) - { - SetDailyArtworkCache(fallback.Data); - return fallback; - } - - var fallbackMessage = string.IsNullOrWhiteSpace(fallback.ErrorMessage) - ? "Direct upstream fallback failed." - : fallback.ErrorMessage; - return RecommendationQueryResult.Fail( - errorCode, - $"{errorMessage}; fallback: {fallbackMessage}"); - } - - private async Task> GetDailyArtworkDirectAsync( - DailyArtworkQuery query, - CancellationToken cancellationToken) - { - var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100); - var localDate = GetChinaLocalDate(); - var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100); - var requestUrl = string.Format( - CultureInfo.InvariantCulture, - _options.ArtInstituteArtworkApiTemplate, - page, - candidateCount); - - string responseText; - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); - request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"); - using var response = await _httpClient.SendAsync(request, cancellationToken); - responseText = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) - { - return RecommendationQueryResult.Fail( - "upstream_http_error", - $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); - } - - try - { - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement; - if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array) - { - return RecommendationQueryResult.Fail("upstream_parse_error", "Artwork list is missing."); - } - - var candidates = new List(); - foreach (var item in dataArray.EnumerateArray()) - { - var title = ReadString(item, "title"); - var imageId = ReadString(item, "image_id"); - if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId)) - { - continue; - } - - var artist = ReadString(item, "artist_title"); - if (string.IsNullOrWhiteSpace(artist)) - { - artist = ReadFirstNonEmptyLine(ReadString(item, "artist_display")); - } - - candidates.Add(new ArtworkCandidate( - title.Trim(), - artist, - ReadString(item, "date_display"), - ReadString(item, "api_link"), - imageId.Trim())); - } - - if (candidates.Count == 0) - { - return RecommendationQueryResult.Fail("upstream_empty_result", "No artwork candidates were returned."); - } - - var indexSeed = localDate.Year * 1000 + localDate.DayOfYear; - var selected = candidates[Math.Abs(indexSeed) % candidates.Count]; - var snapshot = new DailyArtworkSnapshot( - Provider: "ArtInstituteOfChicago", - Title: selected.Title, - Artist: selected.Artist, - Year: selected.Year, - Museum: "The Art Institute of Chicago", - ArtworkUrl: selected.ArtworkUrl, - ImageUrl: BuildArtworkImageUrl(selected.ImageId), - FetchedAt: DateTimeOffset.UtcNow); - - return RecommendationQueryResult.Ok(snapshot); - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); - } - } - - private async Task> TryDirectPoetryFallbackAsync( - DailyPoetryQuery query, - string errorCode, - string errorMessage, - CancellationToken cancellationToken) - { - var fallback = await GetDailyPoetryDirectAsync(query, cancellationToken); - if (fallback.Success && fallback.Data is not null) - { - SetDailyPoetryCache(fallback.Data); - return fallback; - } - - var fallbackMessage = string.IsNullOrWhiteSpace(fallback.ErrorMessage) - ? "Direct upstream fallback failed." - : fallback.ErrorMessage; - return RecommendationQueryResult.Fail( - errorCode, - $"{errorMessage}; fallback: {fallbackMessage}"); - } - - private async Task> GetDailyPoetryDirectAsync( - DailyPoetryQuery query, - CancellationToken cancellationToken) - { - _ = query; - - string responseText; - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl); - request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"); - using var response = await _httpClient.SendAsync(request, cancellationToken); - responseText = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) - { - return RecommendationQueryResult.Fail( - "upstream_http_error", - $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); - } - - try - { - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement; - var content = ReadString(root, "content"); - if (string.IsNullOrWhiteSpace(content)) - { - return RecommendationQueryResult.Fail( - "upstream_parse_error", - "Poetry content is empty."); - } - - var snapshot = new DailyPoetrySnapshot( - Provider: "JinriShici", - Content: content.Trim(), - Origin: ReadString(root, "origin"), - Author: ReadString(root, "author"), - Category: ReadString(root, "category"), - FetchedAt: DateTimeOffset.UtcNow); - - return RecommendationQueryResult.Ok(snapshot); - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); - } - } - - private Uri BuildDailyArtworkUri(string? locale, bool forceRefresh) - { - var baseUrl = _options.BaseUrl.TrimEnd('/'); - var path = _options.DailyArtworkPath.StartsWith("/", StringComparison.Ordinal) - ? _options.DailyArtworkPath - : $"/{_options.DailyArtworkPath}"; - var localePart = string.IsNullOrWhiteSpace(locale) - ? string.Empty - : $"locale={Uri.EscapeDataString(locale.Trim())}&"; - var forcePart = forceRefresh ? "true" : "false"; - return new Uri($"{baseUrl}{path}?{localePart}forceRefresh={forcePart}", UriKind.Absolute); - } - - private Uri BuildDailyPoetryUri(string? locale, bool forceRefresh) - { - var baseUrl = _options.BaseUrl.TrimEnd('/'); - var path = _options.DailyPoetryPath.StartsWith("/", StringComparison.Ordinal) - ? _options.DailyPoetryPath - : $"/{_options.DailyPoetryPath}"; - var localePart = string.IsNullOrWhiteSpace(locale) - ? string.Empty - : $"locale={Uri.EscapeDataString(locale.Trim())}&"; - var forcePart = forceRefresh ? "true" : "false"; - return new Uri($"{baseUrl}{path}?{localePart}forceRefresh={forcePart}", UriKind.Absolute); - } - - private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot) - { - lock (_cacheGate) - { - if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow) - { - snapshot = _dailyArtworkCache.Snapshot; - return true; - } - } - - snapshot = null!; - return false; - } - - private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot) - { - lock (_cacheGate) - { - _dailyArtworkCache = new DailyArtworkCacheEntry( - snapshot, - DateTimeOffset.UtcNow.Add(_options.CacheDuration)); - } - } - - private bool TryGetDailyPoetryFromCache(out DailyPoetrySnapshot snapshot) - { - lock (_cacheGate) - { - if (_dailyPoetryCache is not null && _dailyPoetryCache.ExpireAt > DateTimeOffset.UtcNow) - { - snapshot = _dailyPoetryCache.Snapshot; - return true; - } - } - - snapshot = null!; - return false; - } - - private void SetDailyPoetryCache(DailyPoetrySnapshot snapshot) - { - lock (_cacheGate) - { - _dailyPoetryCache = new DailyPoetryCacheEntry( - snapshot, - DateTimeOffset.UtcNow.Add(_options.CacheDuration)); - } - } - - private static string? ReadString(JsonElement node, params string[] path) - { - var target = TryGetNode(node, path); - if (!target.HasValue) - { - return null; - } - - return target.Value.ValueKind switch - { - JsonValueKind.String => target.Value.GetString(), - JsonValueKind.Number => target.Value.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - _ => null - }; - } - - private static bool? ReadBool(JsonElement node, params string[] path) - { - var target = TryGetNode(node, path); - if (!target.HasValue) - { - return null; - } - - return target.Value.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.String when bool.TryParse(target.Value.GetString(), out var parsed) => parsed, - _ => null - }; - } - - private static JsonElement? TryGetNode(JsonElement node, params string[] path) - { - var current = node; - foreach (var segment in path) - { - if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next)) - { - return null; - } - - current = next; - } - - return current; - } - - private static DateTimeOffset? ParseDateTimeOffset(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null; - } - - private string? BuildArtworkImageUrl(string? imageId) - { - if (string.IsNullOrWhiteSpace(imageId)) - { - return null; - } - - return string.Format( - CultureInfo.InvariantCulture, - _options.ArtInstituteImageUrlTemplate, - imageId.Trim()); - } - - private static string? ReadFirstNonEmptyLine(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - return text - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) - .Select(line => line.Trim()) - .FirstOrDefault(line => !string.IsNullOrWhiteSpace(line)); - } - - private static DateOnly GetChinaLocalDate() - { - var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8)); - return DateOnly.FromDateTime(now.Date); - } - - private static string Truncate(string? text, int maxLength) - { - if (string.IsNullOrEmpty(text)) - { - return string.Empty; - } - - return text.Length <= maxLength - ? text - : $"{text[..maxLength]}..."; - } -} diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs new file mode 100644 index 0000000..14022c6 --- /dev/null +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +public sealed class RecommendationDataService : IRecommendationInfoService, IDisposable +{ + private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt); + private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt); + private sealed record ArtworkCandidate( + string Title, + string? Artist, + string? Year, + string? ArtworkUrl, + string? ImageId); + + private readonly RecommendationApiOptions _options; + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly object _cacheGate = new(); + private DailyArtworkCacheEntry? _dailyArtworkCache; + private DailyPoetryCacheEntry? _dailyPoetryCache; + + public RecommendationDataService( + RecommendationApiOptions? options = null, + HttpClient? httpClient = null) + { + _options = options ?? new RecommendationApiOptions(); + if (httpClient is null) + { + _httpClient = new HttpClient + { + Timeout = _options.RequestTimeout + }; + _ownsHttpClient = true; + } + else + { + _httpClient = httpClient; + _ownsHttpClient = false; + } + } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + public void ClearCache() + { + lock (_cacheGate) + { + _dailyArtworkCache = null; + _dailyPoetryCache = null; + } + } + + public async Task> GetDailyPoetryAsync( + DailyPoetryQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new DailyPoetryQuery(); + if (!normalizedQuery.ForceRefresh && TryGetDailyPoetryFromCache(out var cached)) + { + return RecommendationQueryResult.Ok(cached); + } + + string responseText; + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl); + request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"); + using var response = await _httpClient.SendAsync(request, cancellationToken); + responseText = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + return RecommendationQueryResult.Fail( + "upstream_http_error", + $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + + try + { + using var document = JsonDocument.Parse(responseText); + var root = document.RootElement; + var content = ReadString(root, "content"); + if (string.IsNullOrWhiteSpace(content)) + { + return RecommendationQueryResult.Fail( + "upstream_parse_error", + "Poetry content is empty."); + } + + var snapshot = new DailyPoetrySnapshot( + Provider: "JinriShici", + Content: content.Trim(), + Origin: ReadString(root, "origin"), + Author: ReadString(root, "author"), + Category: ReadString(root, "category"), + FetchedAt: DateTimeOffset.UtcNow); + + SetDailyPoetryCache(snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); + } + } + + public async Task> GetDailyArtworkAsync( + DailyArtworkQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new DailyArtworkQuery(); + if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached)) + { + return RecommendationQueryResult.Ok(cached); + } + + var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100); + var localDate = GetChinaLocalDate(); + var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100); + var requestUrl = string.Format( + CultureInfo.InvariantCulture, + _options.ArtInstituteArtworkApiTemplate, + page, + candidateCount); + + string responseText; + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"); + using var response = await _httpClient.SendAsync(request, cancellationToken); + responseText = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + return RecommendationQueryResult.Fail( + "upstream_http_error", + $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + + try + { + using var document = JsonDocument.Parse(responseText); + var root = document.RootElement; + if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array) + { + return RecommendationQueryResult.Fail("upstream_parse_error", "Artwork list is missing."); + } + + var candidates = new List(); + foreach (var item in dataArray.EnumerateArray()) + { + var title = ReadString(item, "title"); + var imageId = ReadString(item, "image_id"); + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId)) + { + continue; + } + + var artist = ReadString(item, "artist_title"); + if (string.IsNullOrWhiteSpace(artist)) + { + artist = ReadFirstNonEmptyLine(ReadString(item, "artist_display")); + } + + candidates.Add(new ArtworkCandidate( + title.Trim(), + artist, + ReadString(item, "date_display"), + ReadString(item, "api_link"), + imageId.Trim())); + } + + if (candidates.Count == 0) + { + return RecommendationQueryResult.Fail("upstream_empty_result", "No artwork candidates were returned."); + } + + var indexSeed = localDate.Year * 1000 + localDate.DayOfYear; + var selected = candidates[Math.Abs(indexSeed) % candidates.Count]; + var snapshot = new DailyArtworkSnapshot( + Provider: "ArtInstituteOfChicago", + Title: selected.Title, + Artist: selected.Artist, + Year: selected.Year, + Museum: "The Art Institute of Chicago", + ArtworkUrl: selected.ArtworkUrl, + ImageUrl: BuildArtworkImageUrl(selected.ImageId), + FetchedAt: DateTimeOffset.UtcNow); + + SetDailyArtworkCache(snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); + } + } + + private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot) + { + lock (_cacheGate) + { + if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow) + { + snapshot = _dailyArtworkCache.Snapshot; + return true; + } + } + + snapshot = null!; + return false; + } + + private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot) + { + lock (_cacheGate) + { + _dailyArtworkCache = new DailyArtworkCacheEntry( + snapshot, + DateTimeOffset.UtcNow.Add(_options.CacheDuration)); + } + } + + private bool TryGetDailyPoetryFromCache(out DailyPoetrySnapshot snapshot) + { + lock (_cacheGate) + { + if (_dailyPoetryCache is not null && _dailyPoetryCache.ExpireAt > DateTimeOffset.UtcNow) + { + snapshot = _dailyPoetryCache.Snapshot; + return true; + } + } + + snapshot = null!; + return false; + } + + private void SetDailyPoetryCache(DailyPoetrySnapshot snapshot) + { + lock (_cacheGate) + { + _dailyPoetryCache = new DailyPoetryCacheEntry( + snapshot, + DateTimeOffset.UtcNow.Add(_options.CacheDuration)); + } + } + + private static string? ReadString(JsonElement node, params string[] path) + { + var target = TryGetNode(node, path); + if (!target.HasValue) + { + return null; + } + + return target.Value.ValueKind switch + { + JsonValueKind.String => target.Value.GetString(), + JsonValueKind.Number => target.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null + }; + } + + private static JsonElement? TryGetNode(JsonElement node, params string[] path) + { + var current = node; + foreach (var segment in path) + { + if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next)) + { + return null; + } + + current = next; + } + + return current; + } + + private string? BuildArtworkImageUrl(string? imageId) + { + if (string.IsNullOrWhiteSpace(imageId)) + { + return null; + } + + return string.Format( + CultureInfo.InvariantCulture, + _options.ArtInstituteImageUrlTemplate, + imageId.Trim()); + } + + private static string? ReadFirstNonEmptyLine(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return text + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .FirstOrDefault(line => !string.IsNullOrWhiteSpace(line)); + } + + private static DateOnly GetChinaLocalDate() + { + var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8)); + return DateOnly.FromDateTime(now.Date); + } + + private static string Truncate(string? text, int maxLength) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + return text.Length <= maxLength + ? text + : $"{text[..maxLength]}..."; + } +} diff --git a/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs index 469b8f9..b769003 100644 --- a/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs @@ -16,7 +16,7 @@ using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; -public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget +public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget { private static readonly IReadOnlyDictionary ZhWeekdays = new Dictionary @@ -45,7 +45,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget private const int BaseWidthCells = 4; private const int BaseHeightCells = 2; - private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationBackendService(); + private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService(); private readonly DispatcherTimer _refreshTimer = new() { @@ -113,6 +113,15 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget UpdateAdaptiveLayout(); } + public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) + { + _recommendationService = recommendationInfoService ?? DefaultRecommendationService; + if (_isAttached) + { + _ = RefreshArtworkAsync(forceRefresh: false); + } + } + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; @@ -266,7 +275,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget StatusTextBlock.IsVisible = true; StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed"); PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.fallback_title", "Daily Artwork")); - ArtistTextBlock.Text = L("artwork.widget.fallback_artist", "Recommendation backend unavailable"); + ArtistTextBlock.Text = L("artwork.widget.fallback_artist", "Recommendation service unavailable"); YearTextBlock.Text = L("artwork.widget.fallback_year", "Try again later"); UpdateAdaptiveLayout(); } diff --git a/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml.cs index 2510c65..98d7a4a 100644 --- a/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml.cs @@ -16,7 +16,7 @@ using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; -public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget +public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget { private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled); private static readonly char[] NaturalBreakChars = @@ -40,7 +40,7 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget private static readonly HashSet NaturalBreakCharSet = new(NaturalBreakChars); private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); - private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationBackendService(); + private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService(); private const double BaseCellSize = 48d; private const int BaseWidthCells = 4; @@ -143,6 +143,15 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget ApplyModeVisualIfNeeded(force: true); } + public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) + { + _recommendationService = recommendationInfoService ?? DefaultRecommendationService; + if (_isAttached) + { + _ = RefreshPoetryAsync(forceRefresh: false); + } + } + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 17693f0..d1efa00 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -37,7 +37,11 @@ public sealed class DesktopComponentRuntimeDescriptor public string DisplayNameLocalizationKey { get; } - public Control CreateControl(double cellSize, TimeZoneService timeZoneService, IWeatherInfoService weatherInfoService) + public Control CreateControl( + double cellSize, + TimeZoneService timeZoneService, + IWeatherInfoService weatherInfoService, + IRecommendationInfoService recommendationInfoService) { var control = _controlFactory(); if (control is IDesktopComponentWidget sizedComponent) @@ -55,6 +59,11 @@ public sealed class DesktopComponentRuntimeDescriptor weatherInfoAwareComponent.SetWeatherInfoService(weatherInfoService); } + if (control is IRecommendationInfoAwareComponentWidget recommendationInfoAwareComponent) + { + recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService); + } + return control; } diff --git a/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs b/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs index 2b03cc9..de16901 100644 --- a/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs +++ b/LanMountainDesktop/Views/Components/IDesktopComponentWidget.cs @@ -18,6 +18,11 @@ public interface IWeatherInfoAwareComponentWidget void SetWeatherInfoService(IWeatherInfoService weatherInfoService); } +public interface IRecommendationInfoAwareComponentWidget +{ + void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService); +} + public interface IDesktopPageVisibilityAwareComponentWidget { void SetDesktopPageContext(bool isOnActivePage, bool isEditMode); diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 27c5ba1..d53af8f 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -1425,7 +1425,11 @@ public partial class MainWindow return null; } - var component = runtimeDescriptor.CreateControl(_currentDesktopCellSize, _timeZoneService, _weatherDataService); + var component = runtimeDescriptor.CreateControl( + _currentDesktopCellSize, + _timeZoneService, + _weatherDataService, + _recommendationInfoService); component.Classes.Add(DesktopComponentClass); return component; } @@ -2533,7 +2537,11 @@ public partial class MainWindow var previewHeight = previewSpan.HeightCells * previewCellSize; var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110); - var previewControl = descriptor.CreateControl(renderCellSize, _timeZoneService, _weatherDataService); + var previewControl = descriptor.CreateControl( + renderCellSize, + _timeZoneService, + _weatherDataService, + _recommendationInfoService); var previewSurface = new Border { diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 3b03acb..b926d5c 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -91,6 +91,7 @@ public partial class MainWindow : Window private readonly LocalizationService _localizationService = new(); private readonly TimeZoneService _timeZoneService = new(); private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService(); + private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); private readonly ComponentRegistry _componentRegistry = ComponentRegistry .CreateDefault() .RegisterExtensions( @@ -275,6 +276,10 @@ public partial class MainWindow : Window { weatherServiceDisposable.Dispose(); } + if (_recommendationInfoService is IDisposable recommendationServiceDisposable) + { + recommendationServiceDisposable.Dispose(); + } _wallpaperBitmap?.Dispose(); _wallpaperBitmap = null; PropertyChanged -= OnWindowPropertyChanged; diff --git a/run.md b/run.md index 39ba775..5a49597 100644 --- a/run.md +++ b/run.md @@ -19,22 +19,8 @@ dotnet build LanMountainDesktop.sln -c Debug dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj ``` -## 4. 可选:运行推荐后端 -如果你需要每日诗词/名画等推荐能力,可单独启动后端: - -```bash -dotnet run --project LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj -``` - -后端默认会输出监听地址(通常是 `http://localhost:5xxx` 或 `https://localhost:7xxx`)。 - -可用健康检查: - -```bash -curl http://localhost:5000/health -``` - -说明:端口以你本机启动日志为准,`5000` 仅为示例。 +## 4. 推荐能力说明 +桌面端已内置推荐数据服务(每日诗词 / 每日名画),默认无需额外启动本地推荐后端。 ## 5. 常见问题 - 启动失败提示 SDK 版本不匹配:确认 `dotnet --info` 中已安装 .NET 10 SDK。