取名小程序
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
49
DouyinApi.Common/Option/ContentSecurityOptions.cs
Normal file
49
DouyinApi.Common/Option/ContentSecurityOptions.cs
Normal 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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
11
DouyinApi.IServices/IContentSecurityService.cs
Normal file
11
DouyinApi.IServices/IContentSecurityService.cs
Normal 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);
|
||||
}
|
||||
|
||||
36
DouyinApi.Model/Security/ContentSecurityResult.cs
Normal file
36
DouyinApi.Model/Security/ContentSecurityResult.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
281
DouyinApi.Services/Security/ContentSecurityService.cs
Normal file
281
DouyinApi.Services/Security/ContentSecurityService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user