每日运势小程序
This commit is contained in:
@@ -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<DailyFortuneController> _logger;
|
||||
|
||||
public DailyFortuneController(
|
||||
IDailyFortuneService dailyFortuneService,
|
||||
IContentSecurityService contentSecurityService,
|
||||
ILogger<DailyFortuneController> logger)
|
||||
{
|
||||
_dailyFortuneService = dailyFortuneService;
|
||||
_contentSecurityService = contentSecurityService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("analyze")]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,31 @@
|
||||
所有
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:DouyinApi.Model.DailyFortune.DailyFortuneRequest.BirthDate">
|
||||
<summary>
|
||||
出生日期,格式 yyyy-MM-dd。
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:DouyinApi.Model.DailyFortune.DailyFortuneRequest.BirthTime">
|
||||
<summary>
|
||||
出生时间,格式 HH:mm,可为空。
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:DouyinApi.Model.DailyFortune.DailyFortuneRequest.BirthCity">
|
||||
<summary>
|
||||
出生城市(精确到市)。
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:DouyinApi.Model.DailyFortune.DailyFortuneRequest.BirthProvince">
|
||||
<summary>
|
||||
出生省份/州信息,选填,用于提示词增强。
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:DouyinApi.Model.DailyFortune.FortuneDimension.Trend">
|
||||
<summary>
|
||||
up / down / steady
|
||||
</summary>
|
||||
</member>
|
||||
<member name="T:DouyinApi.Model.IDS4DbModels.ApplicationRole">
|
||||
<summary>
|
||||
以下model 来自ids4项目,多库模式,为了调取ids4数据
|
||||
|
||||
@@ -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",
|
||||
|
||||
9
DouyinApi.IServices/IDailyFortuneService.cs
Normal file
9
DouyinApi.IServices/IDailyFortuneService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
using DouyinApi.Model.DailyFortune;
|
||||
|
||||
namespace DouyinApi.IServices;
|
||||
|
||||
public interface IDailyFortuneService
|
||||
{
|
||||
Task<DailyFortuneResponse> AnalyzeAsync(DailyFortuneRequest request);
|
||||
}
|
||||
33
DouyinApi.Model/DailyFortune/DailyFortuneRequest.cs
Normal file
33
DouyinApi.Model/DailyFortune/DailyFortuneRequest.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DouyinApi.Model.DailyFortune;
|
||||
|
||||
public class DailyFortuneRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 出生日期,格式 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression(@"\d{4}-\d{2}-\d{2}", ErrorMessage = "birth_date_invalid")]
|
||||
public string BirthDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 出生时间,格式 HH:mm,可为空。
|
||||
/// </summary>
|
||||
[RegularExpression(@"^$|^\d{2}:\d{2}$", ErrorMessage = "birth_time_invalid")]
|
||||
public string BirthTime { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 出生城市(精确到市)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(40)]
|
||||
public string BirthCity { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 出生省份/州信息,选填,用于提示词增强。
|
||||
/// </summary>
|
||||
[MaxLength(40)]
|
||||
public string BirthProvince { get; set; } = string.Empty;
|
||||
}
|
||||
93
DouyinApi.Model/DailyFortune/DailyFortuneResponse.cs
Normal file
93
DouyinApi.Model/DailyFortune/DailyFortuneResponse.cs
Normal file
@@ -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<FortuneDimension> Dimensions { get; set; } = new List<FortuneDimension>();
|
||||
|
||||
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<PillarInfo> BirthPillars { get; set; } = new List<PillarInfo>();
|
||||
|
||||
public IReadOnlyList<PillarInfo> TodayPillars { get; set; } = new List<PillarInfo>();
|
||||
|
||||
public IReadOnlyList<DailyFortuneFiveElementScore> FiveElementDistribution { get; set; } = new List<DailyFortuneFiveElementScore>();
|
||||
|
||||
public IReadOnlyList<string> WeakElements { get; set; } = new List<string>();
|
||||
|
||||
public IReadOnlyList<string> StrongElements { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// up / down / steady
|
||||
/// </summary>
|
||||
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<string> Colors { get; set; } = new List<string>();
|
||||
|
||||
public IReadOnlyList<string> Directions { get; set; } = new List<string>();
|
||||
|
||||
public IReadOnlyList<string> Props { get; set; } = new List<string>();
|
||||
|
||||
public IReadOnlyList<string> Activities { get; set; } = new List<string>();
|
||||
|
||||
public IReadOnlyList<LuckyTimeSlot> BestTimeSlots { get; set; } = new List<LuckyTimeSlot>();
|
||||
}
|
||||
|
||||
public class LuckyTimeSlot
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public string Period { get; set; } = string.Empty;
|
||||
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
224
DouyinApi.Services/DailyFortune/DailyFortuneFallbackGenerator.cs
Normal file
224
DouyinApi.Services/DailyFortune/DailyFortuneFallbackGenerator.cs
Normal file
@@ -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<string, string> NourishMapping = new(StringComparer.Ordinal)
|
||||
{
|
||||
["木"] = "水",
|
||||
["火"] = "木",
|
||||
["土"] = "火",
|
||||
["金"] = "土",
|
||||
["水"] = "金"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, (string[] Colors, string[] Props, string[] Directions)> 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<string> BuildActivities(string element, string weakElement, BaziProfile todayProfile)
|
||||
{
|
||||
var activities = new List<string>
|
||||
{
|
||||
$"携带{element}系小物件,稳住气场",
|
||||
$"在{todayProfile.MonthBranch}月令的主题上做一点复盘"
|
||||
};
|
||||
|
||||
if (!string.Equals(element, weakElement, StringComparison.Ordinal))
|
||||
{
|
||||
activities.Add($"补足{weakElement}之气,宜接触自然或深呼吸练习");
|
||||
}
|
||||
|
||||
activities.Add("保持饮水与拉伸,照顾身体节奏");
|
||||
return activities;
|
||||
}
|
||||
|
||||
private static string BuildSummary(IEnumerable<FortuneDimension> 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<FortuneDimension> 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;
|
||||
}
|
||||
15
DouyinApi.Services/DailyFortune/DailyFortuneInsight.cs
Normal file
15
DouyinApi.Services/DailyFortune/DailyFortuneInsight.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using DouyinApi.Model.DailyFortune;
|
||||
|
||||
namespace DouyinApi.Services.DailyFortune;
|
||||
|
||||
internal sealed class DailyFortuneInsight
|
||||
{
|
||||
public IReadOnlyList<FortuneDimension> Dimensions { get; init; } = new List<FortuneDimension>();
|
||||
|
||||
public LuckyGuide LuckyGuide { get; init; } = new LuckyGuide();
|
||||
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
|
||||
public string Narrative { get; init; } = string.Empty;
|
||||
}
|
||||
445
DouyinApi.Services/DailyFortuneService.cs
Normal file
445
DouyinApi.Services/DailyFortuneService.cs
Normal file
@@ -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<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);
|
||||
}
|
||||
Reference in New Issue
Block a user