diff --git a/sql/20260729_mcp_tool.sql b/sql/20260729_mcp_tool.sql
new file mode 100644
index 0000000..ebb0a69
--- /dev/null
+++ b/sql/20260729_mcp_tool.sql
@@ -0,0 +1,32 @@
+-- MCP 工具配置(启用/描述覆盖),与代码内置目录合并
+-- 对齐 t_system_other_config / t_order_file 等配置表结构习惯
+
+CREATE TABLE IF NOT EXISTS `t_mcp_tool` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
+ `ToolKey` VARCHAR(64) NOT NULL COMMENT '工具编码,如 oms.price_check',
+ `DisplayName` VARCHAR(128) NOT NULL COMMENT '显示名称',
+ `Description` VARCHAR(1000) NOT NULL COMMENT '给 AI 的工具描述',
+ `Enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用:0-否,1-是',
+ `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
+ `Remark` VARCHAR(512) NULL COMMENT '备注',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `UpdateUserId` INT NULL COMMENT '更新人',
+ `UpdateTime` DATETIME NULL COMMENT '更新时间',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_mcp_tool_key` (`ToolKey`),
+ KEY `idx_mcp_tool_enabled` (`Enabled`, `SortOrder`, `Id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP 工具配置表';
+
+INSERT INTO `t_mcp_tool` (`ToolKey`, `DisplayName`, `Description`, `Enabled`, `SortOrder`, `Remark`)
+VALUES
+ ('oms.price_check', '查价格', '根据目的国、重量等查询运费报价,返回可用渠道与价格摘要。', 1, 10, '内置:封装 /order/price-check'),
+ ('oms.channel_guide', '查渠道对客说明', '按销售产品代码/名称查询对客说明(禁运、可寄品名等),判断客户品名是否可承运。', 1, 20, '内置:读销售产品 CustomerGuideMarkdown'),
+ ('oms.track_query', '查轨迹', '根据运单号/客户单号查询物流轨迹时间轴摘要。', 1, 30, '内置:轨迹中心 GetDetailTimeline')
+ON DUPLICATE KEY UPDATE
+ `DisplayName` = VALUES(`DisplayName`),
+ `Description` = VALUES(`Description`),
+ `SortOrder` = VALUES(`SortOrder`),
+ `Remark` = VALUES(`Remark`);
diff --git a/sql/20260729_mcp_tool_alter_align.sql b/sql/20260729_mcp_tool_alter_align.sql
new file mode 100644
index 0000000..501ee94
--- /dev/null
+++ b/sql/20260729_mcp_tool_alter_align.sql
@@ -0,0 +1,38 @@
+-- 若已按旧版建过 t_mcp_tool,执行本脚本补齐字段(幂等)
+
+ALTER TABLE `t_mcp_tool`
+ ADD COLUMN IF NOT EXISTS `Remark` VARCHAR(512) NULL COMMENT '备注' AFTER `SortOrder`;
+
+-- MySQL 8.0.12 前无 IF NOT EXISTS 时,可改用下面手工判断;8.0.29+ 支持 ADD COLUMN IF NOT EXISTS
+-- 并发戳:已存在则跳过
+SET @col_exists := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 't_mcp_tool'
+ AND COLUMN_NAME = 'ConcurrencyStamp'
+);
+SET @sql := IF(
+ @col_exists = 0,
+ 'ALTER TABLE `t_mcp_tool` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'' AFTER `IsDeleted`',
+ 'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx_exists := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 't_mcp_tool'
+ AND INDEX_NAME = 'idx_mcp_tool_enabled'
+);
+SET @sql := IF(
+ @idx_exists = 0,
+ 'ALTER TABLE `t_mcp_tool` ADD KEY `idx_mcp_tool_enabled` (`Enabled`, `SortOrder`, `Id`)',
+ 'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+ALTER TABLE `t_mcp_tool` COMMENT = 'MCP 工具配置表';
diff --git a/src/Oms.Application/Dtos/Mcp/McpDtos.cs b/src/Oms.Application/Dtos/Mcp/McpDtos.cs
new file mode 100644
index 0000000..a59d983
--- /dev/null
+++ b/src/Oms.Application/Dtos/Mcp/McpDtos.cs
@@ -0,0 +1,85 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Mcp;
+
+public class McpToolOutput
+{
+ public int? Id { get; set; }
+ public string ToolKey { get; set; } = string.Empty;
+ public string DisplayName { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public bool Enabled { get; set; }
+ public int SortOrder { get; set; }
+ /// 内置参数 JSON Schema(只读)
+ public string InputSchemaJson { get; set; } = string.Empty;
+ public bool Builtin { get; set; } = true;
+}
+
+public class McpToolSaveInput
+{
+ [Required]
+ public string ToolKey { get; set; } = string.Empty;
+
+ [Required]
+ public string DisplayName { get; set; } = string.Empty;
+
+ [Required]
+ public string Description { get; set; } = string.Empty;
+
+ public bool Enabled { get; set; } = true;
+
+ public int SortOrder { get; set; }
+}
+
+public class McpToolCallInput
+{
+ [Required]
+ public string ToolKey { get; set; } = string.Empty;
+
+ /// 工具参数 JSON 对象字符串或字典序列化
+ public string ArgumentsJson { get; set; } = "{}";
+}
+
+public class McpToolCallOutput
+{
+ public string ToolKey { get; set; } = string.Empty;
+ public bool Success { get; set; }
+ public string Content { get; set; } = string.Empty;
+ public long ElapsedMs { get; set; }
+}
+
+public class AiAgentChatInput
+{
+ [Required]
+ public string ProviderKey { get; set; } = string.Empty;
+
+ public string? Model { get; set; }
+
+ [Required]
+ public List Messages { get; set; } = [];
+
+ /// 为空则使用全部已启用工具
+ public List? ToolKeys { get; set; }
+
+ public int MaxToolRounds { get; set; } = 3;
+}
+
+public class AiAgentChatMessageInput
+{
+ public string Role { get; set; } = "user";
+ public string Content { get; set; } = string.Empty;
+}
+
+public class AiAgentChatOutput
+{
+ public string Content { get; set; } = string.Empty;
+ public List ToolTraces { get; set; } = [];
+}
+
+public class AiAgentToolTraceOutput
+{
+ public string ToolKey { get; set; } = string.Empty;
+ public string ArgumentsJson { get; set; } = string.Empty;
+ public string ResultPreview { get; set; } = string.Empty;
+ public bool Success { get; set; }
+}
diff --git a/src/Oms.Application/Mcp/AiAgentAbuseGuard.cs b/src/Oms.Application/Mcp/AiAgentAbuseGuard.cs
new file mode 100644
index 0000000..266dfcc
--- /dev/null
+++ b/src/Oms.Application/Mcp/AiAgentAbuseGuard.cs
@@ -0,0 +1,116 @@
+using Fake.Application;
+using Oms.Application.Helpers;
+
+namespace Oms.Application.Mcp;
+
+///
+/// Agent 防滥用(Redis 计数,不落库日志):按用户限制每分钟/每天调用次数与 LLM 轮次预算。
+///
+public static class AiAgentAbuseGuard
+{
+ /// 每用户每分钟最多 Agent 请求次数
+ public const int MaxRequestsPerMinute = 6;
+
+ /// 每用户每天最多 Agent 请求次数
+ public const int MaxRequestsPerDay = 60;
+
+ /// 每用户每天最多 LLM 调用次数(含工具轮次)
+ public const int MaxLlmCallsPerDay = 180;
+
+ /// 单次请求最多工具轮次(含最终总结)
+ public const int MaxToolRoundsHardCap = 2;
+
+ /// 单次请求最多执行工具次数
+ public const int MaxToolInvocationsPerRequest = 3;
+
+ /// 单轮最多并行 tool_calls
+ public const int MaxToolCallsPerRound = 2;
+
+ /// 送入模型的对话条数上限(不含 system)
+ public const int MaxHistoryMessages = 8;
+
+ /// 单条消息字符上限
+ public const int MaxMessageChars = 1200;
+
+ /// 工具结果写入上下文的字符上限
+ public const int MaxToolResultChars = 1000;
+
+ public static void EnsureAllowed(string principalKey)
+ {
+ var key = Normalize(principalKey);
+ TryIncr($"oms:ai:agent:rpm:{key}:{DateTime.UtcNow:yyyyMMddHHmm}", MaxRequestsPerMinute, 70,
+ $"提问过于频繁,请稍后再试(每分钟最多 {MaxRequestsPerMinute} 次)");
+ TryIncr($"oms:ai:agent:rpd:{key}:{DateTime.UtcNow:yyyyMMdd}", MaxRequestsPerDay, 86400,
+ $"今日提问次数已达上限({MaxRequestsPerDay} 次),请明天再试或联系客服");
+ }
+
+ /// 预扣 / 累计一次 LLM 调用;超限抛错。
+ public static void ConsumeLlmCall(string principalKey, int count = 1)
+ {
+ if (count <= 0) return;
+ var key = Normalize(principalKey);
+ var redisKey = $"oms:ai:agent:llm:{key}:{DateTime.UtcNow:yyyyMMdd}";
+ try
+ {
+ var client = RedisHelper.Client;
+ var next = client.IncrBy(redisKey, count);
+ if (next == count)
+ {
+ client.Expire(redisKey, 86400);
+ }
+
+ if (next > MaxLlmCallsPerDay)
+ {
+ throw new BusinessException(
+ $"今日 AI 调用额度已用尽(约 {MaxLlmCallsPerDay} 次模型请求),请明天再试");
+ }
+ }
+ catch (BusinessException)
+ {
+ throw;
+ }
+ catch
+ {
+ // Redis 异常时不阻断,避免整站挂死;仍依赖轮次硬顶
+ }
+ }
+
+ private static void TryIncr(string redisKey, int max, int ttlSeconds, string denyMessage)
+ {
+ try
+ {
+ var client = RedisHelper.Client;
+ var next = client.Incr(redisKey);
+ if (next == 1)
+ {
+ client.Expire(redisKey, ttlSeconds);
+ }
+
+ if (next > max)
+ {
+ throw new BusinessException(denyMessage);
+ }
+ }
+ catch (BusinessException)
+ {
+ throw;
+ }
+ catch
+ {
+ // Redis 不可用时放行,靠硬顶控消耗
+ }
+ }
+
+ private static string Normalize(string principalKey)
+ {
+ var raw = string.IsNullOrWhiteSpace(principalKey) ? "anon" : principalKey.Trim();
+ return raw.Length > 64 ? raw[..64] : raw;
+ }
+
+ public static string Truncate(string? text, int maxChars)
+ {
+ var s = text ?? string.Empty;
+ if (s.Length <= maxChars) return s;
+ return s[..maxChars] + "…";
+ }
+}
diff --git a/src/Oms.Application/Mcp/McpToolCatalog.cs b/src/Oms.Application/Mcp/McpToolCatalog.cs
new file mode 100644
index 0000000..4ab874f
--- /dev/null
+++ b/src/Oms.Application/Mcp/McpToolCatalog.cs
@@ -0,0 +1,78 @@
+namespace Oms.Application.Mcp;
+
+public record McpToolDefinition(
+ string ToolKey,
+ string DisplayName,
+ string Description,
+ string InputSchemaJson,
+ int DefaultSortOrder);
+
+/// 内置 MCP 工具目录(代码权威;库表可覆盖名称/描述/启用)。
+public static class McpToolCatalog
+{
+ public const string PriceCheck = "oms.price_check";
+ public const string ChannelGuide = "oms.channel_guide";
+ public const string TrackQuery = "oms.track_query";
+
+ public static IReadOnlyList All { get; } =
+ [
+ new(
+ PriceCheck,
+ "查价格",
+ "根据目的国、重量等查询运费报价,返回可用渠道与价格摘要。",
+ """
+ {
+ "type": "object",
+ "properties": {
+ "destinationCountry": { "type": "string", "description": "目的国家二字码,如 US、GB" },
+ "cargoWeight": { "type": "number", "description": "货物重量 kg" },
+ "length": { "type": "number", "description": "长 cm,可选" },
+ "width": { "type": "number", "description": "宽 cm,可选" },
+ "height": { "type": "number", "description": "高 cm,可选" },
+ "postalCode": { "type": "string", "description": "邮编,可选" },
+ "shippingMethod": { "type": "string", "description": "运输方式/产品代码关键词,可选" },
+ "cargoType": { "type": "string", "description": "货物属性编码,如 general/sensitive,可选" }
+ },
+ "required": ["destinationCountry", "cargoWeight"]
+ }
+ """,
+ 10),
+ new(
+ ChannelGuide,
+ "查渠道对客说明",
+ "按销售产品代码或名称查询对客说明,判断客户品名是否可承运(禁运/可寄)。",
+ """
+ {
+ "type": "object",
+ "properties": {
+ "productCode": { "type": "string", "description": "销售产品代码,优先使用" },
+ "productName": { "type": "string", "description": "销售产品名称关键词(无代码时用)" },
+ "cargoName": { "type": "string", "description": "客户品名,用于对照说明判断可否承运" }
+ },
+ "required": ["cargoName"],
+ "anyOf": [
+ { "required": ["productCode"] },
+ { "required": ["productName"] }
+ ]
+ }
+ """,
+ 20),
+ new(
+ TrackQuery,
+ "查轨迹",
+ "根据运单号或客户单号查询物流轨迹时间轴摘要。",
+ """
+ {
+ "type": "object",
+ "properties": {
+ "orderNumber": { "type": "string", "description": "运单号/跟踪号/客户单号" }
+ },
+ "required": ["orderNumber"]
+ }
+ """,
+ 30)
+ ];
+
+ public static McpToolDefinition? Find(string toolKey) =>
+ All.FirstOrDefault(x => string.Equals(x.ToolKey, toolKey, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/Oms.Application/Mcp/McpToolExecutor.cs b/src/Oms.Application/Mcp/McpToolExecutor.cs
new file mode 100644
index 0000000..c9fb27e
--- /dev/null
+++ b/src/Oms.Application/Mcp/McpToolExecutor.cs
@@ -0,0 +1,315 @@
+using System.Diagnostics;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Fake.Application;
+using Fake.DependencyInjection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Integrations.SmallBagTrack;
+using Oms.Application.Services.Orders;
+using Oms.Domain.ChannelsAggregate.Repositories;
+using Oms.Domain.Shared.Enums;
+
+namespace Oms.Application.Mcp;
+
+public interface IMcpToolExecutor
+{
+ Task<(bool Success, string Content)> InvokeAsync(
+ string toolKey,
+ string argumentsJson,
+ int? customerId = null,
+ CancellationToken cancellationToken = default);
+}
+
+public class McpToolExecutor(
+ IServiceProvider serviceProvider,
+ ISalesProductRepository salesProductRepository,
+ ISmallBagTrackConfigProvider trackConfigProvider,
+ IHttpClientFactory httpClientFactory,
+ IHttpContextAccessor httpContextAccessor,
+ ILogger logger) : IMcpToolExecutor, IScopedDependency
+{
+ public const string TrackHttpClientName = "McpTrackTimeline";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false
+ };
+
+ public async Task<(bool Success, string Content)> InvokeAsync(
+ string toolKey,
+ string argumentsJson,
+ int? customerId = null,
+ CancellationToken cancellationToken = default)
+ {
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ var key = toolKey.Trim();
+ var result = key switch
+ {
+ McpToolCatalog.PriceCheck => await PriceCheckAsync(argumentsJson, customerId, cancellationToken),
+ McpToolCatalog.ChannelGuide => await ChannelGuideAsync(argumentsJson, cancellationToken),
+ McpToolCatalog.TrackQuery => await TrackQueryAsync(argumentsJson, cancellationToken),
+ _ => (false, $"未知工具:{toolKey}")
+ };
+ logger.LogInformation(
+ "MCP tool {Tool} finished in {Ms}ms success={Ok}",
+ key,
+ sw.ElapsedMilliseconds,
+ result.Item1);
+ return result;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "MCP tool {Tool} failed", toolKey);
+ return (false, $"工具执行失败:{ex.Message}");
+ }
+ }
+
+ private async Task<(bool, string)> PriceCheckAsync(
+ string argumentsJson,
+ int? customerId,
+ CancellationToken cancellationToken)
+ {
+ using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = doc.RootElement;
+ var country = GetString(root, "destinationCountry", "DestinationCountry");
+ if (string.IsNullOrWhiteSpace(country))
+ {
+ return (false, "缺少 destinationCountry(目的国家二字码)");
+ }
+
+ var weight = GetDecimal(root, "cargoWeight", "CargoWeight");
+ if (weight is null or <= 0)
+ {
+ return (false, "缺少有效的 cargoWeight(kg)");
+ }
+
+ var input = new OrderPriceCheckInput
+ {
+ DestinationCountry = country.Trim().ToUpperInvariant(),
+ CargoWeight = weight,
+ Length = GetDecimal(root, "length", "Length"),
+ Width = GetDecimal(root, "width", "Width"),
+ Height = GetDecimal(root, "height", "Height"),
+ PostalCode = GetString(root, "postalCode", "PostalCode"),
+ ShippingMethod = GetString(root, "shippingMethod", "ShippingMethod"),
+ CargoType = GetString(root, "cargoType", "CargoType"),
+ CustomerId = customerId
+ };
+
+ var orderService = serviceProvider.GetService(typeof(OrderService)) as OrderService
+ ?? throw new BusinessException("OrderService 未注册");
+ var result = await orderService.PriceCheckAsync(input);
+
+ var top = (result.Items ?? [])
+ .OrderBy(x => x.TotalCostNum > 0 ? x.TotalCostNum : x.TotalCost)
+ .Take(5)
+ .Select(x => new
+ {
+ productCode = x.ProductCode,
+ shippingMethod = x.ShippingMethod,
+ warehouse = x.Warehouse,
+ cargoType = x.CargoTypeName ?? x.CargoType,
+ totalCost = x.TotalCostNum > 0 ? x.TotalCostNum : x.TotalCost,
+ deliveryTime = x.DeliveryTime,
+ chargeableWeight = x.ChargeableWeight,
+ quoteLevel = x.QuoteLevel
+ })
+ .ToList();
+
+ var failures = (result.Failures ?? [])
+ .Take(5)
+ .Select(x => new { productCode = x.ProductCode, reason = x.Reason })
+ .ToList();
+
+ var payload = new
+ {
+ count = top.Count,
+ items = top,
+ failures,
+ queryHint = result.QueryHint == null
+ ? null
+ : new
+ {
+ result.QueryHint.QuoteLevel,
+ result.QueryHint.MissingFieldCodes,
+ result.QueryHint.Disclaimers
+ }
+ };
+ return (true, JsonSerializer.Serialize(payload, JsonOptions));
+ }
+
+ private async Task<(bool, string)> ChannelGuideAsync(string argumentsJson, CancellationToken cancellationToken)
+ {
+ using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = doc.RootElement;
+ var productCode = GetString(root, "productCode", "ProductCode");
+ var productName = GetString(root, "productName", "ProductName");
+ var cargoName = GetString(root, "cargoName", "CargoName");
+
+ var products = new List();
+ if (!string.IsNullOrWhiteSpace(productCode))
+ {
+ var one = await salesProductRepository.GetByProductCodeAsync(productCode.Trim(), cancellationToken);
+ if (one != null) products.Add(one);
+ }
+
+ if (products.Count == 0 && !string.IsNullOrWhiteSpace(productName))
+ {
+ var keyword = productName.Trim();
+ var list = await salesProductRepository.GetListAsync(
+ x => x.IsActive == EnableStatus.Enabled
+ && (x.ProductName.Contains(keyword) || x.ProductCode.Contains(keyword)));
+ products.AddRange(list.Take(5));
+ }
+
+ if (products.Count == 0)
+ {
+ return (false, "未找到销售产品,请提供 productCode 或 productName");
+ }
+
+ var items = products.Select(p =>
+ {
+ var guide = p.CustomerGuideMarkdown?.Trim() ?? string.Empty;
+ if (guide.Length > 1500) guide = guide[..1500] + "…";
+ return new
+ {
+ productCode = p.ProductCode,
+ productName = p.ProductName,
+ hasGuide = !string.IsNullOrWhiteSpace(guide),
+ customerGuideMarkdown = string.IsNullOrWhiteSpace(guide) ? null : guide,
+ cargoNameToCheck = string.IsNullOrWhiteSpace(cargoName) ? null : cargoName.Trim(),
+ hint = string.IsNullOrWhiteSpace(guide)
+ ? "该产品尚未配置对客说明,无法据此判断品名可否承运。"
+ : "请根据对客说明判断 cargoName 是否可寄;说明未提及的品名不要擅自判定可寄。"
+ };
+ }).ToList();
+
+ return (true, JsonSerializer.Serialize(new { items }, JsonOptions));
+ }
+
+ private async Task<(bool, string)> TrackQueryAsync(string argumentsJson, CancellationToken cancellationToken)
+ {
+ using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var orderNumber = GetString(doc.RootElement, "orderNumber", "OrderNumber", "serviceNo", "trackNo");
+ if (string.IsNullOrWhiteSpace(orderNumber))
+ {
+ return (false, "缺少 orderNumber(运单号)");
+ }
+
+ var settings = await trackConfigProvider.GetAsync(cancellationToken);
+ if (!settings.Enabled || string.IsNullOrWhiteSpace(settings.BaseUrl))
+ {
+ return (false, "轨迹服务未启用或未配置 BaseUrl(系统其他配置 TrackService)");
+ }
+
+ var baseUrl = settings.BaseUrl.Trim().TrimEnd('/');
+ var url = $"{baseUrl}/Track/Order/GetDetailTimeline";
+ var client = httpClientFactory.CreateClient(TrackHttpClientName);
+ using var request = new HttpRequestMessage(HttpMethod.Post, url);
+ request.Content = JsonContent.Create(new { orderNumber = orderNumber.Trim() });
+
+ var auth = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
+ if (!string.IsNullOrWhiteSpace(auth))
+ {
+ request.Headers.TryAddWithoutValidation("Authorization", auth);
+ }
+
+ using var response = await client.SendAsync(request, cancellationToken);
+ var body = await response.Content.ReadAsStringAsync(cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ return (false, $"轨迹查询失败 ({(int)response.StatusCode}): {Trim(body, 400)}");
+ }
+
+ try
+ {
+ using var timelineDoc = JsonDocument.Parse(body);
+ var root = timelineDoc.RootElement;
+ // 兼容直接数组 / { data: [] } / { Data: [] }
+ JsonElement arr = root;
+ if (root.ValueKind == JsonValueKind.Object)
+ {
+ if (root.TryGetProperty("data", out var d) || root.TryGetProperty("Data", out d))
+ arr = d;
+ else if (root.TryGetProperty("items", out var items) || root.TryGetProperty("Items", out items))
+ arr = items;
+ }
+
+ var events = new List