- ASP.NET Core 9 Web API backend with JWT auth, EF Core MySQL - Vue 3 + Vite + Pinia + Ant Design Vue frontend - Multi-platform connection management (UOOC & Zhihuishu) - Video brushing with AES-CBC encryption for Zhihuishu - Multi-task queue with cross-platform parallel execution - Task persistence via MySQL database - Progress tracking with inline catalog enrichment - Mobile-responsive UI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
671 lines
24 KiB
C#
671 lines
24 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Extensions.Options;
|
|
using UoocProgress.Api.Models;
|
|
using UoocProgress.Api.Options;
|
|
|
|
namespace UoocProgress.Api.Services;
|
|
|
|
public sealed class PlatformWorkflowExecutor(
|
|
IHttpClientFactory httpClientFactory,
|
|
IOptions<UoocOptions> uoocOptions,
|
|
TemplateResolver templateResolver,
|
|
SimpleJsonPathService jsonPathService)
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
public IReadOnlyList<PlatformWorkflowStep> GetSteps(PlatformDefinition platform, PlatformWorkflowScope scope) =>
|
|
platform.WorkflowSteps
|
|
.Where(item => item.Scope == scope && item.IsEnabled)
|
|
.OrderBy(item => item.DisplayOrder)
|
|
.ToList();
|
|
|
|
public string ResolveTemplate(string? template, WorkflowExecutionState state) =>
|
|
templateResolver.Resolve(
|
|
template,
|
|
state.InputFields,
|
|
state.SessionData,
|
|
BuildContext(state.ContextValues),
|
|
state.SessionData.StepOutputs);
|
|
|
|
public async Task ExecuteLoginStepAsync(
|
|
PlatformWorkflowStep step,
|
|
WorkflowExecutionState state,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
switch (step.StepType)
|
|
{
|
|
case PlatformWorkflowStepType.SessionPassthrough:
|
|
ApplyPassthroughStep(step, state);
|
|
return;
|
|
case PlatformWorkflowStepType.HttpRequest:
|
|
await ExecuteHttpRequestStepAsync(step, state, cancellationToken);
|
|
return;
|
|
case PlatformWorkflowStepType.BrowserChallenge:
|
|
throw new PlatformOperationException("浏览器挑战步骤需要由连接服务单独接管。");
|
|
default:
|
|
throw new PlatformOperationException($"不支持的平台步骤类型:{step.StepType}");
|
|
}
|
|
}
|
|
|
|
public async Task<IReadOnlyList<CourseOptionDto>> QueryCourseOptionsAsync(
|
|
PlatformDefinition platform,
|
|
WorkflowExecutionState state,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
JsonNode? responseJson = null;
|
|
CourseOptionMappingDto? mapping = null;
|
|
var targetStepKey = platform.CourseQueryStepKey;
|
|
|
|
foreach (var step in GetSteps(platform, PlatformWorkflowScope.CourseQuery))
|
|
{
|
|
var result = await ExecuteStepAsync(step, state, cancellationToken);
|
|
var stepMapping = Deserialize<CourseOptionMappingDto>(step.CourseOptionMappingJson);
|
|
var isTarget = string.IsNullOrWhiteSpace(targetStepKey)
|
|
? stepMapping is not null
|
|
: step.StepKey.Equals(targetStepKey, StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (isTarget)
|
|
{
|
|
mapping = stepMapping ?? throw new PlatformOperationException("课程下拉来源步骤未配置课程映射。");
|
|
responseJson = result.ResponseJson;
|
|
}
|
|
}
|
|
|
|
if (mapping is null || responseJson is null)
|
|
{
|
|
throw new PlatformOperationException("平台未配置可用的课程下拉步骤。");
|
|
}
|
|
|
|
var items = new List<CourseOptionDto>();
|
|
foreach (var node in jsonPathService.ResolveArray(responseJson, mapping.ItemsPath))
|
|
{
|
|
var label = jsonPathService.ResolveString(node, mapping.LabelPath)?.Trim();
|
|
var value = jsonPathService.ResolveString(node, mapping.ValuePath)?.Trim();
|
|
if (string.IsNullOrWhiteSpace(label) || string.IsNullOrWhiteSpace(value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
items.Add(new CourseOptionDto(value, label));
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
public async Task<CatalogResponse> ReadCatalogAsync(
|
|
PlatformDefinition platform,
|
|
WorkflowExecutionState state,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
JsonNode? responseJson = null;
|
|
CatalogMappingDto? mapping = null;
|
|
|
|
foreach (var step in GetSteps(platform, PlatformWorkflowScope.Catalog))
|
|
{
|
|
var result = await ExecuteStepAsync(step, state, cancellationToken);
|
|
var stepMapping = Deserialize<CatalogMappingDto>(step.CatalogMappingJson);
|
|
if (stepMapping is not null)
|
|
{
|
|
responseJson = result.ResponseJson;
|
|
mapping = stepMapping;
|
|
}
|
|
}
|
|
|
|
if (mapping is null || responseJson is null)
|
|
{
|
|
throw new PlatformOperationException("平台未配置可用的章节目录步骤。");
|
|
}
|
|
|
|
var chapters = new List<CatalogChapterDto>();
|
|
foreach (var chapterNode in jsonPathService.ResolveArray(responseJson, mapping.ChaptersPath))
|
|
{
|
|
var sections = new List<CatalogSectionDto>();
|
|
foreach (var sectionNode in jsonPathService.ResolveArray(chapterNode, mapping.SectionsPath))
|
|
{
|
|
sections.Add(
|
|
new CatalogSectionDto(
|
|
jsonPathService.ResolveString(sectionNode, mapping.SectionIdPath) ?? string.Empty,
|
|
jsonPathService.ResolveString(sectionNode, mapping.SectionNumberPath) ?? string.Empty,
|
|
jsonPathService.ResolveString(sectionNode, mapping.SectionNamePath) ?? string.Empty,
|
|
jsonPathService.ResolveBoolean(sectionNode, mapping.SectionFinishedPath),
|
|
jsonPathService.ResolveBoolean(sectionNode, mapping.SectionLearningPath),
|
|
jsonPathService.ResolveString(sectionNode, mapping.SectionTaskIdPath) ?? string.Empty));
|
|
}
|
|
|
|
chapters.Add(
|
|
new CatalogChapterDto(
|
|
jsonPathService.ResolveString(chapterNode, mapping.ChapterIdPath) ?? string.Empty,
|
|
jsonPathService.ResolveString(chapterNode, mapping.ChapterNumberPath) ?? string.Empty,
|
|
jsonPathService.ResolveString(chapterNode, mapping.ChapterNamePath) ?? string.Empty,
|
|
jsonPathService.ResolveBoolean(chapterNode, mapping.ChapterFinishedPath),
|
|
jsonPathService.ResolveBoolean(chapterNode, mapping.ChapterLearningPath),
|
|
sections));
|
|
}
|
|
|
|
return new CatalogResponse(
|
|
state.ContextValues["courseId"],
|
|
chapters,
|
|
false,
|
|
"upstream",
|
|
null);
|
|
}
|
|
|
|
public async Task<UnitsResponse> ReadUnitsAsync(
|
|
PlatformDefinition platform,
|
|
WorkflowExecutionState state,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
JsonNode? responseJson = null;
|
|
UnitMappingDto? mapping = null;
|
|
|
|
foreach (var step in GetSteps(platform, PlatformWorkflowScope.Units))
|
|
{
|
|
var result = await ExecuteStepAsync(step, state, cancellationToken);
|
|
var stepMapping = Deserialize<UnitMappingDto>(step.UnitMappingJson);
|
|
if (stepMapping is not null)
|
|
{
|
|
responseJson = result.ResponseJson;
|
|
mapping = stepMapping;
|
|
}
|
|
}
|
|
|
|
if (mapping is null || responseJson is null)
|
|
{
|
|
throw new PlatformOperationException("平台未配置可用的资源读取步骤。");
|
|
}
|
|
|
|
var items = new List<UnitItemDto>();
|
|
foreach (var itemNode in jsonPathService.ResolveArray(responseJson, mapping.ItemsPath))
|
|
{
|
|
var primarySourceUrl = jsonPathService.ResolveString(itemNode, mapping.VideoSourcePath);
|
|
var primarySourceName = jsonPathService.ResolveString(itemNode, mapping.VideoSourceNamePath);
|
|
items.Add(
|
|
new UnitItemDto(
|
|
jsonPathService.ResolveString(itemNode, mapping.ItemIdPath) ?? string.Empty,
|
|
jsonPathService.ResolveString(itemNode, mapping.ItemTitlePath) ?? string.Empty,
|
|
jsonPathService.ResolveString(itemNode, mapping.ItemTypePath) ?? string.Empty,
|
|
jsonPathService.ResolveBoolean(itemNode, mapping.ItemFinishedPath),
|
|
!string.IsNullOrWhiteSpace(primarySourceUrl),
|
|
jsonPathService.ResolveDouble(itemNode, mapping.VideoPositionPath),
|
|
ResolveNullableDouble(itemNode, mapping.VideoLengthPath),
|
|
string.IsNullOrWhiteSpace(primarySourceName) ? null : primarySourceName,
|
|
string.IsNullOrWhiteSpace(primarySourceUrl) ? null : primarySourceUrl,
|
|
jsonPathService.ResolveInt(itemNode, mapping.DocumentCountPath),
|
|
[],
|
|
""));
|
|
}
|
|
|
|
return new UnitsResponse(
|
|
state.ContextValues["courseId"],
|
|
state.ContextValues["chapterId"],
|
|
state.ContextValues["sectionId"],
|
|
items,
|
|
false,
|
|
"upstream",
|
|
null);
|
|
}
|
|
|
|
private async Task<StepExecutionResult> ExecuteStepAsync(
|
|
PlatformWorkflowStep step,
|
|
WorkflowExecutionState state,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
switch (step.StepType)
|
|
{
|
|
case PlatformWorkflowStepType.SessionPassthrough:
|
|
ApplyPassthroughStep(step, state);
|
|
return new StepExecutionResult(null);
|
|
case PlatformWorkflowStepType.HttpRequest:
|
|
return await ExecuteHttpRequestStepAsync(step, state, cancellationToken);
|
|
default:
|
|
throw new PlatformOperationException("当前作用域不支持浏览器挑战步骤。");
|
|
}
|
|
}
|
|
|
|
private async Task<StepExecutionResult> ExecuteHttpRequestStepAsync(
|
|
PlatformWorkflowStep step,
|
|
WorkflowExecutionState state,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = httpClientFactory.CreateClient("platform-workflow");
|
|
var requestUrl = BuildRequestUrl(step, state);
|
|
using var request = new HttpRequestMessage(new HttpMethod(step.HttpMethod), requestUrl);
|
|
|
|
foreach (var header in ResolveStringMap(step.HeadersTemplateJson, state))
|
|
{
|
|
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
|
|
if (!request.Headers.Contains("Cookie") && state.SessionData.Cookies.Count > 0)
|
|
{
|
|
request.Headers.TryAddWithoutValidation(
|
|
"Cookie",
|
|
string.Join("; ", state.SessionData.Cookies.Select(item => $"{item.Key}={item.Value}")));
|
|
}
|
|
|
|
var bodyNode = ResolveJsonNode(step.BodyTemplateJson, state);
|
|
if (bodyNode is not null)
|
|
{
|
|
if (string.Equals(step.ContentType, "application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
request.Content = new FormUrlEncodedContent(ResolveFormValues(bodyNode));
|
|
}
|
|
else
|
|
{
|
|
request.Content = new StringContent(
|
|
bodyNode.ToJsonString(),
|
|
Encoding.UTF8,
|
|
string.IsNullOrWhiteSpace(step.ContentType) ? "application/json" : step.ContentType);
|
|
}
|
|
}
|
|
|
|
using var response = await client.SendAsync(request, cancellationToken);
|
|
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
|
{
|
|
throw new PlatformOperationException("平台会话已失效或登录已过期。", true);
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new PlatformOperationException($"平台接口返回了 {(int)response.StatusCode}。");
|
|
}
|
|
|
|
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
var responseJson = string.IsNullOrWhiteSpace(responseText)
|
|
? new JsonObject()
|
|
: JsonNode.Parse(responseText);
|
|
|
|
if (!string.IsNullOrWhiteSpace(step.SuccessPath))
|
|
{
|
|
var actual = jsonPathService.ResolveString(responseJson, step.SuccessPath);
|
|
if (!MatchesExpected(step.SuccessExpectedValue, actual))
|
|
{
|
|
if (LooksUnauthorized(actual))
|
|
{
|
|
throw new PlatformOperationException(actual ?? "平台会话已失效。", true);
|
|
}
|
|
|
|
throw new PlatformOperationException(actual ?? $"{step.DisplayName} 未通过成功判定。");
|
|
}
|
|
}
|
|
|
|
var responseHeaders = response.Headers
|
|
.Concat(response.Content.Headers)
|
|
.ToDictionary(
|
|
item => item.Key,
|
|
item => string.Join(", ", item.Value),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
var responseCookies = ExtractCookies(response);
|
|
foreach (var cookie in responseCookies)
|
|
{
|
|
state.SessionData.Cookies[cookie.Key] = cookie.Value;
|
|
}
|
|
|
|
var stepOutputs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var mapping in DeserializeCookieMappings(step.OutputCookiesJson))
|
|
{
|
|
var value = ResolveExpression(mapping.Expression, state, responseJson, responseCookies, responseHeaders);
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
state.SessionData.Cookies[mapping.Name] = value;
|
|
stepOutputs[mapping.Name] = value;
|
|
}
|
|
}
|
|
|
|
foreach (var mapping in DeserializeVariableMappings(step.OutputVariablesJson))
|
|
{
|
|
var value = ResolveExpression(mapping.Key == string.Empty ? string.Empty : mapping.Expression, state, responseJson, responseCookies, responseHeaders);
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
state.SessionData.Outputs[mapping.Key] = value;
|
|
stepOutputs[mapping.Key] = value;
|
|
}
|
|
}
|
|
|
|
if (stepOutputs.Count > 0)
|
|
{
|
|
state.SessionData.StepOutputs[step.StepKey] = stepOutputs;
|
|
}
|
|
|
|
state.StepJson[step.StepKey] = responseJson;
|
|
state.ResponseHeaders[step.StepKey] = responseHeaders;
|
|
return new StepExecutionResult(responseJson);
|
|
}
|
|
|
|
private void ApplyPassthroughStep(PlatformWorkflowStep step, WorkflowExecutionState state)
|
|
{
|
|
var stepOutputs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var mapping in DeserializeCookieMappings(step.OutputCookiesJson))
|
|
{
|
|
var value = ResolveExpression(mapping.Expression, state, null, new Dictionary<string, string>(), new Dictionary<string, string>());
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
state.SessionData.Cookies[mapping.Name] = value;
|
|
stepOutputs[mapping.Name] = value;
|
|
}
|
|
}
|
|
|
|
foreach (var mapping in DeserializeVariableMappings(step.OutputVariablesJson))
|
|
{
|
|
var value = ResolveExpression(mapping.Expression, state, null, new Dictionary<string, string>(), new Dictionary<string, string>());
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
state.SessionData.Outputs[mapping.Key] = value;
|
|
stepOutputs[mapping.Key] = value;
|
|
}
|
|
}
|
|
|
|
if (stepOutputs.Count > 0)
|
|
{
|
|
state.SessionData.StepOutputs[step.StepKey] = stepOutputs;
|
|
}
|
|
}
|
|
|
|
private Uri BuildRequestUrl(PlatformWorkflowStep step, WorkflowExecutionState state)
|
|
{
|
|
var resolvedUrl = ResolveTemplate(step.UrlTemplate, state);
|
|
if (string.IsNullOrWhiteSpace(resolvedUrl))
|
|
{
|
|
throw new PlatformOperationException($"{step.DisplayName} 未配置请求地址。");
|
|
}
|
|
|
|
if (!Uri.TryCreate(resolvedUrl, UriKind.Absolute, out var uri))
|
|
{
|
|
uri = new Uri(new Uri(uoocOptions.Value.BaseUrl.TrimEnd('/') + "/"), resolvedUrl.TrimStart('/'));
|
|
}
|
|
|
|
var query = ResolveStringMap(step.QueryTemplateJson, state);
|
|
if (query.Count == 0)
|
|
{
|
|
return uri;
|
|
}
|
|
|
|
var builder = new UriBuilder(uri);
|
|
var queryString = string.Join(
|
|
"&",
|
|
query
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.Value))
|
|
.Select(item => $"{Uri.EscapeDataString(item.Key)}={Uri.EscapeDataString(item.Value)}"));
|
|
|
|
if (string.IsNullOrWhiteSpace(queryString))
|
|
{
|
|
return uri;
|
|
}
|
|
|
|
builder.Query = queryString;
|
|
return builder.Uri;
|
|
}
|
|
|
|
private Dictionary<string, string> ResolveStringMap(string? json, WorkflowExecutionState state)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
var node = JsonNode.Parse(json) as JsonObject
|
|
?? throw new PlatformOperationException("请求模板 JSON 格式无效。");
|
|
|
|
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var property in node)
|
|
{
|
|
if (property.Value is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
resolved[property.Key] = ResolveNodeToString(property.Value, state);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
private JsonNode? ResolveJsonNode(string? json, WorkflowExecutionState state)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
JsonNode node;
|
|
try
|
|
{
|
|
node = JsonNode.Parse(json) ?? JsonValue.Create(string.Empty)!;
|
|
}
|
|
catch
|
|
{
|
|
return JsonValue.Create(ResolveTemplate(json, state));
|
|
}
|
|
|
|
return ResolveJsonNode(node, state);
|
|
}
|
|
|
|
private JsonNode ResolveJsonNode(JsonNode node, WorkflowExecutionState state)
|
|
{
|
|
return node switch
|
|
{
|
|
JsonObject jsonObject => ResolveObject(jsonObject, state),
|
|
JsonArray jsonArray => ResolveArray(jsonArray, state),
|
|
JsonValue jsonValue => ResolveValue(jsonValue, state),
|
|
_ => node.DeepClone()
|
|
};
|
|
}
|
|
|
|
private JsonObject ResolveObject(JsonObject source, WorkflowExecutionState state)
|
|
{
|
|
var target = new JsonObject();
|
|
foreach (var property in source)
|
|
{
|
|
target[property.Key] = property.Value is null ? null : ResolveJsonNode(property.Value, state);
|
|
}
|
|
|
|
return target;
|
|
}
|
|
|
|
private JsonArray ResolveArray(JsonArray source, WorkflowExecutionState state)
|
|
{
|
|
var target = new JsonArray();
|
|
foreach (var item in source)
|
|
{
|
|
target.Add(item is null ? null : ResolveJsonNode(item, state));
|
|
}
|
|
|
|
return target;
|
|
}
|
|
|
|
private JsonNode ResolveValue(JsonValue value, WorkflowExecutionState state)
|
|
{
|
|
if (value.TryGetValue<string>(out var stringValue))
|
|
{
|
|
return JsonValue.Create(ResolveTemplate(stringValue, state))!;
|
|
}
|
|
|
|
return value.DeepClone();
|
|
}
|
|
|
|
private string ResolveNodeToString(JsonNode node, WorkflowExecutionState state)
|
|
{
|
|
if (node is JsonValue jsonValue)
|
|
{
|
|
if (jsonValue.TryGetValue<string>(out var stringValue))
|
|
{
|
|
return ResolveTemplate(stringValue, state);
|
|
}
|
|
|
|
return node.ToJsonString().Trim('"');
|
|
}
|
|
|
|
return node.ToJsonString();
|
|
}
|
|
|
|
private IReadOnlyDictionary<string, string> BuildContext(IReadOnlyDictionary<string, string> source)
|
|
{
|
|
var context = new Dictionary<string, string>(source, StringComparer.OrdinalIgnoreCase);
|
|
context.TryAdd("uoocBaseUrl", uoocOptions.Value.BaseUrl.TrimEnd('/'));
|
|
return context;
|
|
}
|
|
|
|
private string ResolveExpression(
|
|
string expression,
|
|
WorkflowExecutionState state,
|
|
JsonNode? responseJson,
|
|
IReadOnlyDictionary<string, string> responseCookies,
|
|
IReadOnlyDictionary<string, string> responseHeaders)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expression))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
if (expression.Contains("{{", StringComparison.Ordinal))
|
|
{
|
|
return ResolveTemplate(expression, state);
|
|
}
|
|
|
|
if (expression.StartsWith("cookie:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var cookieName = expression["cookie:".Length..];
|
|
return responseCookies.TryGetValue(cookieName, out var cookieValue) ? cookieValue : string.Empty;
|
|
}
|
|
|
|
if (expression.StartsWith("header:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var headerName = expression["header:".Length..];
|
|
return responseHeaders.TryGetValue(headerName, out var headerValue) ? headerValue : string.Empty;
|
|
}
|
|
|
|
if (expression.StartsWith("$", StringComparison.Ordinal))
|
|
{
|
|
return jsonPathService.ResolveString(responseJson, expression) ?? string.Empty;
|
|
}
|
|
|
|
if (expression.StartsWith("field.", StringComparison.OrdinalIgnoreCase)
|
|
|| expression.StartsWith("context.", StringComparison.OrdinalIgnoreCase)
|
|
|| expression.StartsWith("connection.", StringComparison.OrdinalIgnoreCase)
|
|
|| expression.StartsWith("step.", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return ResolveTemplate($"{{{{{expression}}}}}", state);
|
|
}
|
|
|
|
return expression;
|
|
}
|
|
|
|
private static bool MatchesExpected(string? expected, string? actual)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expected))
|
|
{
|
|
return !string.IsNullOrWhiteSpace(actual)
|
|
&& !string.Equals(actual, "0", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(actual, "false", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
return string.Equals(actual?.Trim(), expected.Trim(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool LooksUnauthorized(string? message) =>
|
|
!string.IsNullOrWhiteSpace(message)
|
|
&& (message.Contains("登录", StringComparison.OrdinalIgnoreCase)
|
|
|| message.Contains("未登录", StringComparison.OrdinalIgnoreCase)
|
|
|| message.Contains("auth", StringComparison.OrdinalIgnoreCase)
|
|
|| message.Contains("expired", StringComparison.OrdinalIgnoreCase));
|
|
|
|
private static Dictionary<string, string> ExtractCookies(HttpResponseMessage response)
|
|
{
|
|
var cookies = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
if (!response.Headers.TryGetValues("Set-Cookie", out var values))
|
|
{
|
|
return cookies;
|
|
}
|
|
|
|
foreach (var raw in values)
|
|
{
|
|
var firstPart = raw.Split(';', 2)[0];
|
|
var separator = firstPart.IndexOf('=');
|
|
if (separator <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var name = firstPart[..separator].Trim();
|
|
var value = firstPart[(separator + 1)..].Trim();
|
|
if (!string.IsNullOrWhiteSpace(name))
|
|
{
|
|
cookies[name] = value;
|
|
}
|
|
}
|
|
|
|
return cookies;
|
|
}
|
|
|
|
private static IEnumerable<KeyValuePair<string, string>> ResolveFormValues(JsonNode bodyNode)
|
|
{
|
|
if (bodyNode is not JsonObject bodyObject)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return bodyObject
|
|
.Where(item => item.Value is not null)
|
|
.Select(item => new KeyValuePair<string, string>(item.Key, item.Value!.ToJsonString().Trim('"')));
|
|
}
|
|
|
|
private static double? ResolveNullableDouble(JsonNode? node, string? path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var service = new SimpleJsonPathService();
|
|
var resolved = service.ResolveString(node, path);
|
|
return double.TryParse(resolved, out var value) ? value : null;
|
|
}
|
|
|
|
private static IReadOnlyList<PlatformCookieMappingDto> DeserializeCookieMappings(string? json) =>
|
|
DeserializeList<PlatformCookieMappingDto>(json);
|
|
|
|
private static IReadOnlyList<PlatformOutputVariableDto> DeserializeVariableMappings(string? json) =>
|
|
DeserializeList<PlatformOutputVariableDto>(json);
|
|
|
|
private static IReadOnlyList<T> DeserializeList<T>(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
|
|
}
|
|
catch
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static T? Deserialize<T>(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
return default;
|
|
}
|
|
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
|
}
|
|
catch
|
|
{
|
|
return default;
|
|
}
|
|
}
|
|
|
|
private sealed record StepExecutionResult(JsonNode? ResponseJson);
|
|
}
|