Files
course/backend/src/UoocProgress.Api/Services/PlatformWorkflowExecutor.cs
T
nanxunandClaude Fable 5 cd486a36ef 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>
2026-06-24 15:31:32 +08:00

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