282 lines
10 KiB
C#
282 lines
10 KiB
C#
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;
|
||
}
|
||
}
|