mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.3.6
减少工程复杂度
This commit is contained in:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
# Backend Services
|
# Backend Services
|
||||||
/LanMountainDesktop/Services/ @
|
/LanMountainDesktop/Services/ @
|
||||||
/LanMountainDesktop.RecommendationBackend/ @
|
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
/docs/ @
|
/docs/ @
|
||||||
|
|||||||
8
.github/FIX_REPORT.md
vendored
8
.github/FIX_REPORT.md
vendored
@@ -17,7 +17,6 @@ The current working directory does not contain a project or solution file.
|
|||||||
### 1. 创建解决方案文件
|
### 1. 创建解决方案文件
|
||||||
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含:
|
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含:
|
||||||
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||||
- `LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj`
|
|
||||||
|
|
||||||
### 2. 验证本地构建工作
|
### 2. 验证本地构建工作
|
||||||
✅ 本地测试通过:
|
✅ 本地测试通过:
|
||||||
@@ -36,12 +35,11 @@ The current working directory does not contain a project or solution file.
|
|||||||
|
|
||||||
## 📋 解决方案文件内容
|
## 📋 解决方案文件内容
|
||||||
|
|
||||||
包含两个项目的标准 Visual Studio 解决方案格式:
|
包含主桌面项目的标准 Visual Studio 解决方案格式:
|
||||||
|
|
||||||
```
|
```
|
||||||
LanMountainDesktop.sln
|
LanMountainDesktop.sln
|
||||||
├── LanMountainDesktop (Desktop UI - Avalonia)
|
└── LanMountainDesktop (Desktop UI - Avalonia)
|
||||||
└── LanMountainDesktop.RecommendationBackend (Web API - ASP.NET Core)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -55,7 +53,7 @@ LanMountainDesktop.sln
|
|||||||
git add LanMountainDesktop.sln
|
git add LanMountainDesktop.sln
|
||||||
|
|
||||||
# 2. 提交
|
# 2. 提交
|
||||||
git commit -m "Add solution file for multi-project structure"
|
git commit -m "Add solution file for desktop project"
|
||||||
|
|
||||||
# 3. 推送
|
# 3. 推送
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|||||||
1
.github/VERSION_SYNC_INFO.md
vendored
1
.github/VERSION_SYNC_INFO.md
vendored
@@ -56,7 +56,6 @@ sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" file.csproj
|
|||||||
|
|
||||||
自动更新的文件:
|
自动更新的文件:
|
||||||
1. `LanMountainDesktop/LanMountainDesktop.csproj`
|
1. `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||||
2. `LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj`
|
|
||||||
|
|
||||||
## ✅ 使用流程
|
## ✅ 使用流程
|
||||||
|
|
||||||
|
|||||||
5
.github/WORKFLOWS_GUIDE.md
vendored
5
.github/WORKFLOWS_GUIDE.md
vendored
@@ -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
|
**Trigger:** Every push/PR to main branches, or manual dispatch
|
||||||
|
|
||||||
**What it does:**
|
**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)
|
- Runs unit tests (if available)
|
||||||
- Uploads build artifacts for inspection
|
- Uploads build artifacts for inspection
|
||||||
- Runs on Windows (windows-latest)
|
- Runs on Windows (windows-latest)
|
||||||
@@ -110,14 +110,12 @@ dotnet restore
|
|||||||
|
|
||||||
# Build (like CI does)
|
# Build (like CI does)
|
||||||
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
|
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
|
||||||
dotnet build LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj
|
|
||||||
|
|
||||||
# Format code locally (required by CI)
|
# Format code locally (required by CI)
|
||||||
dotnet format
|
dotnet format
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
dotnet test LanMountainDesktop/LanMountainDesktop.csproj
|
dotnet test LanMountainDesktop/LanMountainDesktop.csproj
|
||||||
dotnet test LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj
|
|
||||||
|
|
||||||
# Alternative: Use local build scripts (Linux/macOS)
|
# Alternative: Use local build scripts (Linux/macOS)
|
||||||
./scripts/build.sh --rid linux-x64 --version 1.0.0
|
./scripts/build.sh --rid linux-x64 --version 1.0.0
|
||||||
@@ -244,7 +242,6 @@ Consider adding:
|
|||||||
- Multi-platform builds (Linux, macOS)
|
- Multi-platform builds (Linux, macOS)
|
||||||
- Installer generation (.exe, .msi)
|
- Installer generation (.exe, .msi)
|
||||||
- Automated changelog generation
|
- Automated changelog generation
|
||||||
- Docker images for backend
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
@@ -42,7 +42,6 @@ jobs:
|
|||||||
name: build-windows-${{ matrix.configuration }}
|
name: build-windows-${{ matrix.configuration }}
|
||||||
path: |
|
path: |
|
||||||
LanMountainDesktop/bin/${{ matrix.configuration }}/
|
LanMountainDesktop/bin/${{ matrix.configuration }}/
|
||||||
LanMountainDesktop.RecommendationBackend/bin/${{ matrix.configuration }}/
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
@@ -81,7 +80,6 @@ jobs:
|
|||||||
name: build-linux
|
name: build-linux
|
||||||
path: |
|
path: |
|
||||||
LanMountainDesktop/bin/Release/
|
LanMountainDesktop/bin/Release/
|
||||||
LanMountainDesktop.RecommendationBackend/bin/Release/
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -112,5 +110,4 @@ jobs:
|
|||||||
name: build-macos
|
name: build-macos
|
||||||
path: |
|
path: |
|
||||||
LanMountainDesktop/bin/Release/
|
LanMountainDesktop/bin/Release/
|
||||||
LanMountainDesktop.RecommendationBackend/bin/Release/
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -65,8 +65,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$VERSION = "${{ needs.prepare.outputs.version }}"
|
$VERSION = "${{ needs.prepare.outputs.version }}"
|
||||||
$csprojFiles = @(
|
$csprojFiles = @(
|
||||||
"LanMountainDesktop/LanMountainDesktop.csproj",
|
"LanMountainDesktop/LanMountainDesktop.csproj"
|
||||||
"LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($csprojPath in $csprojFiles) {
|
foreach ($csprojPath in $csprojFiles) {
|
||||||
@@ -147,9 +146,6 @@ jobs:
|
|||||||
VERSION="${{ needs.prepare.outputs.version }}"
|
VERSION="${{ needs.prepare.outputs.version }}"
|
||||||
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
|
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
|
||||||
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
|
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
|
||||||
|
|
||||||
echo "Updating version in LanMountainDesktop.RecommendationBackend.csproj to $VERSION"
|
|
||||||
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj
|
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
@@ -235,9 +231,6 @@ jobs:
|
|||||||
VERSION="${{ needs.prepare.outputs.version }}"
|
VERSION="${{ needs.prepare.outputs.version }}"
|
||||||
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
|
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
|
||||||
sed -i '' "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
|
sed -i '' "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
|
||||||
|
|
||||||
echo "Updating version in LanMountainDesktop.RecommendationBackend.csproj to $VERSION"
|
|
||||||
sed -i '' "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj
|
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -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<HotSearchEntry> HotSearches);
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System;
|
|
||||||
using LanMountainDesktop.RecommendationBackend.Services;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<IRecommendationDataService>(serviceProvider =>
|
|
||||||
{
|
|
||||||
var options = builder.Configuration.GetSection("Recommendation").Get<RecommendationApiOptions>();
|
|
||||||
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();
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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`。
|
|
||||||
- 提供内存缓存,降低上游请求频率与组件刷新开销。
|
|
||||||
@@ -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<T>(
|
|
||||||
bool Success,
|
|
||||||
T? Data,
|
|
||||||
string? ErrorCode = null,
|
|
||||||
string? ErrorMessage = null)
|
|
||||||
{
|
|
||||||
public static RecommendationQueryResult<T> Ok(T data)
|
|
||||||
{
|
|
||||||
return new RecommendationQueryResult<T>(true, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RecommendationQueryResult<T> Fail(string errorCode, string errorMessage)
|
|
||||||
{
|
|
||||||
return new RecommendationQueryResult<T>(false, default, errorCode, errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IRecommendationInfoService
|
|
||||||
{
|
|
||||||
Task<RecommendationQueryResult<DailyQuoteSnapshot>> GetDailyQuoteAsync(
|
|
||||||
DailyQuoteQuery query,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
|
|
||||||
DailyPoetryQuery query,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<RecommendationQueryResult<DailyMovieRecommendation>> GetDailyMovieAsync(
|
|
||||||
DailyMovieQuery query,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
|
|
||||||
DailyArtworkQuery query,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>> GetHotSearchAsync(
|
|
||||||
HotSearchQuery query,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IRecommendationDataService : IRecommendationInfoService
|
|
||||||
{
|
|
||||||
Task<RecommendationQueryResult<RecommendationFeedSnapshot>> GetFeedAsync(
|
|
||||||
RecommendationFeedQuery query,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
void ClearCache();
|
|
||||||
}
|
|
||||||
@@ -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("<div\\s+class=\"category-wrap_[^\"]+\"[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
||||||
private static readonly Regex RankRegex = new("<div\\s+class=\"index_[^\"]+\"[^>]*>\\s*(?<value>\\d+)\\s*</div>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
||||||
private static readonly Regex TitleRegex = new("<div\\s+class=\"c-single-text-ellipsis\"[^>]*>\\s*(?<value>.*?)\\s*</div>", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
|
||||||
private static readonly Regex UrlRegex = new("<a\\s+href=\"(?<value>https?://[^\"]+)\"\\s+class=\"title_[^\"]*\"", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
||||||
private static readonly Regex HotValueRegex = new("<div\\s+class=\"hot-index_[^\"]+\"[^>]*>\\s*(?<value>[\\d,]+)\\s*</div>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
||||||
private static readonly Regex SummaryRegex = new("<div\\s+class=\"hot-desc_[^\"]+\"[^>]*>\\s*(?<value>.*?)(?:<a|</div>)", 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<string, CacheEntry> _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<RecommendationQueryResult<DailyQuoteSnapshot>> 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<DailyQuoteSnapshot>.Ok(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
string responseText;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
responseText = await FetchTextAsync(new Uri(_options.DailyQuoteUrl, UriKind.Absolute), cancellationToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyQuoteSnapshot>.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<DailyQuoteSnapshot>.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<DailyQuoteSnapshot>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyQuoteSnapshot>.Fail("parse_error", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RecommendationQueryResult<DailyPoetrySnapshot>> 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<DailyPoetrySnapshot>.Ok(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
string responseText;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
responseText = await FetchTextAsync(new Uri(_options.DailyPoetryUrl, UriKind.Absolute), cancellationToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyPoetrySnapshot>.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<DailyPoetrySnapshot>.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<DailyPoetrySnapshot>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("parse_error", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RecommendationQueryResult<DailyMovieRecommendation>> 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<DailyMovieRecommendation>.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<DailyMovieRecommendation>.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<DailyMovieRecommendation>.Fail("parse_error", "Movie list is missing.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidates = new List<MovieCandidate>();
|
|
||||||
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<DailyMovieRecommendation>.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<DailyMovieRecommendation>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyMovieRecommendation>.Fail("parse_error", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RecommendationQueryResult<DailyArtworkSnapshot>> 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<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.Fail("parse_error", "Artwork list is missing.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidates = new List<ArtworkCandidate>();
|
|
||||||
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<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("parse_error", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>> 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<HotSearchEntry> cached))
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Ok(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.Equals(provider, "Baidu", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.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<IReadOnlyList<HotSearchEntry>>.Fail("network_error", ex.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var entries = ParseBaiduHotSearch(responseText, limit);
|
|
||||||
if (entries.Count == 0)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Fail("parse_error", "No hot search entries found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
SetCache(cacheKey, entries);
|
|
||||||
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Ok(entries);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Fail("parse_error", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RecommendationQueryResult<RecommendationFeedSnapshot>> 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<RecommendationFeedSnapshot>.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<HotSearchEntry>());
|
|
||||||
|
|
||||||
return RecommendationQueryResult<RecommendationFeedSnapshot>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> FetchTextAsync(
|
|
||||||
Uri requestUri,
|
|
||||||
CancellationToken cancellationToken,
|
|
||||||
Action<HttpRequestMessage>? 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<HotSearchEntry> ParseBaiduHotSearch(string html, int limit)
|
|
||||||
{
|
|
||||||
var parts = HotSearchSplitRegex.Split(html);
|
|
||||||
var entries = new List<HotSearchEntry>(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<T>(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]}...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Recommendation": {
|
|
||||||
"CacheDuration": "00:05:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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": "*"
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ VisualStudioVersion = 17.0.31903.59
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.RecommendationBackend", "LanMountainDesktop.RecommendationBackend\LanMountainDesktop.RecommendationBackend.csproj", "{00000002-0000-0000-0000-000000000002}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -246,7 +246,7 @@
|
|||||||
"artwork.widget.loading_subtitle": "Fetching today's masterpiece",
|
"artwork.widget.loading_subtitle": "Fetching today's masterpiece",
|
||||||
"artwork.widget.fetch_failed": "Artwork fetch failed",
|
"artwork.widget.fetch_failed": "Artwork fetch failed",
|
||||||
"artwork.widget.fallback_title": "Daily Artwork",
|
"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.fallback_year": "Try again later",
|
||||||
"artwork.widget.unknown_artist": "Unknown artist",
|
"artwork.widget.unknown_artist": "Unknown artist",
|
||||||
"music.widget.unsupported": "Music control is not supported on this platform",
|
"music.widget.unsupported": "Music control is not supported on this platform",
|
||||||
|
|||||||
@@ -246,7 +246,7 @@
|
|||||||
"artwork.widget.loading_subtitle": "正在获取今日名画",
|
"artwork.widget.loading_subtitle": "正在获取今日名画",
|
||||||
"artwork.widget.fetch_failed": "名画获取失败",
|
"artwork.widget.fetch_failed": "名画获取失败",
|
||||||
"artwork.widget.fallback_title": "每日名画",
|
"artwork.widget.fallback_title": "每日名画",
|
||||||
"artwork.widget.fallback_artist": "推荐后端不可用",
|
"artwork.widget.fallback_artist": "推荐服务不可用",
|
||||||
"artwork.widget.fallback_year": "稍后重试",
|
"artwork.widget.fallback_year": "稍后重试",
|
||||||
"artwork.widget.unknown_artist": "未知作者",
|
"artwork.widget.unknown_artist": "未知作者",
|
||||||
"music.widget.unsupported": "当前平台不支持音乐控制",
|
"music.widget.unsupported": "当前平台不支持音乐控制",
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
using System;
|
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;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
@@ -35,14 +30,8 @@ public sealed record RecommendationQueryResult<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json";
|
||||||
|
|
||||||
public string ArtInstituteArtworkApiTemplate { get; init; } =
|
public string ArtInstituteArtworkApiTemplate { get; init; } =
|
||||||
@@ -70,623 +59,3 @@ public interface IRecommendationInfoService
|
|||||||
|
|
||||||
void ClearCache();
|
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<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
|
|
||||||
DailyPoetryQuery query,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var normalizedQuery = query ?? new DailyPoetryQuery();
|
|
||||||
if (!normalizedQuery.ForceRefresh && TryGetDailyPoetryFromCache(out var cached))
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyPoetrySnapshot>.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<DailyPoetrySnapshot>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return await TryDirectPoetryFallbackAsync(
|
|
||||||
normalizedQuery,
|
|
||||||
"parse_error",
|
|
||||||
ex.Message,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
|
|
||||||
DailyArtworkQuery query,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var normalizedQuery = query ?? new DailyArtworkQuery();
|
|
||||||
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return await TryDirectFallbackAsync(
|
|
||||||
normalizedQuery,
|
|
||||||
"parse_error",
|
|
||||||
ex.Message,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> 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<DailyArtworkSnapshot>.Fail(
|
|
||||||
errorCode,
|
|
||||||
$"{errorMessage}; fallback: {fallbackMessage}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> 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<DailyArtworkSnapshot>.Fail(
|
|
||||||
"upstream_http_error",
|
|
||||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Artwork list is missing.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidates = new List<ArtworkCandidate>();
|
|
||||||
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<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<RecommendationQueryResult<DailyPoetrySnapshot>> 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<DailyPoetrySnapshot>.Fail(
|
|
||||||
errorCode,
|
|
||||||
$"{errorMessage}; fallback: {fallbackMessage}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<RecommendationQueryResult<DailyPoetrySnapshot>> 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<DailyPoetrySnapshot>.Fail(
|
|
||||||
"upstream_http_error",
|
|
||||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyPoetrySnapshot>.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<DailyPoetrySnapshot>.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<DailyPoetrySnapshot>.Ok(snapshot);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return RecommendationQueryResult<DailyPoetrySnapshot>.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]}...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
358
LanMountainDesktop/Services/RecommendationDataService.cs
Normal file
358
LanMountainDesktop/Services/RecommendationDataService.cs
Normal file
@@ -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<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
|
||||||
|
DailyPoetryQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedQuery = query ?? new DailyPoetryQuery();
|
||||||
|
if (!normalizedQuery.ForceRefresh && TryGetDailyPoetryFromCache(out var cached))
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<DailyPoetrySnapshot>.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<DailyPoetrySnapshot>.Fail(
|
||||||
|
"upstream_http_error",
|
||||||
|
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<DailyPoetrySnapshot>.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<DailyPoetrySnapshot>.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<DailyPoetrySnapshot>.Ok(snapshot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
|
||||||
|
DailyArtworkQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedQuery = query ?? new DailyArtworkQuery();
|
||||||
|
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.Fail(
|
||||||
|
"upstream_http_error",
|
||||||
|
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Artwork list is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = new List<ArtworkCandidate>();
|
||||||
|
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<DailyArtworkSnapshot>.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<DailyArtworkSnapshot>.Ok(snapshot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<DailyArtworkSnapshot>.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]}...";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ using LanMountainDesktop.Services;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Views.Components;
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
|
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
||||||
{
|
{
|
||||||
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
|
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
|
||||||
new Dictionary<DayOfWeek, string>
|
new Dictionary<DayOfWeek, string>
|
||||||
@@ -45,7 +45,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
|
|||||||
private const int BaseWidthCells = 4;
|
private const int BaseWidthCells = 4;
|
||||||
private const int BaseHeightCells = 2;
|
private const int BaseHeightCells = 2;
|
||||||
|
|
||||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationBackendService();
|
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
||||||
|
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
private readonly DispatcherTimer _refreshTimer = new()
|
||||||
{
|
{
|
||||||
@@ -113,6 +113,15 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
|
|||||||
UpdateAdaptiveLayout();
|
UpdateAdaptiveLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||||
|
{
|
||||||
|
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||||
|
if (_isAttached)
|
||||||
|
{
|
||||||
|
_ = RefreshArtworkAsync(forceRefresh: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
_isAttached = true;
|
_isAttached = true;
|
||||||
@@ -266,7 +275,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
|
|||||||
StatusTextBlock.IsVisible = true;
|
StatusTextBlock.IsVisible = true;
|
||||||
StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed");
|
StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed");
|
||||||
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.fallback_title", "Daily Artwork"));
|
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");
|
YearTextBlock.Text = L("artwork.widget.fallback_year", "Try again later");
|
||||||
UpdateAdaptiveLayout();
|
UpdateAdaptiveLayout();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ using LanMountainDesktop.Services;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Views.Components;
|
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 Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||||
private static readonly char[] NaturalBreakChars =
|
private static readonly char[] NaturalBreakChars =
|
||||||
@@ -40,7 +40,7 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget
|
|||||||
private static readonly HashSet<char> NaturalBreakCharSet = new(NaturalBreakChars);
|
private static readonly HashSet<char> NaturalBreakCharSet = new(NaturalBreakChars);
|
||||||
|
|
||||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
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 double BaseCellSize = 48d;
|
||||||
private const int BaseWidthCells = 4;
|
private const int BaseWidthCells = 4;
|
||||||
@@ -143,6 +143,15 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget
|
|||||||
ApplyModeVisualIfNeeded(force: true);
|
ApplyModeVisualIfNeeded(force: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||||
|
{
|
||||||
|
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||||
|
if (_isAttached)
|
||||||
|
{
|
||||||
|
_ = RefreshPoetryAsync(forceRefresh: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
_isAttached = true;
|
_isAttached = true;
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ public sealed class DesktopComponentRuntimeDescriptor
|
|||||||
|
|
||||||
public string DisplayNameLocalizationKey { get; }
|
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();
|
var control = _controlFactory();
|
||||||
if (control is IDesktopComponentWidget sizedComponent)
|
if (control is IDesktopComponentWidget sizedComponent)
|
||||||
@@ -55,6 +59,11 @@ public sealed class DesktopComponentRuntimeDescriptor
|
|||||||
weatherInfoAwareComponent.SetWeatherInfoService(weatherInfoService);
|
weatherInfoAwareComponent.SetWeatherInfoService(weatherInfoService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (control is IRecommendationInfoAwareComponentWidget recommendationInfoAwareComponent)
|
||||||
|
{
|
||||||
|
recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService);
|
||||||
|
}
|
||||||
|
|
||||||
return control;
|
return control;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ public interface IWeatherInfoAwareComponentWidget
|
|||||||
void SetWeatherInfoService(IWeatherInfoService weatherInfoService);
|
void SetWeatherInfoService(IWeatherInfoService weatherInfoService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IRecommendationInfoAwareComponentWidget
|
||||||
|
{
|
||||||
|
void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService);
|
||||||
|
}
|
||||||
|
|
||||||
public interface IDesktopPageVisibilityAwareComponentWidget
|
public interface IDesktopPageVisibilityAwareComponentWidget
|
||||||
{
|
{
|
||||||
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);
|
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);
|
||||||
|
|||||||
@@ -1425,7 +1425,11 @@ public partial class MainWindow
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var component = runtimeDescriptor.CreateControl(_currentDesktopCellSize, _timeZoneService, _weatherDataService);
|
var component = runtimeDescriptor.CreateControl(
|
||||||
|
_currentDesktopCellSize,
|
||||||
|
_timeZoneService,
|
||||||
|
_weatherDataService,
|
||||||
|
_recommendationInfoService);
|
||||||
component.Classes.Add(DesktopComponentClass);
|
component.Classes.Add(DesktopComponentClass);
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
@@ -2533,7 +2537,11 @@ public partial class MainWindow
|
|||||||
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
||||||
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
|
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
|
var previewSurface = new Border
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ public partial class MainWindow : Window
|
|||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly TimeZoneService _timeZoneService = new();
|
private readonly TimeZoneService _timeZoneService = new();
|
||||||
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
||||||
|
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||||
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
|
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
|
||||||
.CreateDefault()
|
.CreateDefault()
|
||||||
.RegisterExtensions(
|
.RegisterExtensions(
|
||||||
@@ -275,6 +276,10 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
weatherServiceDisposable.Dispose();
|
weatherServiceDisposable.Dispose();
|
||||||
}
|
}
|
||||||
|
if (_recommendationInfoService is IDisposable recommendationServiceDisposable)
|
||||||
|
{
|
||||||
|
recommendationServiceDisposable.Dispose();
|
||||||
|
}
|
||||||
_wallpaperBitmap?.Dispose();
|
_wallpaperBitmap?.Dispose();
|
||||||
_wallpaperBitmap = null;
|
_wallpaperBitmap = null;
|
||||||
PropertyChanged -= OnWindowPropertyChanged;
|
PropertyChanged -= OnWindowPropertyChanged;
|
||||||
|
|||||||
18
run.md
18
run.md
@@ -19,22 +19,8 @@ dotnet build LanMountainDesktop.sln -c Debug
|
|||||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. 可选:运行推荐后端
|
## 4. 推荐能力说明
|
||||||
如果你需要每日诗词/名画等推荐能力,可单独启动后端:
|
桌面端已内置推荐数据服务(每日诗词 / 每日名画),默认无需额外启动本地推荐后端。
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet run --project LanMountainDesktop.RecommendationBackend/LanMountainDesktop.RecommendationBackend.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
后端默认会输出监听地址(通常是 `http://localhost:5xxx` 或 `https://localhost:7xxx`)。
|
|
||||||
|
|
||||||
可用健康检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:5000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:端口以你本机启动日志为准,`5000` 仅为示例。
|
|
||||||
|
|
||||||
## 5. 常见问题
|
## 5. 常见问题
|
||||||
- 启动失败提示 SDK 版本不匹配:确认 `dotnet --info` 中已安装 .NET 10 SDK。
|
- 启动失败提示 SDK 版本不匹配:确认 `dotnet --info` 中已安装 .NET 10 SDK。
|
||||||
|
|||||||
Reference in New Issue
Block a user