Files
Api/DouyinApi.Services/Security/ContentSecurityService.cs
2025-11-06 19:23:42 +08:00

282 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}