351 lines
13 KiB
C#
351 lines
13 KiB
C#
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.Text.RegularExpressions;
|
||
using System.Threading.Tasks;
|
||
using DouyinApi.Common.Option;
|
||
using DouyinApi.IServices;
|
||
using DouyinApi.Model.Naming;
|
||
using DouyinApi.Services.Naming;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
|
||
namespace DouyinApi.Services;
|
||
|
||
public class NamingService : INamingService
|
||
{
|
||
private static readonly Regex SurnamePattern = new(@"^[\u4e00-\u9fa5]{1,2}$", RegexOptions.Compiled);
|
||
|
||
private readonly IHttpClientFactory _httpClientFactory;
|
||
private readonly DeepSeekOptions _deepSeekOptions;
|
||
private readonly ILogger<NamingService> _logger;
|
||
private readonly BaziCalculator _baziCalculator = new();
|
||
private readonly FallbackNameGenerator _fallbackGenerator = new();
|
||
|
||
public NamingService(
|
||
IHttpClientFactory httpClientFactory,
|
||
IOptions<DeepSeekOptions> deepSeekOptions,
|
||
ILogger<NamingService> logger)
|
||
{
|
||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||
_deepSeekOptions = deepSeekOptions?.Value ?? new DeepSeekOptions();
|
||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||
}
|
||
|
||
public Task<bool> ValidateSurnameAsync(string surname)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(surname))
|
||
{
|
||
return Task.FromResult(false);
|
||
}
|
||
|
||
return Task.FromResult(SurnamePattern.IsMatch(surname.Trim()));
|
||
}
|
||
|
||
public async Task<NamingResponse> GenerateNamesAsync(NamingRequest request)
|
||
{
|
||
if (request == null)
|
||
{
|
||
throw new ArgumentNullException(nameof(request));
|
||
}
|
||
|
||
if (!await ValidateSurnameAsync(request.Surname).ConfigureAwait(false))
|
||
{
|
||
throw new ArgumentException("invalid_surname");
|
||
}
|
||
|
||
var birthDateTime = ComposeBirthDateTime(request);
|
||
var baziProfile = _baziCalculator.Calculate(birthDateTime);
|
||
var analysis = BuildAnalysis(request, baziProfile);
|
||
|
||
var suggestions = await TryGenerateViaDeepSeekAsync(request, baziProfile).ConfigureAwait(false);
|
||
if (!suggestions.Any())
|
||
{
|
||
suggestions = _fallbackGenerator.Generate(request, baziProfile);
|
||
}
|
||
|
||
return new NamingResponse
|
||
{
|
||
Analysis = analysis,
|
||
Results = suggestions
|
||
};
|
||
}
|
||
|
||
private static DateTime ComposeBirthDateTime(NamingRequest request)
|
||
{
|
||
var dateFormats = new[] { "yyyy-MM-dd", "yyyy/M/d", "yyyy.M.d" };
|
||
if (!DateTime.TryParseExact(request.BirthDate, dateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var datePart))
|
||
{
|
||
datePart = DateTime.Today;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(request.BirthTime) &&
|
||
TimeSpan.TryParse(request.BirthTime, CultureInfo.InvariantCulture, out var timePart))
|
||
{
|
||
return datePart.Date.Add(timePart);
|
||
}
|
||
|
||
return datePart.Date.AddHours(12);
|
||
}
|
||
|
||
private static NamingAnalysis BuildAnalysis(NamingRequest request, BaziProfile profile)
|
||
{
|
||
var pillars = new List<string>
|
||
{
|
||
$"{profile.YearStem}{profile.YearBranch}年",
|
||
$"{profile.MonthStem}{profile.MonthBranch}月",
|
||
$"{profile.DayStem}{profile.DayBranch}日",
|
||
$"{profile.HourStem}{profile.HourBranch}时"
|
||
};
|
||
|
||
var distribution = profile.ElementOrder
|
||
.Select(element => new FiveElementScore
|
||
{
|
||
Element = element,
|
||
Count = profile.ElementCounts[element]
|
||
})
|
||
.ToList();
|
||
|
||
var summaryBuilder = new StringBuilder();
|
||
summaryBuilder.Append("八字:");
|
||
summaryBuilder.Append(string.Join("|", pillars));
|
||
summaryBuilder.Append("。五行分布:");
|
||
summaryBuilder.Append(string.Join("、", distribution.Select(d => $"{d.Element}{d.Count}")));
|
||
|
||
if (profile.IsBalanced)
|
||
{
|
||
summaryBuilder.Append("。五行较为均衡,可结合个人志趣择名。");
|
||
}
|
||
else
|
||
{
|
||
var weak = string.Join("、", profile.WeakElements);
|
||
summaryBuilder.Append("。");
|
||
summaryBuilder.Append($"命局中{weak}之气偏弱,宜以对应意象入名调和。");
|
||
}
|
||
|
||
return new NamingAnalysis
|
||
{
|
||
MatchSummary = summaryBuilder.ToString(),
|
||
Pillars = pillars,
|
||
ElementDistribution = distribution
|
||
};
|
||
}
|
||
|
||
private async Task<IReadOnlyList<NamingSuggestion>> TryGenerateViaDeepSeekAsync(NamingRequest request, BaziProfile profile)
|
||
{
|
||
if (IsDeepSeekConfigInvalid())
|
||
{
|
||
return Array.Empty<NamingSuggestion>();
|
||
}
|
||
|
||
try
|
||
{
|
||
var client = _httpClientFactory.CreateClient();
|
||
client.Timeout = TimeSpan.FromSeconds(_deepSeekOptions.TimeoutSeconds > 0 ? _deepSeekOptions.TimeoutSeconds : 15);
|
||
|
||
var httpRequest = BuildDeepSeekRequest(request, profile);
|
||
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 调用失败,状态码:{StatusCode},返回:{Payload}", (int)response.StatusCode, payload);
|
||
return Array.Empty<NamingSuggestion>();
|
||
}
|
||
|
||
return ParseDeepSeekResponse(payload, request);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "DeepSeek 调用异常,启用兜底逻辑");
|
||
return Array.Empty<NamingSuggestion>();
|
||
}
|
||
}
|
||
|
||
private HttpRequestMessage BuildDeepSeekRequest(NamingRequest request, BaziProfile profile)
|
||
{
|
||
var endpoint = string.IsNullOrWhiteSpace(_deepSeekOptions.BaseUrl)
|
||
? "https://api.deepseek.com/v1/chat/completions"
|
||
: $"{_deepSeekOptions.BaseUrl.TrimEnd('/')}/chat/completions";
|
||
|
||
var context = new
|
||
{
|
||
surname = request.Surname,
|
||
gender = request.Gender,
|
||
nameLength = request.NameLength,
|
||
birthDate = request.BirthDate,
|
||
birthTime = request.BirthTime,
|
||
pillars = new
|
||
{
|
||
year = $"{profile.YearStem}{profile.YearBranch}",
|
||
month = $"{profile.MonthStem}{profile.MonthBranch}",
|
||
day = $"{profile.DayStem}{profile.DayBranch}",
|
||
hour = $"{profile.HourStem}{profile.HourBranch}"
|
||
},
|
||
elementDistribution = profile.ElementOrder.ToDictionary(
|
||
element => element,
|
||
element => profile.ElementCounts[element]),
|
||
weakElements = profile.WeakElements,
|
||
strongElements = profile.StrongElements,
|
||
isBalanced = profile.IsBalanced
|
||
};
|
||
|
||
var contextJson = JsonSerializer.Serialize(context, new JsonSerializerOptions
|
||
{
|
||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||
});
|
||
|
||
var lengthDescriptor = string.Equals(request.NameLength, "single", StringComparison.OrdinalIgnoreCase) ? "单字" : "双字";
|
||
var genderLabel = string.Equals(request.Gender, "female", StringComparison.OrdinalIgnoreCase) ? "女孩" : "男孩";
|
||
|
||
var userPrompt = new StringBuilder();
|
||
userPrompt.AppendLine("请扮演专业的中华姓名学顾问,根据八字五行提出姓名建议。");
|
||
userPrompt.AppendLine($"需基于以下 JSON 数据为姓氏“{request.Surname}”的{genderLabel}提供 5 个{lengthDescriptor}中文名字:");
|
||
userPrompt.AppendLine(contextJson);
|
||
userPrompt.AppendLine("要求:");
|
||
userPrompt.AppendLine("1. 每条建议输出对象包含 name、meaning、fiveElementReason 三个字段;");
|
||
userPrompt.AppendLine("2. 所有汉字需为生活常用的简体字,单字笔画尽量小于等于 12;");
|
||
userPrompt.AppendLine("3. name 必须包含姓氏且满足给定的字数;");
|
||
userPrompt.AppendLine("4. fiveElementReason 需指明所补足的五行依据。");
|
||
userPrompt.AppendLine("请仅返回 JSON 数据,格式为 {\"suggestions\":[{...}]},不要附加额外说明。");
|
||
|
||
var requestBody = new
|
||
{
|
||
model = _deepSeekOptions.Model,
|
||
temperature = _deepSeekOptions.Temperature,
|
||
max_tokens = _deepSeekOptions.MaxTokens,
|
||
messages = new[]
|
||
{
|
||
new { role = "system", content = "你是一名资深的中文姓名学专家,擅长根据八字喜用神给出实用建议。" },
|
||
new { role = "user", content = userPrompt.ToString() }
|
||
}
|
||
};
|
||
|
||
var message = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||
{
|
||
Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json")
|
||
};
|
||
|
||
return message;
|
||
}
|
||
|
||
private IReadOnlyList<NamingSuggestion> ParseDeepSeekResponse(string payload, NamingRequest request)
|
||
{
|
||
try
|
||
{
|
||
using var document = JsonDocument.Parse(payload);
|
||
var choices = document.RootElement.GetProperty("choices");
|
||
if (choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0)
|
||
{
|
||
return Array.Empty<NamingSuggestion>();
|
||
}
|
||
|
||
var content = choices[0]
|
||
.GetProperty("message")
|
||
.GetProperty("content")
|
||
.GetString();
|
||
|
||
if (string.IsNullOrWhiteSpace(content))
|
||
{
|
||
return Array.Empty<NamingSuggestion>();
|
||
}
|
||
|
||
using var suggestionsDoc = JsonDocument.Parse(content);
|
||
if (!suggestionsDoc.RootElement.TryGetProperty("suggestions", out var suggestionsElement) ||
|
||
suggestionsElement.ValueKind != JsonValueKind.Array)
|
||
{
|
||
return Array.Empty<NamingSuggestion>();
|
||
}
|
||
|
||
var normalized = new List<NamingSuggestion>();
|
||
var uniqueNames = new HashSet<string>(StringComparer.Ordinal);
|
||
|
||
foreach (var item in suggestionsElement.EnumerateArray())
|
||
{
|
||
var name = item.GetPropertyOrDefault("name");
|
||
var meaning = item.GetPropertyOrDefault("meaning");
|
||
var reason = item.GetPropertyOrDefault("fiveElementReason");
|
||
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
name = name.Trim();
|
||
if (!name.StartsWith(request.Surname, StringComparison.Ordinal))
|
||
{
|
||
name = $"{request.Surname}{name}";
|
||
}
|
||
|
||
if (!ValidateNameLength(name, request))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!uniqueNames.Add(name))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
normalized.Add(new NamingSuggestion
|
||
{
|
||
Name = name,
|
||
Meaning = string.IsNullOrWhiteSpace(meaning) ? "寓意待补充" : meaning.Trim(),
|
||
ElementReason = string.IsNullOrWhiteSpace(reason) ? "结合八字五行进行补益。" : reason.Trim()
|
||
});
|
||
|
||
if (normalized.Count >= 5)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "解析 DeepSeek 响应失败");
|
||
return Array.Empty<NamingSuggestion>();
|
||
}
|
||
}
|
||
|
||
private static bool ValidateNameLength(string fullName, NamingRequest request)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(fullName) || fullName.Length <= request.Surname.Length)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var expectedLength = string.Equals(request.NameLength, "single", StringComparison.OrdinalIgnoreCase) ? 1 : 2;
|
||
var givenName = fullName.Substring(request.Surname.Length);
|
||
return givenName.Length == expectedLength;
|
||
}
|
||
|
||
private bool IsDeepSeekConfigInvalid()
|
||
{
|
||
return string.IsNullOrWhiteSpace(_deepSeekOptions.ApiKey);
|
||
}
|
||
}
|
||
|
||
internal static class JsonElementExtensions
|
||
{
|
||
public static string GetPropertyOrDefault(this JsonElement element, string propertyName)
|
||
{
|
||
if (element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String)
|
||
{
|
||
return value.GetString() ?? string.Empty;
|
||
}
|
||
|
||
return string.Empty;
|
||
}
|
||
}
|