From 823dc8d37bf548e629dbba0ead47a0b72604e5aa Mon Sep 17 00:00:00 2001 From: cjd Date: Thu, 6 Nov 2025 19:23:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=96=E5=90=8D=E5=B0=8F=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MiniProgram/NamingController.cs | 32 +- DouyinApi.Api/appsettings.json | 23 +- .../Option/ContentSecurityOptions.cs | 49 +++ DouyinApi.Common/Option/DeepSeekOptions.cs | 4 + .../IContentSecurityService.cs | 11 + .../Security/ContentSecurityResult.cs | 36 +++ DouyinApi.Services/NamingService.cs | 71 ++++- .../Security/ContentSecurityService.cs | 281 ++++++++++++++++++ 8 files changed, 490 insertions(+), 17 deletions(-) create mode 100644 DouyinApi.Common/Option/ContentSecurityOptions.cs create mode 100644 DouyinApi.IServices/IContentSecurityService.cs create mode 100644 DouyinApi.Model/Security/ContentSecurityResult.cs create mode 100644 DouyinApi.Services/Security/ContentSecurityService.cs diff --git a/DouyinApi.Api/Controllers/MiniProgram/NamingController.cs b/DouyinApi.Api/Controllers/MiniProgram/NamingController.cs index 7887031..7b7b4f7 100644 --- a/DouyinApi.Api/Controllers/MiniProgram/NamingController.cs +++ b/DouyinApi.Api/Controllers/MiniProgram/NamingController.cs @@ -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 _logger; - public NamingController(INamingService namingService, ILogger logger) + public NamingController( + INamingService namingService, + IContentSecurityService contentSecurityService, + ILogger 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 { diff --git a/DouyinApi.Api/appsettings.json b/DouyinApi.Api/appsettings.json index e299b24..0ecc441 100644 --- a/DouyinApi.Api/appsettings.json +++ b/DouyinApi.Api/appsettings.json @@ -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" } } diff --git a/DouyinApi.Common/Option/ContentSecurityOptions.cs b/DouyinApi.Common/Option/ContentSecurityOptions.cs new file mode 100644 index 0000000..315f3f8 --- /dev/null +++ b/DouyinApi.Common/Option/ContentSecurityOptions.cs @@ -0,0 +1,49 @@ +using DouyinApi.Common.Option.Core; + +namespace DouyinApi.Common.Option; + +/// +/// 抖音内容安全配置。 +/// +public sealed class ContentSecurityOptions : IConfigurableOptions +{ + /// + /// 是否启用内容安全检验。 + /// + public bool Enable { get; set; } + + /// + /// 小程序 client_key(即 AppId)。 + /// + public string ClientKey { get; set; } = string.Empty; + + /// + /// 小程序 client_secret。 + /// + public string ClientSecret { get; set; } = string.Empty; + + /// + /// 指定 app_id。当为空时默认使用 。 + /// + public string AppId { get; set; } = string.Empty; + + /// + /// 获取 access_token 的接口地址。 + /// + public string TokenUrl { get; set; } = "https://developer.toutiao.com/api/apps/token"; + + /// + /// 文本检测接口地址。 + /// + public string TextCheckUrl { get; set; } = "https://developer.toutiao.com/api/v2/tags/text/antidirt"; + + /// + /// access_token 缓存秒数。 + /// + public int TokenCacheSeconds { get; set; } = 5400; + + /// + /// 风险场景(按需配置)。 + /// + public string DefaultScene { get; set; } = "naming_input"; +} diff --git a/DouyinApi.Common/Option/DeepSeekOptions.cs b/DouyinApi.Common/Option/DeepSeekOptions.cs index 17849e6..d2c9df3 100644 --- a/DouyinApi.Common/Option/DeepSeekOptions.cs +++ b/DouyinApi.Common/Option/DeepSeekOptions.cs @@ -36,5 +36,9 @@ public class DeepSeekOptions : IConfigurableOptions /// 最大生成 token 数。 /// public int MaxTokens { get; set; } = 800; + /// + /// 输出格式 + /// + public string ResponseFormat { get; set; } = "json_object"; } diff --git a/DouyinApi.IServices/IContentSecurityService.cs b/DouyinApi.IServices/IContentSecurityService.cs new file mode 100644 index 0000000..6e6eea7 --- /dev/null +++ b/DouyinApi.IServices/IContentSecurityService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using DouyinApi.Model.Security; + +namespace DouyinApi.IServices; + +public interface IContentSecurityService +{ + Task CheckTextAsync(string text, CancellationToken cancellationToken = default); +} + diff --git a/DouyinApi.Model/Security/ContentSecurityResult.cs b/DouyinApi.Model/Security/ContentSecurityResult.cs new file mode 100644 index 0000000..3ec6756 --- /dev/null +++ b/DouyinApi.Model/Security/ContentSecurityResult.cs @@ -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 HitKeywords { get; init; } = Array.Empty(); + + public static ContentSecurityResult Safe() => new ContentSecurityResult { IsSafe = true }; + + public static ContentSecurityResult Unsafe(string riskCode, string message) => Unsafe(riskCode, message, Array.Empty()); + + public static ContentSecurityResult Unsafe(string riskCode, string message, IReadOnlyList keywords) + => new ContentSecurityResult + { + IsSafe = false, + RiskCode = riskCode, + Message = message, + HitKeywords = keywords ?? Array.Empty() + }; + + public static ContentSecurityResult Failure(string message) + => new ContentSecurityResult + { + IsSafe = true, + Message = message + }; + } +} diff --git a/DouyinApi.Services/NamingService.cs b/DouyinApi.Services/NamingService.cs index d389773..129d8bd 100644 --- a/DouyinApi.Services/NamingService.cs +++ b/DouyinApi.Services/NamingService.cs @@ -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(); } @@ -162,6 +187,7 @@ public class NamingService : INamingService return Array.Empty(); } + _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(); + } + 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(); } @@ -256,6 +292,7 @@ public class NamingService : INamingService if (string.IsNullOrWhiteSpace(content)) { + _logger.LogWarning("DeepSeek 响应 message.content 为空"); return Array.Empty(); } @@ -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(); } @@ -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) diff --git a/DouyinApi.Services/Security/ContentSecurityService.cs b/DouyinApi.Services/Security/ContentSecurityService.cs new file mode 100644 index 0000000..b167a16 --- /dev/null +++ b/DouyinApi.Services/Security/ContentSecurityService.cs @@ -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 _logger; + + public ContentSecurityService( + IHttpClientFactory httpClientFactory, + IMemoryCache memoryCache, + IOptions options, + ILogger 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 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 + { + ["tasks"] = new[] + { + new Dictionary + { + ["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 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 + { + ["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; + } +}