feat: UOOC/Zhihuishu dual-platform brushing platform
- 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>
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user