Files
Api/DouyinApi.Services/DailyFortuneService.cs

446 lines
18 KiB
C#
Raw Permalink Normal View History

2025-11-09 18:41:10 +08:00
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);
}