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, TemplateResolver templateResolver, SimpleJsonPathService jsonPathService) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public IReadOnlyList 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> 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(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(); 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 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(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(); foreach (var chapterNode in jsonPathService.ResolveArray(responseJson, mapping.ChaptersPath)) { var sections = new List(); 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 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(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(); 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 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 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(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(StringComparer.OrdinalIgnoreCase); foreach (var mapping in DeserializeCookieMappings(step.OutputCookiesJson)) { var value = ResolveExpression(mapping.Expression, state, null, new Dictionary(), new Dictionary()); 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(), new Dictionary()); 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 ResolveStringMap(string? json, WorkflowExecutionState state) { if (string.IsNullOrWhiteSpace(json)) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } var node = JsonNode.Parse(json) as JsonObject ?? throw new PlatformOperationException("请求模板 JSON 格式无效。"); var resolved = new Dictionary(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(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(out var stringValue)) { return ResolveTemplate(stringValue, state); } return node.ToJsonString().Trim('"'); } return node.ToJsonString(); } private IReadOnlyDictionary BuildContext(IReadOnlyDictionary source) { var context = new Dictionary(source, StringComparer.OrdinalIgnoreCase); context.TryAdd("uoocBaseUrl", uoocOptions.Value.BaseUrl.TrimEnd('/')); return context; } private string ResolveExpression( string expression, WorkflowExecutionState state, JsonNode? responseJson, IReadOnlyDictionary responseCookies, IReadOnlyDictionary 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 ExtractCookies(HttpResponseMessage response) { var cookies = new Dictionary(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> ResolveFormValues(JsonNode bodyNode) { if (bodyNode is not JsonObject bodyObject) { return []; } return bodyObject .Where(item => item.Value is not null) .Select(item => new KeyValuePair(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 DeserializeCookieMappings(string? json) => DeserializeList(json); private static IReadOnlyList DeserializeVariableMappings(string? json) => DeserializeList(json); private static IReadOnlyList DeserializeList(string? json) { if (string.IsNullOrWhiteSpace(json)) { return []; } try { return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; } catch { return []; } } private static T? Deserialize(string? json) { if (string.IsNullOrWhiteSpace(json)) { return default; } try { return JsonSerializer.Deserialize(json, JsonOptions); } catch { return default; } } private sealed record StepExecutionResult(JsonNode? ResponseJson); }