using System.Xml;
using System.Xml.Linq;

namespace Dpz.Core.Service.Mediator.Features.Storage.Commands;

/// <summary>
/// 腾讯云媒体处理队列回调的结构化负载
/// </summary>
internal sealed record TencentMediaProcessCallbackPayload(
    string Format,
    List<TencentMediaQueueJobRecord> JobsDetail,
    List<string> NonExistJobIds
);

/// <summary>
/// 解析腾讯云媒体处理队列回调正文,自动识别 JSON 或 XML 格式
/// </summary>
internal static class TencentMediaProcessCallbackParser
{
    /// <summary>
    /// 解析回调正文为统一的结构化负载,格式无法识别或内容非法时抛出 FormatException
    /// </summary>
    public static TencentMediaProcessCallbackPayload Parse(string payload, string? contentType)
    {
        var format = DetectFormat(payload, contentType);

        try
        {
            return format switch
            {
                "JSON" => ParseJson(payload),
                "XML" => ParseXml(payload),
                _ => throw new FormatException("不支持的腾讯云回调格式"),
            };
        }
        catch (JsonException exception)
        {
            throw new FormatException("腾讯云 JSON 回调格式无效", exception);
        }
        catch (XmlException exception)
        {
            throw new FormatException("腾讯云 XML 回调格式无效", exception);
        }
    }

    /// <summary>
    /// 根据正文首字符或 Content-Type 判断回调格式
    /// </summary>
    private static string DetectFormat(string payload, string? contentType)
    {
        var trimmedPayload = payload.TrimStart();
        if (trimmedPayload.StartsWith('<'))
        {
            return "XML";
        }

        if (trimmedPayload.StartsWith('{') || trimmedPayload.StartsWith('['))
        {
            return "JSON";
        }

        if (contentType?.Contains("xml", StringComparison.OrdinalIgnoreCase) == true)
        {
            return "XML";
        }

        if (contentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
        {
            return "JSON";
        }

        throw new FormatException("无法识别腾讯云回调格式");
    }

    /// <summary>
    /// 解析 JSON 格式回调,兼容外层 Response 包装与单任务对象两种结构
    /// </summary>
    private static TencentMediaProcessCallbackPayload ParseJson(string payload)
    {
        using var document = JsonDocument.Parse(payload);
        var response = document.RootElement;
        if (TryGetProperty(response, "Response", out var responseElement))
        {
            response = responseElement;
        }

        var jobs = new List<TencentMediaQueueJobRecord>();
        if (TryGetProperty(response, "JobsDetail", out var jobsElement))
        {
            if (jobsElement.ValueKind == JsonValueKind.Object)
            {
                jobs.Add(ParseJsonJob(jobsElement));
            }
            else if (jobsElement.ValueKind == JsonValueKind.Array)
            {
                foreach (var item in jobsElement.EnumerateArray())
                {
                    if (item.ValueKind == JsonValueKind.Object)
                    {
                        jobs.Add(ParseJsonJob(item));
                    }
                }
            }
        }

        var nonExistJobIds = new List<string>();
        if (TryGetProperty(response, "NonExistJobIds", out var nonExistElement))
        {
            if (nonExistElement.ValueKind == JsonValueKind.Array)
            {
                foreach (var item in nonExistElement.EnumerateArray())
                {
                    var value = GetJsonString(item);
                    if (!string.IsNullOrWhiteSpace(value))
                    {
                        nonExistJobIds.Add(value);
                    }
                }
            }
            else
            {
                var value = GetJsonString(nonExistElement);
                if (!string.IsNullOrWhiteSpace(value))
                {
                    nonExistJobIds.Add(value);
                }
            }
        }

        return new TencentMediaProcessCallbackPayload("JSON", jobs, nonExistJobIds);
    }

    /// <summary>
    /// 解析单个 JSON 任务对象为任务摘要
    /// </summary>
    private static TencentMediaQueueJobRecord ParseJsonJob(JsonElement job)
    {
        return new TencentMediaQueueJobRecord
        {
            JobId = GetJsonPropertyString(job, "JobId"),
            State = GetJsonPropertyString(job, "State"),
            Code = GetJsonPropertyString(job, "Code"),
            Message = GetJsonPropertyString(job, "Message"),
            Tag = GetJsonPropertyString(job, "Tag"),
            QueueId = GetJsonPropertyString(job, "QueueId"),
            CreationTime = GetJsonPropertyString(job, "CreationTime"),
            StartTime = GetJsonPropertyString(job, "StartTime"),
            EndTime = GetJsonPropertyString(job, "EndTime"),
            InputObject = GetJsonNestedPropertyString(job, "Input", "Object"),
            OutputObject = GetJsonNestedPropertyString(job, "Operation", "Output", "Object"),
        };
    }

    /// <summary>
    /// 解析 XML 格式回调,兼容外层 Response 包装
    /// </summary>
    private static TencentMediaProcessCallbackPayload ParseXml(string payload)
    {
        using var stringReader = new StringReader(payload);
        using var reader = XmlReader.Create(
            stringReader,
            new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }
        );
        var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace);
        var response = document.Root;
        if (response == null || !string.Equals(response.Name.LocalName, "Response"))
        {
            response = document
                .Descendants()
                .FirstOrDefault(x => string.Equals(x.Name.LocalName, "Response"));
        }

        if (response == null)
        {
            throw new FormatException("腾讯云 XML 回调缺少 Response 节点");
        }

        var jobs = response
            .Elements()
            .Where(x => string.Equals(x.Name.LocalName, "JobsDetail"))
            .Select(ParseXmlJob)
            .ToList();

        var nonExistJobIds = response
            .Elements()
            .Where(x => string.Equals(x.Name.LocalName, "NonExistJobIds"))
            .SelectMany(GetXmlStringValues)
            .Where(x => !string.IsNullOrWhiteSpace(x))
            .ToList();

        return new TencentMediaProcessCallbackPayload("XML", jobs, nonExistJobIds);
    }

    /// <summary>
    /// 解析单个 XML 任务节点为任务摘要
    /// </summary>
    private static TencentMediaQueueJobRecord ParseXmlJob(XElement job)
    {
        return new TencentMediaQueueJobRecord
        {
            JobId = GetXmlNestedChildValue(job, "JobId"),
            State = GetXmlNestedChildValue(job, "State"),
            Code = GetXmlNestedChildValue(job, "Code"),
            Message = GetXmlNestedChildValue(job, "Message"),
            Tag = GetXmlNestedChildValue(job, "Tag"),
            QueueId = GetXmlNestedChildValue(job, "QueueId"),
            CreationTime = GetXmlNestedChildValue(job, "CreationTime"),
            StartTime = GetXmlNestedChildValue(job, "StartTime"),
            EndTime = GetXmlNestedChildValue(job, "EndTime"),
            InputObject = GetXmlNestedChildValue(job, "Input", "Object"),
            OutputObject = GetXmlNestedChildValue(job, "Operation", "Output", "Object"),
        };
    }

    /// <summary>
    /// 按属性名不区分大小写查找 JSON 属性
    /// </summary>
    private static bool TryGetProperty(
        JsonElement element,
        string propertyName,
        out JsonElement value
    )
    {
        if (element.ValueKind == JsonValueKind.Object)
        {
            foreach (var property in element.EnumerateObject())
            {
                if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
                {
                    value = property.Value;
                    return true;
                }
            }
        }

        value = default;
        return false;
    }

    /// <summary>
    /// 读取 JSON 属性值并转为字符串,属性缺失时返回 null
    /// </summary>
    private static string? GetJsonPropertyString(JsonElement element, string propertyName)
    {
        return TryGetProperty(element, propertyName, out var value) ? GetJsonString(value) : null;
    }

    /// <summary>
    /// 按属性名路径逐层读取 JSON 字符串值,任一层缺失时返回 null
    /// </summary>
    private static string? GetJsonNestedPropertyString(
        JsonElement element,
        params string[] propertyNames
    )
    {
        foreach (var propertyName in propertyNames)
        {
            if (!TryGetProperty(element, propertyName, out element))
            {
                return null;
            }
        }

        return GetJsonString(element);
    }

    /// <summary>
    /// 将 JSON 值转为字符串,仅支持字符串、数字和布尔值,其余返回 null
    /// </summary>
    private static string? GetJsonString(JsonElement element)
    {
        return element.ValueKind switch
        {
            JsonValueKind.String => element.GetString(),
            JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.ToString(),
            _ => null,
        };
    }

    /// <summary>
    /// 按节点名路径逐层查找 XML 子节点并返回其文本值,任一层缺失时返回 null
    /// </summary>
    private static string? GetXmlNestedChildValue(XElement element, params string[] names)
    {
        var current = element;
        foreach (var name in names)
        {
            current = current.Elements().FirstOrDefault(x => string.Equals(x.Name.LocalName, name));
            if (current is null)
            {
                return null;
            }
        }

        return current.Value;
    }

    /// <summary>
    /// 读取 NonExistJobIds 节点下所有子节点的文本值,无子节点时返回节点自身文本
    /// </summary>
    private static List<string> GetXmlStringValues(XElement element)
    {
        var childValues = element
            .Elements()
            .Select(x => x.Value)
            .Where(x => !string.IsNullOrWhiteSpace(x))
            .ToList();
        return childValues.Count > 0 ? childValues : [element.Value];
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这段代码是一个用于解析腾讯云媒体处理(MPS)队列回调消息的 C# 解析器。它能把腾讯云回调返回的 JSON 或 XML 正文统一转换成结构化的数据模型,方便后续业务逻辑处理。


1. 整体设计

数据模型:TencentMediaProcessCallbackPayload

这是一个 record,包含三个属性:

  • Format:识别出的回调格式,固定为 "JSON""XML"
  • JobsDetail:任务详情列表,每个任务被抽象为 TencentMediaQueueJobRecord
  • NonExistJobIds:不存在的任务 ID 列表。

它用于统一承载来自两种不同格式的原始数据。

静态解析类:TencentMediaProcessCallbackParser

对外只暴露一个入口方法 Parse,内部根据格式自动分流,并进行容错解析。


2. 核心入口:Parse 方法

public static TencentMediaProcessCallbackPayload Parse(string payload, string? contentType)

参数:

  • payload:回调正文(字符串)。
  • contentType:HTTP 头中的 Content-Type,可能为 null

流程:

  1. 调用 DetectFormat 判断格式。
  2. 根据格式调用 ParseJsonParseXml
  3. 捕获解析过程中可能抛出的 JsonExceptionXmlException,统一包装为 FormatException,并附带说明“腾讯云 JSON/XML 回调格式无效”的提示信息。

3. 格式检测:DetectFormat

判断优先级如下:

  • 去掉正文开头的空白字符后,若以 < 开头 → 判定为 XML。
  • 若以 {[ 开头 → 判定为 JSON。
  • 如果正文没有明显特征,则根据 contentType 判断:
    • 包含 "xml"(不区分大小写)→ XML。
    • 包含 "json"(不区分大小写)→ JSON。
  • 否则抛出 FormatException,提示“无法识别腾讯云回调格式”。

这种策略优先信任实际正文内容,再使用 HTTP 头作为备选,增强了兼容性。


4. JSON 格式解析

兼容外层包装

腾讯云 API 通常返回类似 {"Response": {...}} 的结构,但某些场景也可能直接返回数据本身。代码先尝试从根元素中读取 "Response" 属性(不区分大小写),如果存在就切换到该子元素,否则仍使用原始根元素。

解析任务列表 JobsDetail

  • JobsDetail 是一个对象,则视为单个任务,直接解析。
  • JobsDetail 是一个数组,则遍历数组,仅处理对象类型的元素。
  • 每个对象通过 ParseJsonJob 转换为 TencentMediaQueueJobRecord

解析 NonExistJobIds

  • 若数组则逐个读取字符串元素。
  • 若单个值则直接读取为该列表的唯一元素。
  • 空字符串会被忽略。

ParseJsonJob 方法

从任务对象中提取常用字段:

  • 基本字段:JobIdStateCodeMessageTagQueueIdCreationTimeStartTimeEndTime
  • 嵌套字段:Input.ObjectOperation.Output.Object,即输入/输出文件路径。

所有字段都通过辅助方法安全读取,缺失时返回 null,不会因缺少某个属性而中断解析。


5. XML 格式解析

安全初始化

使用 XmlReader.Create 时指定:

  • DtdProcessing = DtdProcessing.Prohibit:禁止 DTD 处理,防止 XXE(XML 外部实体注入)攻击。
  • XmlResolver = null:不解析外部资源。

查找 Response 节点

与 JSON 类似,先看根节点是否为 <Response>,若不是,则在所有后代节点中查找第一个名为 Response 的节点。若找不到则抛出 FormatException

解析任务列表 JobsDetail

通过 response.Elements() 直接查找所有名为 JobsDetail 的子节点,逐一调用 ParseXmlJob

解析 NonExistJobIds

同样查找该子节点,并通过 GetXmlStringValues 提取所有子元素的文本值;若没有子元素,则返回节点自身的文本。

ParseXmlJob 方法

对应 JSON 版本,提取相同的字段,并使用 GetXmlNestedChildValue 逐层查找嵌套节点(如 Input.ObjectOperation.Output.Object)。


6. 辅助方法

JSON 辅助方法

  • TryGetProperty:忽略属性名大小写,在 JSON 对象中查找指定属性,将结果输出到 out 参数。
  • GetJsonPropertyString:获取指定属性的字符串值。
  • GetJsonNestedPropertyString:按路径逐层查找属性,例如 GetJsonNestedPropertyString(job, "Operation", "Output", "Object")
  • GetJsonString:将 JSON 值转为字符串——只有字符串、数字、布尔值会被转换,其他类型(如对象、数组)返回 null

XML 辅助方法

  • GetXmlNestedChildValue:按节点名路径逐层查找子节点,最后返回其文本值,任一层缺失返回 null
  • GetXmlStringValues:收集某节点下所有子元素的文本值,如果没有子元素则返回节点本身文本(用于兼容直接以文本形式存在的 NonExistJobIds)。

7. 关键特性与优点

  • 自动格式识别:不需要调用方特意指定格式,更加健壮。
  • 兼容多种腾讯云返回结构:既能处理带 Response 包装的标准响应,也能处理直接返回数据的简化结构;任务可以是单个对象或数组。
  • 字段缺失容错:所有字段读取均为可选,缺失时置 null,不会因数据不完整而抛异常。
  • 安全性:XML 解析时禁用了 DTD 和外部实体,防止 XXE 等安全风险。
  • 统一的错误处理:将底层异常(JsonException / XmlException)包装成 FormatException,便于上层捕获并返回明确的业务错误。

8. 使用场景

这段代码适用于接收腾讯云媒体处理(MPS)任务完成、失败等回调通知的场景。它将原始回调消息转换为 TencentMediaProcessCallbackPayload 对象,上层可以据此获取任务 ID、状态、输入输出路径等信息,从而更新业务状态或触发后续流程。


总体而言,这是一个结构清晰、防御性较强的解析器,充分考虑了腾讯云回调的多样性和潜在的安全威胁,是一个可靠的基础组件。

评论加载中...