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; } }