取名小程序

This commit is contained in:
cjd
2025-11-06 19:23:42 +08:00
parent 68ae25fac2
commit 823dc8d37b
8 changed files with 490 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Globalization;
using System.Threading.Tasks;
using DouyinApi.Controllers;
@@ -16,11 +16,16 @@ namespace DouyinApi.Api.Controllers.MiniProgram
public class NamingController : BaseApiController
{
private readonly INamingService _namingService;
private readonly IContentSecurityService _contentSecurityService;
private readonly ILogger<NamingController> _logger;
public NamingController(INamingService namingService, ILogger<NamingController> logger)
public NamingController(
INamingService namingService,
IContentSecurityService contentSecurityService,
ILogger<NamingController> logger)
{
_namingService = namingService;
_contentSecurityService = contentSecurityService;
_logger = logger;
}
@@ -33,6 +38,12 @@ namespace DouyinApi.Api.Controllers.MiniProgram
}
var surname = request.Surname?.Trim() ?? string.Empty;
var security = await _contentSecurityService.CheckTextAsync(surname);
if (!security.IsSafe)
{
return Ok(new { isValid = false, message = "输入内容存在风险,请重新输入" });
}
var isValid = await _namingService.ValidateSurnameAsync(surname);
return Ok(new
{
@@ -46,13 +57,28 @@ namespace DouyinApi.Api.Controllers.MiniProgram
{
if (!ModelState.IsValid)
{
return BadRequest(new { message = "参数不合" });
return BadRequest(new { message = "参数不合" });
}
var normalizedRequest = NormalizeRequest(request);
try
{
var payload = string.Join(" ", new[]
{
normalizedRequest.Surname,
normalizedRequest.Gender,
normalizedRequest.BirthDate,
normalizedRequest.BirthTime,
normalizedRequest.NameLength
}).Trim();
var security = await _contentSecurityService.CheckTextAsync(payload);
if (!security.IsSafe)
{
return BadRequest(new { message = "CONTENT_RISK" });
}
var response = await _namingService.GenerateNamesAsync(normalizedRequest);
return Ok(new
{

View File

@@ -256,16 +256,16 @@
"Enabled": true
},
"SignalRSendLog": {
"Enabled": true
"Enabled": false
},
"QuartzNetJob": {
"Enabled": true
"Enabled": false
},
"Consul": {
"Enabled": false
},
"IpRateLimit": {
"Enabled": true
"Enabled": false
},
"EncryptionResponse": {
"Enabled": true,
@@ -362,10 +362,21 @@
},
"DeepSeek": {
"BaseUrl": "https://api.deepseek.com/v1",
"ApiKey": "",
"ApiKey": "sk-97a34dbac42b43b0a8cedc79082df0c6",
"Model": "deepseek-chat",
"TimeoutSeconds": 15,
"TimeoutSeconds": 60,
"Temperature": 0.6,
"MaxTokens": 800
"MaxTokens": 800,
"ResponseFormat": "json_object"
},
"ContentSecurity": {
"Enable": true,
"ClientKey": "tta82ade973724953d01",
"ClientSecret": "4ea9a6eff22158770818b8aa3a77e2b042d1016d",
"AppId": "tta82ade973724953d01",
"TokenUrl": "https://developer.toutiao.com/api/apps/v2/token",
"TextCheckUrl": "https://developer.toutiao.com/api/v2/tags/text/antidirt",
"TokenCacheSeconds": 5400,
"DefaultScene": "naming_input"
}
}

View File

@@ -0,0 +1,49 @@
using DouyinApi.Common.Option.Core;
namespace DouyinApi.Common.Option;
/// <summary>
/// 抖音内容安全配置。
/// </summary>
public sealed class ContentSecurityOptions : IConfigurableOptions
{
/// <summary>
/// 是否启用内容安全检验。
/// </summary>
public bool Enable { get; set; }
/// <summary>
/// 小程序 client_key即 AppId
/// </summary>
public string ClientKey { get; set; } = string.Empty;
/// <summary>
/// 小程序 client_secret。
/// </summary>
public string ClientSecret { get; set; } = string.Empty;
/// <summary>
/// 指定 app_id。当为空时默认使用 <see cref="ClientKey"/>。
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 获取 access_token 的接口地址。
/// </summary>
public string TokenUrl { get; set; } = "https://developer.toutiao.com/api/apps/token";
/// <summary>
/// 文本检测接口地址。
/// </summary>
public string TextCheckUrl { get; set; } = "https://developer.toutiao.com/api/v2/tags/text/antidirt";
/// <summary>
/// access_token 缓存秒数。
/// </summary>
public int TokenCacheSeconds { get; set; } = 5400;
/// <summary>
/// 风险场景(按需配置)。
/// </summary>
public string DefaultScene { get; set; } = "naming_input";
}

View File

@@ -36,5 +36,9 @@ public class DeepSeekOptions : IConfigurableOptions
/// 最大生成 token 数。
/// </summary>
public int MaxTokens { get; set; } = 800;
/// <summary>
/// 输出格式
/// </summary>
public string ResponseFormat { get; set; } = "json_object";
}

View File

@@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
using DouyinApi.Model.Security;
namespace DouyinApi.IServices;
public interface IContentSecurityService
{
Task<ContentSecurityResult> CheckTextAsync(string text, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
namespace DouyinApi.Model.Security
{
public class ContentSecurityResult
{
public bool IsSafe { get; init; }
public string RiskCode { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public IReadOnlyList<string> HitKeywords { get; init; } = Array.Empty<string>();
public static ContentSecurityResult Safe() => new ContentSecurityResult { IsSafe = true };
public static ContentSecurityResult Unsafe(string riskCode, string message) => Unsafe(riskCode, message, Array.Empty<string>());
public static ContentSecurityResult Unsafe(string riskCode, string message, IReadOnlyList<string> keywords)
=> new ContentSecurityResult
{
IsSafe = false,
RiskCode = riskCode,
Message = message,
HitKeywords = keywords ?? Array.Empty<string>()
};
public static ContentSecurityResult Failure(string message)
=> new ContentSecurityResult
{
IsSafe = true,
Message = message
};
}
}

View File

@@ -67,7 +67,31 @@ public class NamingService : INamingService
var suggestions = await TryGenerateViaDeepSeekAsync(request, baziProfile).ConfigureAwait(false);
if (!suggestions.Any())
{
_logger.LogWarning("DeepSeek returned no suggestions, falling back to local generator. Request={@Request}", new
{
request.Surname,
request.Gender,
request.NameLength,
request.BirthDate,
request.BirthTime
});
suggestions = _fallbackGenerator.Generate(request, baziProfile);
if (!suggestions.Any())
{
_logger.LogError("Fallback generator also returned empty results. Request={@Request}", new
{
request.Surname,
request.Gender,
request.NameLength,
request.BirthDate,
request.BirthTime
});
}
}
else
{
_logger.LogInformation("DeepSeek generated {Count} suggestions for {Surname}", suggestions.Count, request.Surname);
}
return new NamingResponse
@@ -141,6 +165,7 @@ public class NamingService : INamingService
{
if (IsDeepSeekConfigInvalid())
{
_logger.LogWarning("DeepSeek skipped: configuration missing.");
return Array.Empty<NamingSuggestion>();
}
@@ -162,6 +187,7 @@ public class NamingService : INamingService
return Array.Empty<NamingSuggestion>();
}
_logger.LogInformation("DeepSeek 调用成功,状态码:{StatusCode},响应长度:{Length}", (int)response.StatusCode, payload?.Length ?? 0);
return ParseDeepSeekResponse(payload, request);
}
catch (Exception ex)
@@ -204,35 +230,38 @@ public class NamingService : INamingService
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
var lengthDescriptor = string.Equals(request.NameLength, "single", StringComparison.OrdinalIgnoreCase) ? "单字" : "双字";
var lengthDescriptor = string.Equals(request.NameLength, "single", StringComparison.OrdinalIgnoreCase) ? 1 : 2;
var nameLength = request.Surname.Length + lengthDescriptor;
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($"需基于以下 JSON 数据为姓氏“{request.Surname}”的{genderLabel}提供 5 个{nameLength}字中文名字:");
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("2. meaning为姓名的含义和出处");
userPrompt.AppendLine("3. 所有汉字需为生活常用的简体字,单字笔画尽量小于等于 12");
userPrompt.AppendLine("4. name 必须包含姓氏且满足给定的字数;");
userPrompt.AppendLine("5. fiveElementReason 需指明所补足的五行依据。");
userPrompt.AppendLine("请仅返回 JSON 数据,格式为 {\"suggestions\":[{...}]},不要附加额外说明。");
var requestBody = new
{
model = _deepSeekOptions.Model,
temperature = _deepSeekOptions.Temperature,
max_tokens = _deepSeekOptions.MaxTokens,
//max_tokens = _deepSeekOptions.MaxTokens,
response_format = new { type = _deepSeekOptions.ResponseFormat },
messages = new[]
{
new { role = "system", content = "你是一名资深的中文姓名学专家,擅长根据八字喜用神给出实用建议。" },
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")
Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json"),
};
return message;
@@ -242,10 +271,17 @@ public class NamingService : INamingService
{
try
{
if (string.IsNullOrWhiteSpace(payload))
{
_logger.LogWarning("DeepSeek 响应为空字符串");
return Array.Empty<NamingSuggestion>();
}
using var document = JsonDocument.Parse(payload);
var choices = document.RootElement.GetProperty("choices");
if (choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0)
{
_logger.LogWarning("DeepSeek 响应缺少 choices 数组payload={Payload}", payload);
return Array.Empty<NamingSuggestion>();
}
@@ -256,6 +292,7 @@ public class NamingService : INamingService
if (string.IsNullOrWhiteSpace(content))
{
_logger.LogWarning("DeepSeek 响应 message.content 为空");
return Array.Empty<NamingSuggestion>();
}
@@ -263,6 +300,7 @@ public class NamingService : INamingService
if (!suggestionsDoc.RootElement.TryGetProperty("suggestions", out var suggestionsElement) ||
suggestionsElement.ValueKind != JsonValueKind.Array)
{
_logger.LogWarning("DeepSeek 返回内容缺少 suggestions 数组content={Content}", content);
return Array.Empty<NamingSuggestion>();
}
@@ -277,6 +315,7 @@ public class NamingService : INamingService
if (string.IsNullOrWhiteSpace(name))
{
_logger.LogWarning("DeepSeek suggestion缺少 name 字段item={@Item}", item.ToString());
continue;
}
@@ -288,11 +327,22 @@ public class NamingService : INamingService
if (!ValidateNameLength(name, request))
{
_logger.LogWarning(
"DeepSeek 建议姓名长度与请求不符已丢弃。Name={Name}, Expect={Expect}, Request={@Request}",
name,
request.NameLength,
new
{
request.Surname,
request.Gender,
request.NameLength
});
continue;
}
if (!uniqueNames.Add(name))
{
_logger.LogInformation("DeepSeek 建议出现重复姓名已跳过。Name={Name}", name);
continue;
}
@@ -309,6 +359,11 @@ public class NamingService : INamingService
}
}
if (normalized.Count == 0)
{
_logger.LogWarning("DeepSeek suggestions 解析后为空,原始 content={Content}", content);
}
return normalized;
}
catch (Exception ex)

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using DouyinApi.Common.Option;
using DouyinApi.IServices;
using DouyinApi.Model.Security;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DouyinApi.Services.Security;
public class ContentSecurityService : IContentSecurityService
{
private const string TokenCacheKey = "douyin:content-security:token";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly ContentSecurityOptions _options;
private readonly ILogger<ContentSecurityService> _logger;
public ContentSecurityService(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IOptions<ContentSecurityOptions> options,
ILogger<ContentSecurityService> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_options = options?.Value ?? new ContentSecurityOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ContentSecurityResult> CheckTextAsync(string text, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(text))
{
return ContentSecurityResult.Safe();
}
if (!_options.Enable)
{
return ContentSecurityResult.Safe();
}
if (string.IsNullOrWhiteSpace(_options.ClientKey))
{
_logger.LogWarning("Content security skipped: client_key missing.");
return ContentSecurityResult.Safe();
}
var accessToken = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(accessToken))
{
return ContentSecurityResult.Failure("content_security_token_unavailable");
}
try
{
var requestUri = ResolveTextCheckUrl();
var httpClient = _httpClientFactory.CreateClient();
var payload = new Dictionary<string, object?>
{
["tasks"] = new[]
{
new Dictionary<string, object?>
{
["content"] = text
}
}
};
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = new StringContent(JsonSerializer.Serialize(payload, SerializerOptions), Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("X-Token", accessToken);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Content security request failed: {StatusCode} - {Body}", response.StatusCode, body);
return ContentSecurityResult.Failure("content_security_request_failed");
}
return ParseResult(body);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content security check failed.");
return ContentSecurityResult.Failure("content_security_exception");
}
}
private async Task<string?> GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (_memoryCache.TryGetValue(TokenCacheKey, out string cachedToken) && !string.IsNullOrEmpty(cachedToken))
{
return cachedToken;
}
var httpClient = _httpClientFactory.CreateClient();
var tokenUrl = string.IsNullOrWhiteSpace(_options.TokenUrl)
? "https://developer.toutiao.com/api/apps/token"
: _options.TokenUrl;
var appId = string.IsNullOrWhiteSpace(_options.AppId) ? _options.ClientKey : _options.AppId;
if (string.IsNullOrWhiteSpace(appId))
{
_logger.LogWarning("Content security token request skipped: appId missing.");
return null;
}
if (string.IsNullOrWhiteSpace(_options.ClientSecret))
{
_logger.LogWarning("Content security token request skipped: secret missing.");
return null;
}
var payload = new Dictionary<string, string>
{
["appid"] = appId,
["secret"] = _options.ClientSecret,
["grant_type"] = "client_credential"
};
using var request = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
{
Content = new StringContent(JsonSerializer.Serialize(payload, SerializerOptions), Encoding.UTF8, "application/json")
};
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Content security token request failed: {StatusCode} - {Body}", response.StatusCode, body);
return null;
}
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (TryGetTopError(root, out var errorMessage))
{
_logger.LogWarning("Content security token response error: {Message}", errorMessage);
return null;
}
if (!root.TryGetProperty("data", out var dataElement))
{
_logger.LogWarning("Content security token response missing access_token. Body: {Body}", body);
return null;
}
if (!dataElement.TryGetProperty("access_token", out var tokenElement))
{
_logger.LogWarning("Content security token response missing access_token. Body: {Body}", body);
return null;
}
var token = tokenElement.GetString();
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
var expires = root.TryGetProperty("expires_in", out var expiresElement)
? expiresElement.GetInt32()
: _options.TokenCacheSeconds;
if (expires <= 0)
{
expires = _options.TokenCacheSeconds;
}
var cacheEntryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Math.Max(60, expires - 120))
};
_memoryCache.Set(TokenCacheKey, token, cacheEntryOptions);
return token;
}
private string ResolveTextCheckUrl()
{
return string.IsNullOrWhiteSpace(_options.TextCheckUrl)
? "https://developer.toutiao.com/api/v2/tags/text/antidirt"
: _options.TextCheckUrl;
}
private ContentSecurityResult ParseResult(string body)
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (TryGetTopError(root, out var error))
{
return ContentSecurityResult.Failure(error);
}
if (!root.TryGetProperty("data", out var dataElement) || dataElement.ValueKind != JsonValueKind.Array)
{
return ContentSecurityResult.Safe();
}
foreach (var item in dataElement.EnumerateArray())
{
var code = item.TryGetProperty("code", out var codeElement) && codeElement.ValueKind == JsonValueKind.Number
? codeElement.GetInt32()
: 0;
var msg = item.TryGetProperty("msg", out var msgElement) ? msgElement.GetString() : string.Empty;
if (code != 0)
{
var message = string.IsNullOrWhiteSpace(msg)
? $"内容安全检测失败code={code}"
: msg!;
return ContentSecurityResult.Failure(message);
}
if (!item.TryGetProperty("predicts", out var predictsElement) || predictsElement.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var predict in predictsElement.EnumerateArray())
{
if (predict.TryGetProperty("hit", out var hitElement) && JsonBooleanEqualsTrue(hitElement))
{
var reason = string.IsNullOrWhiteSpace(msg) ? "内容包含风险信息" : msg!;
return ContentSecurityResult.Unsafe("content_security_blocked", reason);
}
}
}
return ContentSecurityResult.Safe();
}
private static bool JsonBooleanEqualsTrue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => element.GetDouble() != 0,
JsonValueKind.String => bool.TryParse(element.GetString(), out var result) && result,
_ => false
};
}
private static bool TryGetTopError(JsonElement root, out string message)
{
if (root.TryGetProperty("err_no", out var errNoElement) && errNoElement.ValueKind == JsonValueKind.Number && errNoElement.GetInt32() != 0)
{
message = root.TryGetProperty("message", out var msgElement) ? msgElement.GetString() ?? "content_security_error" : "content_security_error";
return true;
}
if (root.TryGetProperty("code", out var codeElement) && codeElement.ValueKind == JsonValueKind.Number && codeElement.GetInt32() != 0)
{
var code = codeElement.GetInt32();
var baseMessage = $"content_security_error_{code}";
message = root.TryGetProperty("message", out var msgElement) ? (msgElement.GetString() ?? baseMessage) : baseMessage;
return true;
}
message = string.Empty;
return false;
}
}