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 _logger; private readonly BaziCalculator _baziCalculator = new(); private readonly DailyFortuneFallbackGenerator _fallbackGenerator = new(); public DailyFortuneService( IHttpClientFactory httpClientFactory, IOptions deepSeekOptions, ILogger logger) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _deepSeekOptions = deepSeekOptions?.Value ?? new DeepSeekOptions(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task 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 BuildPillars(BaziProfile profile) => new List { 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 BuildFiveElements(BaziProfile profile) => profile.ElementOrder .Select(element => new DailyFortuneFiveElementScore { Element = element, Count = profile.ElementCounts[element] }) .ToList(); private async Task 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 ParseDimensions(JsonElement element) { if (!element.TryGetProperty("dimensions", out var array) || array.ValueKind != JsonValueKind.Array) { return Array.Empty(); } var normalized = new List(); 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 ReadStringArray(JsonElement root, string propertyName) { if (!root.TryGetProperty(propertyName, out var array) || array.ValueKind != JsonValueKind.Array) { return Array.Empty(); } return array .EnumerateArray() .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : string.Empty) .Where(value => !string.IsNullOrWhiteSpace(value)) .ToList(); } private static IReadOnlyList ReadTimeSlots(JsonElement guideElement) { if (!guideElement.TryGetProperty("bestTimeSlots", out var array) || array.ValueKind != JsonValueKind.Array) { return Array.Empty(); } 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); }