Files
Api/DouyinApi.Services/Security/ContentSecurityService.cs

282 lines
10 KiB
C#
Raw Normal View History

2025-11-06 19:23:42 +08:00
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;
}
}