Files
Api/DouyinApi.Services/DailyFortuneService.cs
2025-11-09 18:41:10 +08:00

446 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;
using DouyinApi.Common.Option;
using DouyinApi.IServices;
using DouyinApi.Model.DailyFortune;
using DouyinApi.Services.DailyFortune;
using DouyinApi.Services.Naming;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DouyinApi.Services;
public class DailyFortuneService : IDailyFortuneService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly DeepSeekOptions _deepSeekOptions;
private readonly ILogger<DailyFortuneService> _logger;
private readonly BaziCalculator _baziCalculator = new();
private readonly DailyFortuneFallbackGenerator _fallbackGenerator = new();
public DailyFortuneService(
IHttpClientFactory httpClientFactory,
IOptions<DeepSeekOptions> deepSeekOptions,
ILogger<DailyFortuneService> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_deepSeekOptions = deepSeekOptions?.Value ?? new DeepSeekOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DailyFortuneResponse> AnalyzeAsync(DailyFortuneRequest request)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var normalized = NormalizeRequest(request);
if (string.IsNullOrWhiteSpace(normalized.BirthCity))
{
throw new ArgumentException("invalid_birth_city");
}
var birthDateTime = ComposeBirthDateTime(normalized);
var fortuneMoment = GetBeijingNow();
var birthProfile = _baziCalculator.Calculate(birthDateTime);
var todayProfile = _baziCalculator.Calculate(fortuneMoment);
var profile = BuildProfile(normalized, birthDateTime, birthProfile, todayProfile);
var insight = await TryGenerateViaDeepSeekAsync(normalized, birthProfile, todayProfile, fortuneMoment).ConfigureAwait(false)
?? _fallbackGenerator.Generate(normalized, birthProfile, todayProfile, fortuneMoment);
return new DailyFortuneResponse
{
FortuneDate = fortuneMoment.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Profile = profile,
Dimensions = insight.Dimensions,
LuckyGuide = insight.LuckyGuide,
Summary = insight.Summary,
Narrative = insight.Narrative
};
}
private static DailyFortuneRequest NormalizeRequest(DailyFortuneRequest request) =>
new()
{
BirthDate = (request.BirthDate ?? string.Empty).Trim(),
BirthTime = (request.BirthTime ?? string.Empty).Trim(),
BirthCity = (request.BirthCity ?? string.Empty).Trim(),
BirthProvince = (request.BirthProvince ?? string.Empty).Trim()
};
private static DateTime ComposeBirthDateTime(DailyFortuneRequest request)
{
if (!DateTime.TryParseExact(
request.BirthDate,
new[] { "yyyy-MM-dd", "yyyy/M/d", "yyyy.M.d" },
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var birthDate))
{
throw new ArgumentException("invalid_birthdate");
}
if (!string.IsNullOrWhiteSpace(request.BirthTime) &&
TimeSpan.TryParse(request.BirthTime, CultureInfo.InvariantCulture, out var timeSpan))
{
return birthDate.Date.Add(timeSpan);
}
return birthDate.Date.AddHours(12);
}
private static DateTime GetBeijingNow()
{
var utcNow = DateTime.UtcNow;
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
return TimeZoneInfo.ConvertTimeFromUtc(utcNow, tz);
}
catch (TimeZoneNotFoundException)
{
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
return TimeZoneInfo.ConvertTimeFromUtc(utcNow, tz);
}
catch
{
return utcNow.AddHours(8);
}
}
catch (InvalidTimeZoneException)
{
return utcNow.AddHours(8);
}
}
private static FortuneProfile BuildProfile(
DailyFortuneRequest request,
DateTime birthDateTime,
BaziProfile birthProfile,
BaziProfile todayProfile)
{
return new FortuneProfile
{
BirthCity = request.BirthCity,
BirthProvince = request.BirthProvince,
BirthDateTime = birthDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
BirthPillars = BuildPillars(birthProfile),
TodayPillars = BuildPillars(todayProfile),
FiveElementDistribution = BuildFiveElements(birthProfile),
WeakElements = birthProfile.WeakElements.ToArray(),
StrongElements = birthProfile.StrongElements.ToArray()
};
}
private static IReadOnlyList<PillarInfo> BuildPillars(BaziProfile profile) =>
new List<PillarInfo>
{
new() { Label = "年柱", Value = $"{profile.YearStem}{profile.YearBranch}" },
new() { Label = "月柱", Value = $"{profile.MonthStem}{profile.MonthBranch}" },
new() { Label = "日柱", Value = $"{profile.DayStem}{profile.DayBranch}" },
new() { Label = "时柱", Value = $"{profile.HourStem}{profile.HourBranch}" }
};
private static IReadOnlyList<DailyFortuneFiveElementScore> BuildFiveElements(BaziProfile profile) =>
profile.ElementOrder
.Select(element => new DailyFortuneFiveElementScore
{
Element = element,
Count = profile.ElementCounts[element]
})
.ToList();
private async Task<DailyFortuneInsight> TryGenerateViaDeepSeekAsync(
DailyFortuneRequest request,
BaziProfile birthProfile,
BaziProfile todayProfile,
DateTime fortuneMoment)
{
if (IsDeepSeekConfigInvalid())
{
_logger.LogWarning("DeepSeek skipped for daily fortune: configuration missing.");
return null;
}
try
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(_deepSeekOptions.TimeoutSeconds > 0 ? _deepSeekOptions.TimeoutSeconds : 20);
var httpRequest = BuildDeepSeekRequest(request, birthProfile, todayProfile, fortuneMoment);
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _deepSeekOptions.ApiKey);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await client.SendAsync(httpRequest).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("DeepSeek daily fortune failed: status={Status} payload={Payload}", (int)response.StatusCode, payload);
return null;
}
_logger.LogInformation("DeepSeek daily fortune success: status={Status} length={Length}", (int)response.StatusCode, payload?.Length ?? 0);
var insight = ParseDeepSeekResponse(payload, request, birthProfile, todayProfile, fortuneMoment);
if (insight.Dimensions == null || insight.Dimensions.Count < 3)
{
_logger.LogWarning("DeepSeek daily fortune returned insufficient dimensions, fallback will be used.");
return null;
}
return insight;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DeepSeek daily fortune invocation failed, using fallback.");
return null;
}
}
private HttpRequestMessage BuildDeepSeekRequest(
DailyFortuneRequest request,
BaziProfile birthProfile,
BaziProfile todayProfile,
DateTime fortuneMoment)
{
var endpoint = string.IsNullOrWhiteSpace(_deepSeekOptions.BaseUrl)
? "https://api.deepseek.com/v1/chat/completions"
: $"{_deepSeekOptions.BaseUrl.TrimEnd('/')}/chat/completions";
var context = new
{
birthCity = request.BirthCity,
birthProvince = request.BirthProvince,
birthDate = request.BirthDate,
birthTime = request.BirthTime,
fortuneDate = fortuneMoment.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
birthPillars = new
{
year = $"{birthProfile.YearStem}{birthProfile.YearBranch}",
month = $"{birthProfile.MonthStem}{birthProfile.MonthBranch}",
day = $"{birthProfile.DayStem}{birthProfile.DayBranch}",
hour = $"{birthProfile.HourStem}{birthProfile.HourBranch}"
},
todayPillars = new
{
year = $"{todayProfile.YearStem}{todayProfile.YearBranch}",
month = $"{todayProfile.MonthStem}{todayProfile.MonthBranch}",
day = $"{todayProfile.DayStem}{todayProfile.DayBranch}",
hour = $"{todayProfile.HourStem}{todayProfile.HourBranch}"
},
fiveElements = birthProfile.ElementOrder.ToDictionary(
element => element,
element => birthProfile.ElementCounts[element]),
weakElements = birthProfile.WeakElements,
strongElements = birthProfile.StrongElements,
isBalanced = birthProfile.IsBalanced
};
var contextJson = JsonSerializer.Serialize(context, new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
var prompt = new StringBuilder();
prompt.AppendLine("你是一名资深命理师,结合八字与当日天干地支分析每日运势。");
prompt.AppendLine("请根据下述 JSON 数据(出生信息 + 八字 + 五行 + 今日干支)输出结构化的每日分析:");
prompt.AppendLine(contextJson);
prompt.AppendLine("输出要求:");
prompt.AppendLine("1. 必须返回 JSON 对象,键包括 summary、narrative、dimensions、luckyGuide");
prompt.AppendLine("2. dimensions 数组含 6 项,每项包含 key、title、score(0-100)、trend(up/down/steady)、insight、suggestion");
prompt.AppendLine("3. luckyGuide 需提供 element、colors[]、directions[]、props[]、activities[],以及 bestTimeSlots[](含 label、period、reason");
prompt.AppendLine("4. narrative 给出 2-3 段深入解读Summary 保持 1 句话;");
prompt.AppendLine("5. 禁止输出除 JSON 以外的文字。");
var body = new
{
model = _deepSeekOptions.Model,
temperature = _deepSeekOptions.Temperature,
response_format = new { type = _deepSeekOptions.ResponseFormat },
messages = new[]
{
new { role = "system", content = "你是一位严谨的中文命理顾问,擅长结合八字和当日天象输出可执行的每日指引。" },
new { role = "user", content = prompt.ToString() }
}
};
return new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")
};
}
private DailyFortuneInsight ParseDeepSeekResponse(
string payload,
DailyFortuneRequest request,
BaziProfile birthProfile,
BaziProfile todayProfile,
DateTime fortuneMoment)
{
try
{
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
if (root.TryGetProperty("choices", out var choices) &&
choices.ValueKind == JsonValueKind.Array &&
choices.GetArrayLength() > 0)
{
var messageContent = choices[0].GetProperty("message").GetProperty("content").GetString();
if (!string.IsNullOrWhiteSpace(messageContent))
{
using var inner = JsonDocument.Parse(messageContent);
return ParseInsightObject(inner.RootElement);
}
}
if (root.ValueKind == JsonValueKind.Object)
{
return ParseInsightObject(root);
}
_logger.LogWarning("DeepSeek daily fortune payload not recognized.");
return _fallbackGenerator.Generate(request, birthProfile, todayProfile, fortuneMoment);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DeepSeek daily fortune parse failed, fallback to generator.");
return _fallbackGenerator.Generate(request, birthProfile, todayProfile, fortuneMoment);
}
}
private DailyFortuneInsight ParseInsightObject(JsonElement element)
{
var summary = element.GetPropertyOrDefault("summary");
var narrative = element.GetPropertyOrDefault("narrative");
var dimensions = ParseDimensions(element);
var luckyGuide = ParseLuckyGuide(element);
return new DailyFortuneInsight
{
Summary = string.IsNullOrWhiteSpace(summary) ? "今日行运信息已生成。" : summary,
Narrative = string.IsNullOrWhiteSpace(narrative) ? "结合先天命盘与当日天象,建议顺势而为、稳中求进。" : narrative,
Dimensions = dimensions,
LuckyGuide = luckyGuide
};
}
private static IReadOnlyList<FortuneDimension> ParseDimensions(JsonElement element)
{
if (!element.TryGetProperty("dimensions", out var array) || array.ValueKind != JsonValueKind.Array)
{
return Array.Empty<FortuneDimension>();
}
var normalized = new List<FortuneDimension>();
foreach (var item in array.EnumerateArray())
{
var key = item.GetPropertyOrDefault("key");
var title = item.GetPropertyOrDefault("title");
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
if (string.IsNullOrWhiteSpace(title))
{
title = key switch
{
"career" => "事业运",
"wealth" => "财务运",
"relationship" => "情感运",
"health" => "健康运",
"social" => "人际运",
"inspiration" => "灵感运",
_ => key
};
}
var score = item.TryGetProperty("score", out var scoreElement) && scoreElement.TryGetInt32(out var scoreValue)
? Math.Clamp(scoreValue, 0, 100)
: 70;
var trend = item.GetPropertyOrDefault("trend");
trend = trend is "up" or "down" or "steady" ? trend : "steady";
normalized.Add(new FortuneDimension
{
Key = key,
Title = title,
Score = score,
Trend = trend,
Insight = item.GetPropertyOrDefault("insight"),
Suggestion = item.GetPropertyOrDefault("suggestion")
});
}
return normalized;
}
private static LuckyGuide ParseLuckyGuide(JsonElement element)
{
if (!element.TryGetProperty("luckyGuide", out var guideElement) || guideElement.ValueKind != JsonValueKind.Object)
{
return new LuckyGuide();
}
return new LuckyGuide
{
Element = guideElement.GetPropertyOrDefault("element"),
Colors = ReadStringArray(guideElement, "colors"),
Directions = ReadStringArray(guideElement, "directions"),
Props = ReadStringArray(guideElement, "props"),
Activities = ReadStringArray(guideElement, "activities"),
BestTimeSlots = ReadTimeSlots(guideElement)
};
}
private static IReadOnlyList<string> ReadStringArray(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var array) || array.ValueKind != JsonValueKind.Array)
{
return Array.Empty<string>();
}
return array
.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : string.Empty)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToList();
}
private static IReadOnlyList<LuckyTimeSlot> ReadTimeSlots(JsonElement guideElement)
{
if (!guideElement.TryGetProperty("bestTimeSlots", out var array) || array.ValueKind != JsonValueKind.Array)
{
return Array.Empty<LuckyTimeSlot>();
}
return array
.EnumerateArray()
.Select(item => new LuckyTimeSlot
{
Label = item.GetPropertyOrDefault("label"),
Period = item.GetPropertyOrDefault("period"),
Reason = item.GetPropertyOrDefault("reason")
})
.Where(slot => !string.IsNullOrWhiteSpace(slot.Label) && !string.IsNullOrWhiteSpace(slot.Period))
.ToList();
}
private bool IsDeepSeekConfigInvalid() =>
string.IsNullOrWhiteSpace(_deepSeekOptions.ApiKey);
}