From 6cba0f59762f17edc48a828c9190ba48fdcaa8f1 Mon Sep 17 00:00:00 2001 From: cjd Date: Sun, 9 Nov 2025 18:41:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=BF=90=E5=8A=BF=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MiniProgram/DailyFortuneController.cs | 78 +++ DouyinApi.Api/DouyinApi.Model.xml | 25 + DouyinApi.Api/appsettings.json | 3 +- DouyinApi.IServices/IDailyFortuneService.cs | 9 + .../DailyFortune/DailyFortuneRequest.cs | 33 ++ .../DailyFortune/DailyFortuneResponse.cs | 93 ++++ .../DailyFortuneFallbackGenerator.cs | 224 +++++++++ .../DailyFortune/DailyFortuneInsight.cs | 15 + DouyinApi.Services/DailyFortuneService.cs | 445 ++++++++++++++++++ 9 files changed, 924 insertions(+), 1 deletion(-) create mode 100644 DouyinApi.Api/Controllers/MiniProgram/DailyFortuneController.cs create mode 100644 DouyinApi.IServices/IDailyFortuneService.cs create mode 100644 DouyinApi.Model/DailyFortune/DailyFortuneRequest.cs create mode 100644 DouyinApi.Model/DailyFortune/DailyFortuneResponse.cs create mode 100644 DouyinApi.Services/DailyFortune/DailyFortuneFallbackGenerator.cs create mode 100644 DouyinApi.Services/DailyFortune/DailyFortuneInsight.cs create mode 100644 DouyinApi.Services/DailyFortuneService.cs diff --git a/DouyinApi.Api/Controllers/MiniProgram/DailyFortuneController.cs b/DouyinApi.Api/Controllers/MiniProgram/DailyFortuneController.cs new file mode 100644 index 0000000..631fa8b --- /dev/null +++ b/DouyinApi.Api/Controllers/MiniProgram/DailyFortuneController.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using DouyinApi.Controllers; +using DouyinApi.IServices; +using DouyinApi.Model.DailyFortune; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace DouyinApi.Api.Controllers.MiniProgram; + +[Produces("application/json")] +[Route("api/daily-fortune")] +[AllowAnonymous] +public class DailyFortuneController : BaseApiController +{ + private readonly IDailyFortuneService _dailyFortuneService; + private readonly IContentSecurityService _contentSecurityService; + private readonly ILogger _logger; + + public DailyFortuneController( + IDailyFortuneService dailyFortuneService, + IContentSecurityService contentSecurityService, + ILogger logger) + { + _dailyFortuneService = dailyFortuneService; + _contentSecurityService = contentSecurityService; + _logger = logger; + } + + [HttpPost("analyze")] + public async Task Analyze([FromBody] DailyFortuneRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(new { message = "invalid_request" }); + } + + var normalizedCity = request.BirthCity?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalizedCity)) + { + return BadRequest(new { message = "invalid_birth_city" }); + } + + var securityText = string.Join(" ", new[] + { + normalizedCity, + request.BirthProvince ?? string.Empty, + request.BirthDate ?? string.Empty, + request.BirthTime ?? string.Empty + }).Trim(); + + var security = await _contentSecurityService.CheckTextAsync(securityText); + if (!security.IsSafe) + { + return BadRequest(new { message = "CONTENT_RISK" }); + } + + try + { + var response = await _dailyFortuneService.AnalyzeAsync(request); + return Ok(response); + } + catch (ArgumentException ex) when (ex.Message == "invalid_birthdate") + { + return BadRequest(new { message = "BIRTHDATE_INVALID" }); + } + catch (ArgumentException ex) when (ex.Message == "invalid_birth_city") + { + return BadRequest(new { message = "invalid_birth_city" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Daily fortune analyze failed"); + return StatusCode(500, new { message = "GENERATION_FAILED" }); + } + } +} diff --git a/DouyinApi.Api/DouyinApi.Model.xml b/DouyinApi.Api/DouyinApi.Model.xml index e10d840..9813620 100644 --- a/DouyinApi.Api/DouyinApi.Model.xml +++ b/DouyinApi.Api/DouyinApi.Model.xml @@ -34,6 +34,31 @@ 所有 + + + 出生日期,格式 yyyy-MM-dd。 + + + + + 出生时间,格式 HH:mm,可为空。 + + + + + 出生城市(精确到市)。 + + + + + 出生省份/州信息,选填,用于提示词增强。 + + + + + up / down / steady + + 以下model 来自ids4项目,多库模式,为了调取ids4数据 diff --git a/DouyinApi.Api/appsettings.json b/DouyinApi.Api/appsettings.json index 0ecc441..f8dcc58 100644 --- a/DouyinApi.Api/appsettings.json +++ b/DouyinApi.Api/appsettings.json @@ -152,7 +152,8 @@ "ConnId": "WMBLOG_MYSQL", "DBType": 0, "Enabled": true, - "Connection": "Server=zhongjy001.synology.me;Port=3315;Database=douyinminiprogram;User=root;Password=123456asdfg;" + //"Connection": "Server=127.0.0.1;Port=3306;Database=douyinminiprogram;User=root;Password=e6b4274e7731b9de;" + "Connection": "Server=zhongjy001.synology.me;Port=3315;Database=fatemaster;User=root;Password=123456asdfg;" } //{ // "ConnId": "WMBLOG_MYSQL_2", diff --git a/DouyinApi.IServices/IDailyFortuneService.cs b/DouyinApi.IServices/IDailyFortuneService.cs new file mode 100644 index 0000000..a98157f --- /dev/null +++ b/DouyinApi.IServices/IDailyFortuneService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using DouyinApi.Model.DailyFortune; + +namespace DouyinApi.IServices; + +public interface IDailyFortuneService +{ + Task AnalyzeAsync(DailyFortuneRequest request); +} diff --git a/DouyinApi.Model/DailyFortune/DailyFortuneRequest.cs b/DouyinApi.Model/DailyFortune/DailyFortuneRequest.cs new file mode 100644 index 0000000..d7e2010 --- /dev/null +++ b/DouyinApi.Model/DailyFortune/DailyFortuneRequest.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace DouyinApi.Model.DailyFortune; + +public class DailyFortuneRequest +{ + /// + /// 出生日期,格式 yyyy-MM-dd。 + /// + [Required] + [RegularExpression(@"\d{4}-\d{2}-\d{2}", ErrorMessage = "birth_date_invalid")] + public string BirthDate { get; set; } = string.Empty; + + /// + /// 出生时间,格式 HH:mm,可为空。 + /// + [RegularExpression(@"^$|^\d{2}:\d{2}$", ErrorMessage = "birth_time_invalid")] + public string BirthTime { get; set; } = string.Empty; + + /// + /// 出生城市(精确到市)。 + /// + [Required] + [MinLength(2)] + [MaxLength(40)] + public string BirthCity { get; set; } = string.Empty; + + /// + /// 出生省份/州信息,选填,用于提示词增强。 + /// + [MaxLength(40)] + public string BirthProvince { get; set; } = string.Empty; +} diff --git a/DouyinApi.Model/DailyFortune/DailyFortuneResponse.cs b/DouyinApi.Model/DailyFortune/DailyFortuneResponse.cs new file mode 100644 index 0000000..8e945d1 --- /dev/null +++ b/DouyinApi.Model/DailyFortune/DailyFortuneResponse.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; + +namespace DouyinApi.Model.DailyFortune; + +public class DailyFortuneResponse +{ + public string FortuneDate { get; set; } = string.Empty; + + public FortuneProfile Profile { get; set; } = new FortuneProfile(); + + public IReadOnlyList Dimensions { get; set; } = new List(); + + public LuckyGuide LuckyGuide { get; set; } = new LuckyGuide(); + + public string Summary { get; set; } = string.Empty; + + public string Narrative { get; set; } = string.Empty; +} + +public class FortuneProfile +{ + public string BirthCity { get; set; } = string.Empty; + + public string BirthProvince { get; set; } = string.Empty; + + public string BirthDateTime { get; set; } = string.Empty; + + public IReadOnlyList BirthPillars { get; set; } = new List(); + + public IReadOnlyList TodayPillars { get; set; } = new List(); + + public IReadOnlyList FiveElementDistribution { get; set; } = new List(); + + public IReadOnlyList WeakElements { get; set; } = new List(); + + public IReadOnlyList StrongElements { get; set; } = new List(); +} + +public class PillarInfo +{ + public string Label { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; +} + +public class DailyFortuneFiveElementScore +{ + public string Element { get; set; } = string.Empty; + + public int Count { get; set; } +} + +public class FortuneDimension +{ + public string Key { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public int Score { get; set; } + + /// + /// up / down / steady + /// + public string Trend { get; set; } = "steady"; + + public string Insight { get; set; } = string.Empty; + + public string Suggestion { get; set; } = string.Empty; +} + +public class LuckyGuide +{ + public string Element { get; set; } = string.Empty; + + public IReadOnlyList Colors { get; set; } = new List(); + + public IReadOnlyList Directions { get; set; } = new List(); + + public IReadOnlyList Props { get; set; } = new List(); + + public IReadOnlyList Activities { get; set; } = new List(); + + public IReadOnlyList BestTimeSlots { get; set; } = new List(); +} + +public class LuckyTimeSlot +{ + public string Label { get; set; } = string.Empty; + + public string Period { get; set; } = string.Empty; + + public string Reason { get; set; } = string.Empty; +} diff --git a/DouyinApi.Services/DailyFortune/DailyFortuneFallbackGenerator.cs b/DouyinApi.Services/DailyFortune/DailyFortuneFallbackGenerator.cs new file mode 100644 index 0000000..fad614d --- /dev/null +++ b/DouyinApi.Services/DailyFortune/DailyFortuneFallbackGenerator.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using DouyinApi.Model.DailyFortune; +using DouyinApi.Services.Naming; + +namespace DouyinApi.Services.DailyFortune; + +internal sealed class DailyFortuneFallbackGenerator +{ + private static readonly (string Key, string Title, string Focus)[] DimensionDefinitions = + { + ("career", "事业运", "职场推进"), + ("wealth", "财务运", "资源调度"), + ("relationship", "情感运", "情绪表达"), + ("health", "健康运", "身心节奏"), + ("social", "人际运", "团队协同"), + ("inspiration", "灵感运", "学习洞察") + }; + + private static readonly Dictionary NourishMapping = new(StringComparer.Ordinal) + { + ["木"] = "水", + ["火"] = "木", + ["土"] = "火", + ["金"] = "土", + ["水"] = "金" + }; + + private static readonly Dictionary ElementMappings = new(StringComparer.Ordinal) + { + ["木"] = (new[] { "青绿", "墨绿" }, new[] { "绿松石", "木质手串" }, new[] { "正东", "东南" }), + ["火"] = (new[] { "朱红", "橘色" }, new[] { "朱砂饰品", "暖色围巾" }, new[] { "正南", "东南" }), + ["土"] = (new[] { "杏色", "暖棕" }, new[] { "陶土摆件", "黄玉戒" }, new[] { "中央", "西南" }), + ["金"] = (new[] { "米金", "象牙白" }, new[] { "金属耳饰", "白水晶" }, new[] { "正西", "西北" }), + ["水"] = (new[] { "深蓝", "青蓝" }, new[] { "黑曜石", "海蓝宝" }, new[] { "正北", "东北" }) + }; + + private static readonly (string Label, string Period)[] TimeSlots = + { + ("拂晓", "05:00-07:00"), + ("辰时", "07:00-09:00"), + ("巳时", "09:00-11:00"), + ("午时", "11:00-13:00"), + ("申时", "15:00-17:00"), + ("酉时", "17:00-19:00"), + ("夜间", "19:00-21:00") + }; + + public DailyFortuneInsight Generate(DailyFortuneRequest request, BaziProfile birthProfile, BaziProfile todayProfile, DateTime fortuneMoment) + { + var seedSource = $"{request.BirthCity}-{request.BirthDate}-{request.BirthTime}-{fortuneMoment:yyyyMMdd}"; + var seed = Math.Abs(seedSource.GetHashCode()); + var random = new Random(seed); + var weakElement = ResolveWeakElement(birthProfile, todayProfile); + + var dimensions = DimensionDefinitions + .Select((definition, index) => + { + var swing = ((seed >> (index + 1)) % 21) - 10; + var baseScore = 70 + swing; + if (!birthProfile.IsBalanced && string.Equals(definition.Key, "health", StringComparison.Ordinal)) + { + baseScore -= 2; + } + var score = Math.Clamp(baseScore, 48, 96); + var trend = swing switch + { + > 3 => "up", + < -3 => "down", + _ => "steady" + }; + + var insight = $"{definition.Focus}受{weakElement}气场牵引,今日呈现{DescribeTrend(trend)}走势。"; + var suggestion = BuildSuggestion(definition.Key, weakElement, todayProfile, trend); + + return new FortuneDimension + { + Key = definition.Key, + Title = definition.Title, + Score = score, + Trend = trend, + Insight = insight, + Suggestion = suggestion + }; + }) + .ToList(); + + var luckyGuide = BuildLuckyGuide(weakElement, todayProfile, random); + var summary = BuildSummary(dimensions, luckyGuide, fortuneMoment); + var narrative = BuildNarrative(request, dimensions, luckyGuide, weakElement, todayProfile, fortuneMoment); + + return new DailyFortuneInsight + { + Dimensions = dimensions, + LuckyGuide = luckyGuide, + Summary = summary, + Narrative = narrative + }; + } + + private static LuckyGuide BuildLuckyGuide(string weakElement, BaziProfile todayProfile, Random random) + { + var target = TargetElement(weakElement); + if (!ElementMappings.TryGetValue(target, out var mapping)) + { + mapping = ElementMappings["木"]; + } + + var slotIndex = random.Next(TimeSlots.Length); + var slotB = (slotIndex + 3) % TimeSlots.Length; + + return new LuckyGuide + { + Element = target, + Colors = mapping.Colors, + Props = mapping.Props, + Directions = mapping.Directions, + Activities = BuildActivities(target, weakElement, todayProfile), + BestTimeSlots = new[] + { + new LuckyTimeSlot + { + Label = TimeSlots[slotIndex].Label, + Period = TimeSlots[slotIndex].Period, + Reason = $"与日支{todayProfile.DayBranch}气息谐和" + }, + new LuckyTimeSlot + { + Label = TimeSlots[slotB].Label, + Period = TimeSlots[slotB].Period, + Reason = "利于沉浸式处理重点事务" + } + } + }; + } + + private static IReadOnlyList BuildActivities(string element, string weakElement, BaziProfile todayProfile) + { + var activities = new List + { + $"携带{element}系小物件,稳住气场", + $"在{todayProfile.MonthBranch}月令的主题上做一点复盘" + }; + + if (!string.Equals(element, weakElement, StringComparison.Ordinal)) + { + activities.Add($"补足{weakElement}之气,宜接触自然或深呼吸练习"); + } + + activities.Add("保持饮水与拉伸,照顾身体节奏"); + return activities; + } + + private static string BuildSummary(IEnumerable dimensions, LuckyGuide guide, DateTime fortuneMoment) + { + var avg = (int)Math.Round(dimensions.Average(x => x.Score)); + var strongest = dimensions.OrderByDescending(x => x.Score).First(); + var weakest = dimensions.OrderBy(x => x.Score).First(); + return $"{fortuneMoment:yyyy年M月d日}整体指数约为{avg}分,{strongest.Title}是今日亮点,{weakest.Title}需留意。以{guide.Element}元素入场,能更顺利地衔接节奏。"; + } + + private static string BuildNarrative( + DailyFortuneRequest request, + IEnumerable dimensions, + LuckyGuide guide, + string weakElement, + BaziProfile todayProfile, + DateTime fortuneMoment) + { + var focus = dimensions + .Where(x => x.Trend == "up") + .Select(x => x.Title) + .DefaultIfEmpty("核心主题") + .ToArray(); + + return $"结合{request.BirthCity}出生的先天命盘与今日{todayProfile.DayStem}{todayProfile.DayBranch}能量,建议把握{string.Join("、", focus)}。" + + $"多使用{string.Join("、", guide.Colors)}等{guide.Element}系元素,可温和弥补{weakElement}不足。" + + $"若能在{string.Join("、", guide.BestTimeSlots.Select(s => s.Label))}安排重要事项,将更容易与当日天象共振。"; + } + + private static string BuildSuggestion(string dimensionKey, string weakElement, BaziProfile todayProfile, string trend) + { + return dimensionKey switch + { + "career" => trend == "up" + ? "适合推进关键节点,善用会议前的5分钟整理要点。" + : "避免一次承担过多议题,可把结论拆分分批落实。", + "wealth" => trend == "up" + ? "梳理现金流或理财计划,集中处理应收款。" + : "控制冲动型消费,预算留白以应对变量。", + "relationship" => trend == "up" + ? "表达时保持真诚与柔软,分享情绪而非立场。" + : "先倾听再回应,避免在情绪波动时做决定。", + "health" => trend == "up" + ? "保持稳定作息,适合做轻量有氧或拉伸。" + : "补水与热身不可省,防止因节奏紊乱带来疲惫感。", + "social" => trend == "up" + ? "在团队中扮演连接者的角色,协调资源会有惊喜。" + : "不必事事亲自上阵,学会授权也能稳住局面。", + "inspiration" => trend == "up" + ? "灵感度高,可记录随手闪现的想法并快速试错。" + : "转换学习方式,利用碎片化时间吸收新知。", + _ => $"保持呼吸节奏,善用{weakElement}相关意象维持专注度。" + }; + } + + private static string DescribeTrend(string trend) => + trend switch + { + "up" => "回升", + "down" => "略有波动", + _ => "趋于平稳" + }; + + private static string ResolveWeakElement(BaziProfile birthProfile, BaziProfile todayProfile) => + birthProfile.WeakElements.FirstOrDefault() ?? + todayProfile.WeakElements.FirstOrDefault() ?? + "木"; + + private static string TargetElement(string weakElement) => + NourishMapping.TryGetValue(weakElement, out var target) ? target : weakElement; +} diff --git a/DouyinApi.Services/DailyFortune/DailyFortuneInsight.cs b/DouyinApi.Services/DailyFortune/DailyFortuneInsight.cs new file mode 100644 index 0000000..cbb1311 --- /dev/null +++ b/DouyinApi.Services/DailyFortune/DailyFortuneInsight.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using DouyinApi.Model.DailyFortune; + +namespace DouyinApi.Services.DailyFortune; + +internal sealed class DailyFortuneInsight +{ + public IReadOnlyList Dimensions { get; init; } = new List(); + + public LuckyGuide LuckyGuide { get; init; } = new LuckyGuide(); + + public string Summary { get; init; } = string.Empty; + + public string Narrative { get; init; } = string.Empty; +} diff --git a/DouyinApi.Services/DailyFortuneService.cs b/DouyinApi.Services/DailyFortuneService.cs new file mode 100644 index 0000000..536911e --- /dev/null +++ b/DouyinApi.Services/DailyFortuneService.cs @@ -0,0 +1,445 @@ +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); +}