mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.2.2
时钟组件的完善。
This commit is contained in:
678
LanMontainDesktop/Services/HolidayCalendarService.cs
Normal file
678
LanMontainDesktop/Services/HolidayCalendarService.cs
Normal file
@@ -0,0 +1,678 @@
|
||||
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;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public enum HolidayDayType
|
||||
{
|
||||
Workday = 0,
|
||||
Weekend = 1,
|
||||
LegalHoliday = 2,
|
||||
AdjustedWorkday = 3,
|
||||
Unknown = 99
|
||||
}
|
||||
|
||||
public sealed record HolidayDayStatus(
|
||||
DateOnly Date,
|
||||
HolidayDayType DayType,
|
||||
string TypeNameZh,
|
||||
bool IsHoliday,
|
||||
bool IsAdjustedWorkday,
|
||||
string? NameZh,
|
||||
string? NameEn,
|
||||
string? TargetHolidayZh);
|
||||
|
||||
public sealed record HolidayDisplayInfo(
|
||||
HolidayCountdownInfo? NextHoliday,
|
||||
HolidayDayStatus TodayStatus,
|
||||
bool UsesOnlineData);
|
||||
|
||||
public sealed class HolidayCalendarService : IDisposable
|
||||
{
|
||||
private static readonly ChineseLunisolarCalendar LunarCalendar = new();
|
||||
|
||||
private sealed record HolidayTemplate(
|
||||
string NameZh,
|
||||
string NameEn,
|
||||
Func<int, DateOnly?> ResolveDateForYear);
|
||||
|
||||
private sealed record HolidayArrangementDay(
|
||||
DateOnly Date,
|
||||
bool IsHoliday,
|
||||
bool IsAdjustedWorkday,
|
||||
string NameZh,
|
||||
string NameEn,
|
||||
string? TargetHolidayZh,
|
||||
bool? IsAfterAdjust);
|
||||
|
||||
private sealed record CacheEntry<T>(T Value, DateTimeOffset ExpireAt);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> HolidayNameMapZhToEn =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["\u5143\u65e6"] = "New Year's Day",
|
||||
["\u6625\u8282"] = "Spring Festival",
|
||||
["\u6e05\u660e\u8282"] = "Tomb-Sweeping Day",
|
||||
["\u52b3\u52a8\u8282"] = "Labor Day",
|
||||
["\u7aef\u5348\u8282"] = "Dragon Boat Festival",
|
||||
["\u4e2d\u79cb\u8282"] = "Mid-Autumn Festival",
|
||||
["\u56fd\u5e86\u8282"] = "National Day",
|
||||
["\u9664\u5915"] = "Lunar New Year's Eve",
|
||||
["\u521d\u4e00"] = "Spring Festival Day 1",
|
||||
["\u521d\u4e8c"] = "Spring Festival Day 2",
|
||||
["\u521d\u4e09"] = "Spring Festival Day 3",
|
||||
["\u521d\u56db"] = "Spring Festival Day 4",
|
||||
["\u521d\u4e94"] = "Spring Festival Day 5",
|
||||
["\u521d\u516d"] = "Spring Festival Day 6",
|
||||
["\u521d\u4e03"] = "Spring Festival Day 7"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyList<HolidayTemplate> HolidayTemplates =
|
||||
[
|
||||
new HolidayTemplate(
|
||||
"\u5143\u65e6",
|
||||
"New Year's Day",
|
||||
year => new DateOnly(year, 1, 1)),
|
||||
new HolidayTemplate(
|
||||
"\u6625\u8282",
|
||||
"Spring Festival",
|
||||
year => TryResolveLunarHolidayDate(year, lunarMonth: 1, lunarDay: 1)),
|
||||
new HolidayTemplate(
|
||||
"\u52b3\u52a8\u8282",
|
||||
"Labor Day",
|
||||
year => new DateOnly(year, 5, 1)),
|
||||
new HolidayTemplate(
|
||||
"\u7aef\u5348\u8282",
|
||||
"Dragon Boat Festival",
|
||||
year => TryResolveLunarHolidayDate(year, lunarMonth: 5, lunarDay: 5)),
|
||||
new HolidayTemplate(
|
||||
"\u4e2d\u79cb\u8282",
|
||||
"Mid-Autumn Festival",
|
||||
year => TryResolveLunarHolidayDate(year, lunarMonth: 8, lunarDay: 15)),
|
||||
new HolidayTemplate(
|
||||
"\u56fd\u5e86\u8282",
|
||||
"National Day",
|
||||
year => new DateOnly(year, 10, 1))
|
||||
];
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly string _baseUrl;
|
||||
private readonly TimeSpan _yearCacheDuration;
|
||||
private readonly TimeSpan _dayCacheDuration;
|
||||
private readonly object _cacheGate = new();
|
||||
private readonly Dictionary<int, CacheEntry<IReadOnlyList<HolidayArrangementDay>>> _yearHolidayCache = new();
|
||||
private readonly Dictionary<DateOnly, CacheEntry<HolidayDayStatus>> _dayStatusCache = new();
|
||||
|
||||
public HolidayCalendarService(
|
||||
HttpClient? httpClient = null,
|
||||
string baseUrl = "https://timor.tech",
|
||||
TimeSpan? yearCacheDuration = null,
|
||||
TimeSpan? dayCacheDuration = null,
|
||||
TimeSpan? requestTimeout = null)
|
||||
{
|
||||
_baseUrl = string.IsNullOrWhiteSpace(baseUrl) ? "https://timor.tech" : baseUrl.Trim();
|
||||
_yearCacheDuration = yearCacheDuration ?? TimeSpan.FromHours(12);
|
||||
_dayCacheDuration = dayCacheDuration ?? TimeSpan.FromHours(3);
|
||||
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = requestTimeout ?? TimeSpan.FromSeconds(8)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HolidayDisplayInfo> GetDisplayInfoAsync(
|
||||
DateTime dateTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(dateTime.Date);
|
||||
var todayStatus = BuildFallbackDayStatus(today);
|
||||
var usesOnlineData = false;
|
||||
|
||||
try
|
||||
{
|
||||
todayStatus = await GetDayStatusOnlineAsync(today, cancellationToken);
|
||||
usesOnlineData = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep local fallback status.
|
||||
}
|
||||
|
||||
HolidayCountdownInfo? nextHoliday = null;
|
||||
try
|
||||
{
|
||||
nextHoliday = await GetNextHolidayOnlineAsync(today, cancellationToken);
|
||||
if (nextHoliday is not null)
|
||||
{
|
||||
usesOnlineData = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep local fallback countdown.
|
||||
}
|
||||
|
||||
nextHoliday ??= GetNextHoliday(dateTime);
|
||||
return new HolidayDisplayInfo(nextHoliday, todayStatus, usesOnlineData);
|
||||
}
|
||||
|
||||
public HolidayCountdownInfo? GetNextHoliday(DateTime dateTime)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(dateTime.Date);
|
||||
var candidates = BuildHolidayCandidates(today.Year - 1, today.Year + 3);
|
||||
HolidayCountdownInfo? best = null;
|
||||
|
||||
foreach (var holiday in candidates)
|
||||
{
|
||||
if (holiday.Date < today)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best is null || holiday.Date < best.Date)
|
||||
{
|
||||
best = holiday;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private async Task<HolidayCountdownInfo?> GetNextHolidayOnlineAsync(DateOnly today, CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = new List<HolidayCountdownInfo>();
|
||||
for (var year = today.Year; year <= today.Year + 2; year++)
|
||||
{
|
||||
var yearData = await GetYearHolidayDataOnlineAsync(year, cancellationToken);
|
||||
foreach (var item in yearData)
|
||||
{
|
||||
if (!item.IsHoliday || item.Date < today)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add(new HolidayCountdownInfo(
|
||||
NameZh: item.NameZh,
|
||||
NameEn: item.NameEn,
|
||||
Date: item.Date));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.Count == 0
|
||||
? null
|
||||
: candidates.OrderBy(item => item.Date).First();
|
||||
}
|
||||
|
||||
private async Task<HolidayDayStatus> GetDayStatusOnlineAsync(DateOnly date, CancellationToken cancellationToken)
|
||||
{
|
||||
if (TryGetDayStatusFromCache(date, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var uri = BuildRequestUri($"/api/holiday/info/{date:yyyy-MM-dd}");
|
||||
var responseText = await FetchAsync(uri, cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
var code = ReadInt(root, "code");
|
||||
if (code.HasValue && code.Value != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Holiday API returned error code {code.Value}.");
|
||||
}
|
||||
|
||||
var typeNode = TryGetNode(root, "type");
|
||||
var holidayNode = TryGetNode(root, "holiday");
|
||||
|
||||
var apiType = ReadInt(typeNode, "type");
|
||||
var typeNameZh = ReadString(typeNode, "name") ?? string.Empty;
|
||||
var dayType = MapDayType(apiType);
|
||||
|
||||
var holidayFlag = ReadBool(holidayNode, "holiday");
|
||||
var isHoliday = holidayFlag ?? dayType == HolidayDayType.LegalHoliday;
|
||||
var nameZh = ReadString(holidayNode, "name");
|
||||
var targetZh = ReadString(holidayNode, "target");
|
||||
var isAdjustedWorkday =
|
||||
dayType == HolidayDayType.AdjustedWorkday ||
|
||||
(!isHoliday &&
|
||||
(!string.IsNullOrWhiteSpace(targetZh) ||
|
||||
(nameZh?.Contains("\u8865\u73ed", StringComparison.Ordinal) ?? false)));
|
||||
|
||||
var nameEn = ResolveHolidayEnName(nameZh, targetZh, isHoliday, isAdjustedWorkday);
|
||||
var result = new HolidayDayStatus(
|
||||
Date: date,
|
||||
DayType: dayType,
|
||||
TypeNameZh: typeNameZh,
|
||||
IsHoliday: isHoliday,
|
||||
IsAdjustedWorkday: isAdjustedWorkday,
|
||||
NameZh: nameZh,
|
||||
NameEn: nameEn,
|
||||
TargetHolidayZh: targetZh);
|
||||
|
||||
SetDayStatusCache(date, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<HolidayArrangementDay>> GetYearHolidayDataOnlineAsync(int year, CancellationToken cancellationToken)
|
||||
{
|
||||
if (TryGetYearHolidayDataFromCache(year, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var uri = BuildRequestUri($"/api/holiday/year/{year}/");
|
||||
var responseText = await FetchAsync(uri, cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
var code = ReadInt(root, "code");
|
||||
if (code.HasValue && code.Value != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Holiday year API returned error code {code.Value}.");
|
||||
}
|
||||
|
||||
var result = new List<HolidayArrangementDay>();
|
||||
var holidayRoot = TryGetNode(root, "holiday");
|
||||
if (holidayRoot is not null && holidayRoot.Value.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in holidayRoot.Value.EnumerateObject())
|
||||
{
|
||||
var node = property.Value;
|
||||
var date = ParseDateOnly(ReadString(node, "date")) ??
|
||||
ParseDateOnly(string.Create(CultureInfo.InvariantCulture, $"{year}-{property.Name}"));
|
||||
if (!date.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isHoliday = ReadBool(node, "holiday") ?? false;
|
||||
var nameZh = ReadString(node, "name");
|
||||
if (string.IsNullOrWhiteSpace(nameZh))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetZh = ReadString(node, "target");
|
||||
var isAfterAdjust = ReadBool(node, "after");
|
||||
var isAdjustedWorkday = !isHoliday &&
|
||||
(!string.IsNullOrWhiteSpace(targetZh) ||
|
||||
nameZh.Contains("\u8865\u73ed", StringComparison.Ordinal));
|
||||
|
||||
result.Add(new HolidayArrangementDay(
|
||||
Date: date.Value,
|
||||
IsHoliday: isHoliday,
|
||||
IsAdjustedWorkday: isAdjustedWorkday,
|
||||
NameZh: nameZh,
|
||||
NameEn: ResolveHolidayEnName(nameZh, targetZh, isHoliday, isAdjustedWorkday),
|
||||
TargetHolidayZh: targetZh,
|
||||
IsAfterAdjust: isAfterAdjust));
|
||||
}
|
||||
}
|
||||
|
||||
result.Sort((left, right) => left.Date.CompareTo(right.Date));
|
||||
SetYearHolidayDataCache(year, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<HolidayCountdownInfo> BuildHolidayCandidates(int yearFrom, int yearToInclusive)
|
||||
{
|
||||
var results = new List<HolidayCountdownInfo>();
|
||||
if (yearToInclusive < yearFrom)
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
for (var year = yearFrom; year <= yearToInclusive; year++)
|
||||
{
|
||||
foreach (var template in HolidayTemplates)
|
||||
{
|
||||
var date = template.ResolveDateForYear(year);
|
||||
if (!date.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new HolidayCountdownInfo(
|
||||
NameZh: template.NameZh,
|
||||
NameEn: template.NameEn,
|
||||
Date: date.Value));
|
||||
}
|
||||
}
|
||||
|
||||
results.Sort((left, right) => left.Date.CompareTo(right.Date));
|
||||
return results;
|
||||
}
|
||||
|
||||
private static DateOnly? TryResolveLunarHolidayDate(int lunarYear, int lunarMonth, int lunarDay)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappedMonth = MapRegularLunarMonthToRawMonth(lunarYear, lunarMonth);
|
||||
var gregorian = LunarCalendar.ToDateTime(lunarYear, mappedMonth, lunarDay, 0, 0, 0, 0);
|
||||
return DateOnly.FromDateTime(gregorian);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int MapRegularLunarMonthToRawMonth(int lunarYear, int regularMonth)
|
||||
{
|
||||
var leapMonth = LunarCalendar.GetLeapMonth(lunarYear);
|
||||
if (leapMonth == 0)
|
||||
{
|
||||
return regularMonth;
|
||||
}
|
||||
|
||||
// ChineseLunisolarCalendar month index inserts leap month:
|
||||
// if leap month is after regular month N, GetLeapMonth returns N + 1.
|
||||
// Months after that slot shift by +1.
|
||||
return leapMonth <= regularMonth ? regularMonth + 1 : regularMonth;
|
||||
}
|
||||
|
||||
private static HolidayDayStatus BuildFallbackDayStatus(DateOnly date)
|
||||
{
|
||||
var dayOfWeek = date.DayOfWeek;
|
||||
var isWeekend = dayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||
return new HolidayDayStatus(
|
||||
Date: date,
|
||||
DayType: isWeekend ? HolidayDayType.Weekend : HolidayDayType.Workday,
|
||||
TypeNameZh: isWeekend ? "\u5468\u672b" : "\u5de5\u4f5c\u65e5",
|
||||
IsHoliday: false,
|
||||
IsAdjustedWorkday: false,
|
||||
NameZh: null,
|
||||
NameEn: null,
|
||||
TargetHolidayZh: null);
|
||||
}
|
||||
|
||||
private static HolidayDayType MapDayType(int? apiType)
|
||||
{
|
||||
return apiType switch
|
||||
{
|
||||
0 => HolidayDayType.Workday,
|
||||
1 => HolidayDayType.Weekend,
|
||||
2 => HolidayDayType.LegalHoliday,
|
||||
3 => HolidayDayType.AdjustedWorkday,
|
||||
_ => HolidayDayType.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveHolidayEnName(
|
||||
string? holidayNameZh,
|
||||
string? targetHolidayZh,
|
||||
bool isHoliday,
|
||||
bool isAdjustedWorkday)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(holidayNameZh) &&
|
||||
HolidayNameMapZhToEn.TryGetValue(holidayNameZh, out var holidayEn))
|
||||
{
|
||||
return holidayEn;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(targetHolidayZh) &&
|
||||
HolidayNameMapZhToEn.TryGetValue(targetHolidayZh, out var targetEn))
|
||||
{
|
||||
return isAdjustedWorkday
|
||||
? $"{targetEn} Make-up Workday"
|
||||
: targetEn;
|
||||
}
|
||||
|
||||
if (isAdjustedWorkday)
|
||||
{
|
||||
return "Make-up Workday";
|
||||
}
|
||||
|
||||
return isHoliday ? "Holiday" : "Workday";
|
||||
}
|
||||
|
||||
private async Task<string> FetchAsync(Uri requestUri, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(content, 180)}");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private Uri BuildRequestUri(string path)
|
||||
{
|
||||
var baseUrl = _baseUrl.TrimEnd('/');
|
||||
var normalizedPath = path.StartsWith("/", StringComparison.Ordinal) ? path : $"/{path}";
|
||||
return new Uri($"{baseUrl}{normalizedPath}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
private bool TryGetYearHolidayDataFromCache(int year, out IReadOnlyList<HolidayArrangementDay> value)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_yearHolidayCache.TryGetValue(year, out var entry) &&
|
||||
entry.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
value = entry.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = Array.Empty<HolidayArrangementDay>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetYearHolidayDataCache(int year, IReadOnlyList<HolidayArrangementDay> value)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_yearHolidayCache[year] = new CacheEntry<IReadOnlyList<HolidayArrangementDay>>(
|
||||
value,
|
||||
DateTimeOffset.UtcNow.Add(_yearCacheDuration));
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetDayStatusFromCache(DateOnly date, out HolidayDayStatus value)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_dayStatusCache.TryGetValue(date, out var entry) &&
|
||||
entry.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
value = entry.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = BuildFallbackDayStatus(date);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetDayStatusCache(DateOnly date, HolidayDayStatus value)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_dayStatusCache[date] = new CacheEntry<HolidayDayStatus>(
|
||||
value,
|
||||
DateTimeOffset.UtcNow.Add(_dayCacheDuration));
|
||||
}
|
||||
}
|
||||
|
||||
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 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 int? ReadInt(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;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(target.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? ReadBool(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;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number))
|
||||
{
|
||||
return number != 0;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = target.Value.GetString();
|
||||
if (bool.TryParse(value, out var parsedBool))
|
||||
{
|
||||
return parsedBool;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedInt))
|
||||
{
|
||||
return parsedInt != 0;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDateOnly(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
|
||||
{
|
||||
return dateOnly;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTime))
|
||||
{
|
||||
return DateOnly.FromDateTime(dateTime);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Truncate(string? text, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return text.Length <= maxLength
|
||||
? text
|
||||
: $"{text[..maxLength]}...";
|
||||
}
|
||||
|
||||
public static string FormatDate(DateOnly date, bool isZh)
|
||||
{
|
||||
return isZh
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{date.Year}\u5e74{date.Month}\u6708{date.Day}\u65e5")
|
||||
: date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record HolidayCountdownInfo(
|
||||
string NameZh,
|
||||
string NameEn,
|
||||
DateOnly Date);
|
||||
44
LanMontainDesktop/Services/IWeatherDataService.cs
Normal file
44
LanMontainDesktop/Services/IWeatherDataService.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public sealed record WeatherQuery(
|
||||
string LocationKey,
|
||||
double Latitude,
|
||||
double Longitude,
|
||||
int ForecastDays = 7,
|
||||
string? Locale = null,
|
||||
bool? IsGlobal = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record WeatherQueryResult<T>(
|
||||
bool Success,
|
||||
T? Data,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static WeatherQueryResult<T> Ok(T data)
|
||||
{
|
||||
return new WeatherQueryResult<T>(true, data);
|
||||
}
|
||||
|
||||
public static WeatherQueryResult<T> Fail(string errorCode, string errorMessage)
|
||||
{
|
||||
return new WeatherQueryResult<T>(false, default, errorCode, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IWeatherDataService
|
||||
{
|
||||
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||
string keyword,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
void ClearCache();
|
||||
}
|
||||
731
LanMontainDesktop/Services/XiaomiWeatherService.cs
Normal file
731
LanMontainDesktop/Services/XiaomiWeatherService.cs
Normal file
@@ -0,0 +1,731 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public sealed record XiaomiWeatherApiOptions
|
||||
{
|
||||
public string BaseUrl { get; init; } = "https://weatherapi.market.xiaomi.com";
|
||||
|
||||
public string WeatherAllPath { get; init; } = "/wtr-v3/weather/all";
|
||||
|
||||
public string CitySearchPath { get; init; } = "/wtr-v3/location/city/search";
|
||||
|
||||
public string AppKey { get; init; } = "weather20151024";
|
||||
|
||||
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
||||
|
||||
public string Source { get; init; } = "xiaomi";
|
||||
|
||||
public string Locale { get; init; } = "zh_cn";
|
||||
|
||||
public bool IsGlobal { get; init; }
|
||||
|
||||
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
|
||||
}
|
||||
|
||||
public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||
{
|
||||
private sealed record CacheEntry(WeatherSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
|
||||
private static readonly IReadOnlyDictionary<int, string> ZhWeatherDescriptions = new Dictionary<int, string>
|
||||
{
|
||||
[0] = "\u6674",
|
||||
[1] = "\u591a\u4e91",
|
||||
[2] = "\u9634",
|
||||
[3] = "\u9635\u96e8",
|
||||
[4] = "\u96f7\u9635\u96e8",
|
||||
[7] = "\u5c0f\u96e8",
|
||||
[8] = "\u4e2d\u96e8",
|
||||
[9] = "\u5927\u96e8",
|
||||
[13] = "\u9635\u96ea",
|
||||
[14] = "\u5c0f\u96ea",
|
||||
[15] = "\u4e2d\u96ea",
|
||||
[16] = "\u5927\u96ea",
|
||||
[18] = "\u96fe",
|
||||
[32] = "\u973e"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<int, string> EnWeatherDescriptions = new Dictionary<int, string>
|
||||
{
|
||||
[0] = "Clear",
|
||||
[1] = "Partly Cloudy",
|
||||
[2] = "Cloudy",
|
||||
[3] = "Shower",
|
||||
[4] = "Thunder Shower",
|
||||
[7] = "Light Rain",
|
||||
[8] = "Moderate Rain",
|
||||
[9] = "Heavy Rain",
|
||||
[13] = "Snow Flurry",
|
||||
[14] = "Light Snow",
|
||||
[15] = "Moderate Snow",
|
||||
[16] = "Heavy Snow",
|
||||
[18] = "Fog",
|
||||
[32] = "Haze"
|
||||
};
|
||||
|
||||
private readonly XiaomiWeatherApiOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly object _cacheGate = new();
|
||||
private readonly Dictionary<string, CacheEntry> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public XiaomiWeatherService(
|
||||
XiaomiWeatherApiOptions? options = null,
|
||||
HttpClient? httpClient = null)
|
||||
{
|
||||
_options = options ?? new XiaomiWeatherApiOptions();
|
||||
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<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||
string keyword,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Fail("invalid_keyword", "Keyword cannot be empty.");
|
||||
}
|
||||
|
||||
var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? _options.Locale : locale.Trim();
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["name"] = keyword.Trim(),
|
||||
["locale"] = normalizedLocale
|
||||
};
|
||||
|
||||
var requestUri = BuildUri(_options.CitySearchPath, parameters);
|
||||
string responseText;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Fail(
|
||||
"http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Fail("network_error", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
if (TryGetProperty(root, out var dataNode, "data"))
|
||||
{
|
||||
root = dataNode;
|
||||
}
|
||||
|
||||
var locations = ParseLocationArray(root);
|
||||
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Ok(locations);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WeatherQueryResult<IReadOnlyList<WeatherLocation>>.Fail("parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
|
||||
WeatherQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query.LocationKey))
|
||||
{
|
||||
return WeatherQueryResult<WeatherSnapshot>.Fail("invalid_location", "LocationKey is required.");
|
||||
}
|
||||
|
||||
var normalizedDays = Math.Clamp(query.ForecastDays, 1, 15);
|
||||
var normalizedLocale = string.IsNullOrWhiteSpace(query.Locale) ? _options.Locale : query.Locale.Trim();
|
||||
var isGlobal = query.IsGlobal ?? _options.IsGlobal;
|
||||
var cacheKey = BuildCacheKey(query.LocationKey, query.Latitude, query.Longitude, normalizedDays, normalizedLocale, isGlobal);
|
||||
|
||||
if (!query.ForceRefresh && TryGetCached(cacheKey, out var cached))
|
||||
{
|
||||
return WeatherQueryResult<WeatherSnapshot>.Ok(cached);
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["locationKey"] = query.LocationKey.Trim(),
|
||||
["latitude"] = query.Latitude.ToString("F6", CultureInfo.InvariantCulture),
|
||||
["longitude"] = query.Longitude.ToString("F6", CultureInfo.InvariantCulture),
|
||||
["days"] = normalizedDays.ToString(CultureInfo.InvariantCulture),
|
||||
["appKey"] = _options.AppKey,
|
||||
["sign"] = _options.Sign,
|
||||
["locale"] = normalizedLocale,
|
||||
["isGlobal"] = isGlobal ? "true" : "false",
|
||||
["ts"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.Source))
|
||||
{
|
||||
parameters["source"] = _options.Source;
|
||||
}
|
||||
|
||||
var requestUri = BuildUri(_options.WeatherAllPath, parameters);
|
||||
string responseText;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return WeatherQueryResult<WeatherSnapshot>.Fail(
|
||||
"http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 220)}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WeatherQueryResult<WeatherSnapshot>.Fail("network_error", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var snapshot = ParseWeatherSnapshot(
|
||||
document.RootElement,
|
||||
query.LocationKey.Trim(),
|
||||
query.Latitude,
|
||||
query.Longitude,
|
||||
normalizedDays,
|
||||
normalizedLocale);
|
||||
|
||||
SetCache(cacheKey, snapshot);
|
||||
return WeatherQueryResult<WeatherSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WeatherQueryResult<WeatherSnapshot>.Fail("parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WeatherLocation> ParseLocationArray(JsonElement root)
|
||||
{
|
||||
var results = new List<WeatherLocation>();
|
||||
if (!TryResolveLocationArray(root, out var locationArray))
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
foreach (var item in locationArray.EnumerateArray())
|
||||
{
|
||||
var locationKey = ReadString(item, "locationKey") ??
|
||||
ReadString(item, "key") ??
|
||||
ReadString(item, "id");
|
||||
if (string.IsNullOrWhiteSpace(locationKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = ReadString(item, "name") ??
|
||||
ReadString(item, "city") ??
|
||||
locationKey;
|
||||
var affiliation = ReadString(item, "affiliation") ?? ReadString(item, "province");
|
||||
|
||||
var latitude = ReadDouble(item, "latitude") ?? 0;
|
||||
var longitude = ReadDouble(item, "longitude") ?? 0;
|
||||
|
||||
results.Add(new WeatherLocation(name, locationKey, latitude, longitude, affiliation));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private WeatherSnapshot ParseWeatherSnapshot(
|
||||
JsonElement root,
|
||||
string locationKey,
|
||||
double latitude,
|
||||
double longitude,
|
||||
int days,
|
||||
string locale)
|
||||
{
|
||||
var payload = root;
|
||||
if (TryGetProperty(payload, out var dataNode, "data"))
|
||||
{
|
||||
payload = dataNode;
|
||||
}
|
||||
|
||||
var errorCode = ReadInt(root, "code");
|
||||
if (errorCode.HasValue && errorCode.Value is not (0 or 200))
|
||||
{
|
||||
var message = ReadString(root, "description") ??
|
||||
ReadString(root, "msg") ??
|
||||
$"Weather API returned error code {errorCode.Value}.";
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
var currentNode = TryGetNode(payload, "current") ?? payload;
|
||||
var cityNode = TryGetNode(payload, "city");
|
||||
var dailyNode = TryGetNode(payload, "forecastDaily") ?? TryGetNode(payload, "daily");
|
||||
|
||||
var weatherCode = ReadInt(currentNode, "weather", "value") ??
|
||||
ReadInt(currentNode, "weatherCode") ??
|
||||
ReadInt(currentNode, "code");
|
||||
|
||||
var weatherText = ReadString(currentNode, "weather", "desc") ??
|
||||
ReadString(currentNode, "weather", "text") ??
|
||||
ResolveWeatherDescription(weatherCode, locale);
|
||||
|
||||
var current = new WeatherCurrentCondition(
|
||||
TemperatureC: ReadDouble(currentNode, "temperature", "value") ?? ReadDouble(currentNode, "temperature"),
|
||||
FeelsLikeC: ReadDouble(currentNode, "feelsLike", "value") ?? ReadDouble(currentNode, "apparentTemperature", "value"),
|
||||
RelativeHumidityPercent: ReadInt(currentNode, "humidity", "value") ?? ReadInt(currentNode, "humidity"),
|
||||
AirQualityIndex: ReadInt(payload, "aqi", "value") ??
|
||||
ReadInt(currentNode, "aqi", "value") ??
|
||||
ReadInt(payload, "aqi", "index"),
|
||||
WindSpeedKph: ReadDouble(currentNode, "wind", "speed", "value") ??
|
||||
ReadDouble(currentNode, "windSpeed", "value"),
|
||||
WindDirectionDegree: ReadDouble(currentNode, "wind", "angle", "value") ??
|
||||
ReadDouble(currentNode, "wind", "direction", "value"),
|
||||
WeatherCode: weatherCode,
|
||||
WeatherText: weatherText);
|
||||
|
||||
var forecasts = ParseDailyForecasts(dailyNode, days, locale);
|
||||
|
||||
var locationName = ReadString(cityNode, "name") ??
|
||||
ReadString(payload, "cityName") ??
|
||||
ReadString(payload, "locationName");
|
||||
var observationTime = ParseTime(ReadString(currentNode, "pubTime")) ??
|
||||
ParseTime(ReadString(payload, "pubTime")) ??
|
||||
ParseTime(ReadString(payload, "serverTime"));
|
||||
|
||||
return new WeatherSnapshot(
|
||||
Provider: "Xiaomi",
|
||||
LocationKey: locationKey,
|
||||
LocationName: locationName,
|
||||
Latitude: latitude,
|
||||
Longitude: longitude,
|
||||
FetchedAt: DateTimeOffset.UtcNow,
|
||||
ObservationTime: observationTime,
|
||||
Current: current,
|
||||
DailyForecasts: forecasts);
|
||||
}
|
||||
|
||||
private IReadOnlyList<WeatherDailyForecast> ParseDailyForecasts(JsonElement? dailyNode, int days, string locale)
|
||||
{
|
||||
var forecasts = new List<WeatherDailyForecast>();
|
||||
if (!dailyNode.HasValue || dailyNode.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return forecasts;
|
||||
}
|
||||
|
||||
var root = dailyNode.Value;
|
||||
var temperatureArray = ReadArray(root, "temperature", "value");
|
||||
var weatherArray = ReadArray(root, "weather", "value");
|
||||
var sunArray = ReadArray(root, "sunRiseSet", "value") ?? ReadArray(root, "sunriseSunset", "value");
|
||||
var precipitationArray = ReadArray(root, "precipitationProbability", "value");
|
||||
var dateArray = ReadArray(root, "date", "value") ?? ReadArray(root, "date");
|
||||
|
||||
var count = Math.Max(
|
||||
Math.Max(temperatureArray?.GetArrayLength() ?? 0, weatherArray?.GetArrayLength() ?? 0),
|
||||
Math.Max(sunArray?.GetArrayLength() ?? 0, precipitationArray?.GetArrayLength() ?? 0));
|
||||
count = Math.Max(count, dateArray?.GetArrayLength() ?? 0);
|
||||
count = Math.Clamp(count, 0, days);
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var forecastDate = ResolveDateForIndex(dateArray, i);
|
||||
if (forecastDate is null)
|
||||
{
|
||||
forecastDate = DateOnly.FromDateTime(DateTime.Today.AddDays(i));
|
||||
}
|
||||
|
||||
var tempItem = GetArrayItem(temperatureArray, i);
|
||||
var weatherItem = GetArrayItem(weatherArray, i);
|
||||
var sunItem = GetArrayItem(sunArray, i);
|
||||
var precipitationItem = GetArrayItem(precipitationArray, i);
|
||||
|
||||
var dayCode = ReadInt(weatherItem, "from") ?? ReadInt(weatherItem, "day");
|
||||
var nightCode = ReadInt(weatherItem, "to") ?? ReadInt(weatherItem, "night");
|
||||
var dayText = ResolveWeatherDescription(dayCode, locale);
|
||||
var nightText = ResolveWeatherDescription(nightCode, locale);
|
||||
|
||||
forecasts.Add(new WeatherDailyForecast(
|
||||
Date: forecastDate.Value,
|
||||
LowTemperatureC: ReadDouble(tempItem, "from") ?? ReadDouble(tempItem, "min"),
|
||||
HighTemperatureC: ReadDouble(tempItem, "to") ?? ReadDouble(tempItem, "max"),
|
||||
DayWeatherCode: dayCode,
|
||||
DayWeatherText: dayText,
|
||||
NightWeatherCode: nightCode,
|
||||
NightWeatherText: nightText,
|
||||
SunriseTime: ReadString(sunItem, "from") ?? ReadString(sunItem, "sunrise"),
|
||||
SunsetTime: ReadString(sunItem, "to") ?? ReadString(sunItem, "sunset"),
|
||||
PrecipitationProbabilityPercent: ReadInt(precipitationItem, "from") ??
|
||||
ReadInt(precipitationItem, "value") ??
|
||||
ReadInt(precipitationItem, "probability")));
|
||||
}
|
||||
|
||||
return forecasts;
|
||||
}
|
||||
|
||||
private static DateOnly? ResolveDateForIndex(JsonElement? dateArray, int index)
|
||||
{
|
||||
var item = GetArrayItem(dateArray, index);
|
||||
if (item is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = item.Value.GetString();
|
||||
if (DateOnly.TryParse(text, out var dateOnly))
|
||||
{
|
||||
return dateOnly;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(text, out var dateTime))
|
||||
{
|
||||
return DateOnly.FromDateTime(dateTime);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool TryGetCached(string key, out WeatherSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
snapshot = entry.Snapshot;
|
||||
return true;
|
||||
}
|
||||
|
||||
_cache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetCache(string key, WeatherSnapshot snapshot)
|
||||
{
|
||||
var expireAt = DateTimeOffset.UtcNow.Add(_options.CacheDuration);
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_cache[key] = new CacheEntry(snapshot, expireAt);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(
|
||||
string locationKey,
|
||||
double latitude,
|
||||
double longitude,
|
||||
int days,
|
||||
string locale,
|
||||
bool isGlobal)
|
||||
{
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{locationKey.Trim()}|{latitude:F4}|{longitude:F4}|{days}|{locale}|{isGlobal}");
|
||||
}
|
||||
|
||||
private Uri BuildUri(string path, IReadOnlyDictionary<string, string> query)
|
||||
{
|
||||
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||
var requestPath = path.StartsWith("/", StringComparison.Ordinal) ? path : $"/{path}";
|
||||
|
||||
var builder = new System.Text.StringBuilder(baseUrl.Length + requestPath.Length + 128);
|
||||
builder.Append(baseUrl);
|
||||
builder.Append(requestPath);
|
||||
|
||||
var first = true;
|
||||
foreach (var pair in query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(first ? '?' : '&');
|
||||
first = false;
|
||||
builder.Append(Uri.EscapeDataString(pair.Key));
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(pair.Value ?? string.Empty));
|
||||
}
|
||||
|
||||
return new Uri(builder.ToString(), UriKind.Absolute);
|
||||
}
|
||||
|
||||
private static bool TryResolveLocationArray(JsonElement root, out JsonElement array)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
array = root;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, out array, "cities") && array.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, out array, "city") && array.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, out array, "location") && array.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, out var data) && data.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
array = data;
|
||||
return true;
|
||||
}
|
||||
|
||||
array = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement node, out JsonElement value, string propertyName = "data")
|
||||
{
|
||||
value = default;
|
||||
return node.ValueKind == JsonValueKind.Object &&
|
||||
node.TryGetProperty(propertyName, out value);
|
||||
}
|
||||
|
||||
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 JsonElement? ReadArray(JsonElement node, params string[] path)
|
||||
{
|
||||
var target = TryGetNode(node, path);
|
||||
if (target is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return target.Value;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.Object &&
|
||||
target.Value.TryGetProperty("value", out var value) &&
|
||||
value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement? GetArrayItem(JsonElement? array, int index)
|
||||
{
|
||||
if (!array.HasValue || array.Value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (index < 0 || index >= array.Value.GetArrayLength())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return array.Value[index];
|
||||
}
|
||||
|
||||
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 int? ReadInt(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;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(target.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? ReadDouble(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;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetDouble(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (target.Value.ValueKind == JsonValueKind.String &&
|
||||
double.TryParse(target.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTime(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dto))
|
||||
{
|
||||
return dto;
|
||||
}
|
||||
|
||||
if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var epoch))
|
||||
{
|
||||
// Xiaomi endpoints may return second or millisecond Unix timestamps.
|
||||
return epoch > 1_000_000_000_000
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(epoch)
|
||||
: DateTimeOffset.FromUnixTimeSeconds(epoch);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolveWeatherDescription(int? code, string locale)
|
||||
{
|
||||
if (!code.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var isZh = locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase);
|
||||
var source = isZh ? ZhWeatherDescriptions : EnWeatherDescriptions;
|
||||
if (source.TryGetValue(code.Value, out var text))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
return isZh ? $"\u5929\u6c14\u7801 {code.Value}" : $"Weather {code.Value}";
|
||||
}
|
||||
|
||||
private static string Truncate(string? text, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return text.Length <= maxLength
|
||||
? text
|
||||
: $"{text[..maxLength]}...";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user