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(); + if (arr.ValueKind == JsonValueKind.Array) + { + foreach (var el in arr.EnumerateArray().Take(12)) + { + events.Add(new + { + time = Pick(el, "trackTime", "TrackTime", "time", "Time", "createTime", "CreateTime"), + content = Pick(el, "trackContent", "TrackContent", "content", "Content", "description", "Description"), + location = Pick(el, "location", "Location", "city", "City"), + status = Pick(el, "trackCode", "TrackCode", "status", "Status") + }); + } + } + + return (true, JsonSerializer.Serialize(new + { + orderNumber = orderNumber.Trim(), + count = events.Count, + events + }, JsonOptions)); + } + catch + { + return (true, Trim(body, 4000)); + } + } + + private static string? GetString(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String) + { + var s = el.GetString(); + if (!string.IsNullOrWhiteSpace(s)) return s; + } + } + return null; + } + + private static decimal? GetDecimal(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (!root.TryGetProperty(name, out var el)) continue; + if (el.ValueKind == JsonValueKind.Number && el.TryGetDecimal(out var d)) return d; + if (el.ValueKind == JsonValueKind.String && decimal.TryParse(el.GetString(), out var parsed)) return parsed; + } + return null; + } + + private static string? Pick(JsonElement el, params string[] names) + { + foreach (var name in names) + { + if (!el.TryGetProperty(name, out var p)) continue; + var s = p.ValueKind switch + { + JsonValueKind.String => p.GetString(), + JsonValueKind.Number => p.ToString(), + _ => p.ToString() + }; + if (!string.IsNullOrWhiteSpace(s)) return s; + } + return null; + } + + private static string Trim(string text, int max) => + string.IsNullOrEmpty(text) ? string.Empty : text.Length <= max ? text : text[..max]; +} diff --git a/src/Oms.Application/Services/Mcp/McpToolService.cs b/src/Oms.Application/Services/Mcp/McpToolService.cs new file mode 100644 index 0000000..d1a412d --- /dev/null +++ b/src/Oms.Application/Services/Mcp/McpToolService.cs @@ -0,0 +1,317 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Mcp; +using Oms.Application.Dtos.Shared; +using Oms.Application.Mcp; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.McpAggregate.Entities; +using Oms.Domain.McpAggregate.Repositories; +using Oms.Domain.XhsAggregate.Repositories; + +namespace Oms.Application.Services.Mcp; + +[ApiExplorerSettings(GroupName = "OMS-Mcp")] +public class McpToolService : ApplicationService +{ + private IMcpToolRepository McpToolRepository => LazyServiceProvider.GetRequiredService(); + private IMcpToolExecutor ToolExecutor => LazyServiceProvider.GetRequiredService(); + private IXhsAiProviderRepository AiProviderRepository => LazyServiceProvider.GetRequiredService(); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private XhsLlmClient LlmClient => LazyServiceProvider.GetRequiredService(); + + /// 列出 MCP 工具(内置目录 + 库表覆盖) + [HttpPost("/mcp/tool/list")] + public async Task> ListAsync() + { + var stored = await McpToolRepository.GetListAsync(); + var byKey = stored.ToDictionary(x => x.ToolKey, StringComparer.OrdinalIgnoreCase); + var items = McpToolCatalog.All.Select(def => + { + byKey.TryGetValue(def.ToolKey, out var row); + return new McpToolOutput + { + Id = row?.Id, + ToolKey = def.ToolKey, + DisplayName = string.IsNullOrWhiteSpace(row?.DisplayName) ? def.DisplayName : row!.DisplayName, + Description = string.IsNullOrWhiteSpace(row?.Description) ? def.Description : row!.Description, + Enabled = row?.Enabled ?? true, + SortOrder = row?.SortOrder ?? def.DefaultSortOrder, + InputSchemaJson = def.InputSchemaJson.Trim(), + Builtin = true + }; + }) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.ToolKey) + .ToList(); + + return new OmsItemsResult { Items = items }; + } + + /// 保存工具启用状态与描述 + [HttpPost("/mcp/tool/save")] + public async Task SaveAsync(McpToolSaveInput input) + { + var def = McpToolCatalog.Find(input.ToolKey) + ?? throw new BusinessException($"未知工具:{input.ToolKey}"); + + var entity = await McpToolRepository.GetByToolKeyAsync(def.ToolKey); + if (entity == null) + { + entity = new McpTool { ToolKey = def.ToolKey }; + await McpToolRepository.InsertAsync(entity); + } + + entity.DisplayName = string.IsNullOrWhiteSpace(input.DisplayName) ? def.DisplayName : input.DisplayName.Trim(); + entity.Description = string.IsNullOrWhiteSpace(input.Description) ? def.Description : input.Description.Trim(); + entity.Enabled = input.Enabled; + entity.SortOrder = input.SortOrder; + await McpToolRepository.UpdateAsync(entity); + + return new McpToolOutput + { + Id = entity.Id, + ToolKey = entity.ToolKey, + DisplayName = entity.DisplayName, + Description = entity.Description, + Enabled = entity.Enabled, + SortOrder = entity.SortOrder, + InputSchemaJson = def.InputSchemaJson.Trim(), + Builtin = true + }; + } + + /// 手动试跑工具 + [HttpPost("/mcp/tool/call")] + public async Task CallAsync(McpToolCallInput input) + { + await EnsureToolEnabledAsync(input.ToolKey); + var sw = Stopwatch.StartNew(); + var (ok, content) = await ToolExecutor.InvokeAsync(input.ToolKey, input.ArgumentsJson ?? "{}"); + return new McpToolCallOutput + { + ToolKey = input.ToolKey, + Success = ok, + Content = content, + ElapsedMs = sw.ElapsedMilliseconds + }; + } + + /// AI Agent 对话(可自动调用已启用 MCP 工具;带防滥用限流) + [HttpPost("/ai/agent/chat")] + public async Task AgentChatAsync(AiAgentChatInput input) + { + var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser); + var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser); + var principal = mapId is > 0 + ? $"u{mapId.Value}" + : (customerId > 0 ? $"c{customerId}" : $"a{CurrentUser.GetId()}"); + + AiAgentAbuseGuard.EnsureAllowed(principal); + + var provider = await ResolveProviderAsync(customerId, input.ProviderKey.Trim()); + + var enabledTools = await ResolveEnabledToolsAsync(input.ToolKeys); + var llmTools = enabledTools.Select(t => + { + JsonNode parameters; + try + { + parameters = JsonNode.Parse(t.InputSchemaJson) ?? new JsonObject(); + } + catch + { + parameters = new JsonObject { ["type"] = "object" }; + } + + return new LlmToolDefinition + { + Name = t.ToolKey, + Description = $"{t.DisplayName}:{t.Description}", + Parameters = parameters + }; + }).ToList(); + + var messages = new List + { + new() + { + Role = "system", + Content = + "你是 OMS 客户侧物流助手。可用工具:" + + "oms.price_check(查价格)、oms.channel_guide(查对客说明/品名可否承运)、oms.track_query(查轨迹)。" + + "\n【强制槽位规则】信息不齐时禁止猜测调用工具,先用一两句中文追问缺项:" + + "\n- 查价必填:目的国家、实重(kg);选填:邮编、长宽高(cm)、货物属性、运输方式。" + + "国家可用中文,你在调工具时转成二字码(美国→US、英国→GB)。" + + "\n- 对客说明/可承运必填:销售产品代码或名称 + 客户品名;缺一不可。" + + "\n- 查轨迹必填:运单号/跟踪号/客户单号;若上下文已有运单号可直接查。" + + "\n【省 token】回复尽量短;一次只调必需工具;不要复述大段工具原文或 JSON。" + } + }; + + foreach (var m in TrimHistory(input.Messages)) + { + messages.Add(new LlmChatMessage + { + Role = string.IsNullOrWhiteSpace(m.Role) ? "user" : m.Role.Trim(), + Content = AiAgentAbuseGuard.Truncate(m.Content, AiAgentAbuseGuard.MaxMessageChars) + }); + } + + var traces = new List(); + var maxRounds = Math.Clamp( + input.MaxToolRounds <= 0 ? AiAgentAbuseGuard.MaxToolRoundsHardCap : input.MaxToolRounds, + 1, + AiAgentAbuseGuard.MaxToolRoundsHardCap); + var toolInvocations = 0; + + for (var round = 0; round < maxRounds; round++) + { + AiAgentAbuseGuard.ConsumeLlmCall(principal); + var result = await LlmClient.ChatCompletionDetailedAsync( + provider, + messages, + llmTools.Count > 0 ? llmTools : null, + input.Model); + + if (result.ToolCalls.Count == 0) + { + return new AiAgentChatOutput + { + Content = result.Content?.Trim() ?? string.Empty, + ToolTraces = traces + }; + } + + var calls = result.ToolCalls.Take(AiAgentAbuseGuard.MaxToolCallsPerRound).ToList(); + messages.Add(new LlmChatMessage + { + Role = "assistant", + Content = AiAgentAbuseGuard.Truncate(result.Content, 400), + ToolCalls = calls + }); + + foreach (var call in calls) + { + if (toolInvocations >= AiAgentAbuseGuard.MaxToolInvocationsPerRequest) + { + messages.Add(new LlmChatMessage + { + Role = "tool", + ToolCallId = call.Id, + Name = call.FunctionName, + Content = "本轮工具调用次数已达上限,请根据已有结果直接回答用户。" + }); + continue; + } + + toolInvocations++; + var enabled = enabledTools.Any(t => + string.Equals(t.ToolKey, call.FunctionName, StringComparison.OrdinalIgnoreCase)); + string toolContent; + var success = false; + if (!enabled) + { + toolContent = $"工具未启用或不存在:{call.FunctionName}"; + } + else + { + (success, toolContent) = await ToolExecutor.InvokeAsync( + call.FunctionName, + call.ArgumentsJson, + customerId); + } + + toolContent = AiAgentAbuseGuard.Truncate(toolContent, AiAgentAbuseGuard.MaxToolResultChars); + traces.Add(new AiAgentToolTraceOutput + { + ToolKey = call.FunctionName, + ArgumentsJson = AiAgentAbuseGuard.Truncate(call.ArgumentsJson, 400), + ResultPreview = toolContent.Length > 500 ? toolContent[..500] + "…" : toolContent, + Success = success + }); + + messages.Add(new LlmChatMessage + { + Role = "tool", + ToolCallId = call.Id, + Name = call.FunctionName, + Content = toolContent + }); + } + } + + AiAgentAbuseGuard.ConsumeLlmCall(principal); + var final = await LlmClient.ChatCompletionDetailedAsync(provider, messages, tools: null, input.Model); + return new AiAgentChatOutput + { + Content = final.Content?.Trim() + ?? "已查询相关信息,请根据页面提示补充后重试,或联系客服。", + ToolTraces = traces + }; + } + + private static List TrimHistory(List raw) + { + var cleaned = (raw ?? []) + .Where(m => m != null && !string.IsNullOrWhiteSpace(m.Content)) + .Select(m => new AiAgentChatMessageInput + { + Role = string.IsNullOrWhiteSpace(m.Role) ? "user" : m.Role.Trim().ToLowerInvariant(), + Content = m.Content ?? "" + }) + .Where(m => m.Role is "user" or "assistant" or "system") + .ToList(); + + if (cleaned.Count <= AiAgentAbuseGuard.MaxHistoryMessages) + { + return cleaned; + } + + return cleaned.Skip(cleaned.Count - AiAgentAbuseGuard.MaxHistoryMessages).ToList(); + } + + private async Task EnsureToolEnabledAsync(string toolKey) + { + var def = McpToolCatalog.Find(toolKey) + ?? throw new BusinessException($"未知工具:{toolKey}"); + var row = await McpToolRepository.GetByToolKeyAsync(def.ToolKey); + if (row is { Enabled: false }) + { + throw new BusinessException($"工具已禁用:{def.ToolKey}"); + } + } + + private async Task> ResolveEnabledToolsAsync(List? toolKeys) + { + var all = (await ListAsync()).Items; + var enabled = all.Where(x => x.Enabled).ToList(); + if (toolKeys is { Count: > 0 }) + { + var set = new HashSet(toolKeys, StringComparer.OrdinalIgnoreCase); + enabled = enabled.Where(x => set.Contains(x.ToolKey)).ToList(); + } + return enabled; + } + + private async Task ResolveProviderAsync( + int customerId, + string providerKey) + { + var provider = await AiProviderRepository.GetByProviderKeyAsync(customerId, providerKey); + if (provider == null || !provider.Enabled) + { + provider = await AiProviderRepository.GetByProviderKeyAsync(0, providerKey); + } + + if (provider == null || !provider.Enabled) + { + throw new BusinessException($"AI 提供商 {providerKey} 未配置或未启用"); + } + + return provider; + } +} diff --git a/src/Oms.Application/Shared/XhsLlmClient.cs b/src/Oms.Application/Shared/XhsLlmClient.cs index 02c5166..c76d48f 100644 --- a/src/Oms.Application/Shared/XhsLlmClient.cs +++ b/src/Oms.Application/Shared/XhsLlmClient.cs @@ -1,12 +1,44 @@ using System.Net.Http.Headers; -using System.Net.Http.Json; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using Fake.Application; using Microsoft.Extensions.DependencyInjection; +using Oms.Application.Mcp; using Oms.Domain.XhsAggregate.Entities; namespace Oms.Application.Shared; +public class LlmChatMessage +{ + public string Role { get; set; } = "user"; + public string? Content { get; set; } + public List? ToolCalls { get; set; } + public string? ToolCallId { get; set; } + public string? Name { get; set; } +} + +public class LlmToolCall +{ + public string Id { get; set; } = string.Empty; + public string Type { get; set; } = "function"; + public string FunctionName { get; set; } = string.Empty; + public string ArgumentsJson { get; set; } = "{}"; +} + +public class LlmToolDefinition +{ + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public JsonNode Parameters { get; set; } = new JsonObject(); +} + +public class LlmChatResult +{ + public string? Content { get; set; } + public List ToolCalls { get; set; } = []; +} + /// /// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。 /// @@ -26,6 +58,22 @@ public class XhsLlmClient IReadOnlyList<(string Role, string Content)> messages, string? modelOverride = null, CancellationToken cancellationToken = default) + { + var result = await ChatCompletionDetailedAsync( + provider, + messages.Select(m => new LlmChatMessage { Role = m.Role, Content = m.Content }).ToList(), + tools: null, + modelOverride, + cancellationToken); + return result.Content?.Trim() ?? string.Empty; + } + + public async Task ChatCompletionDetailedAsync( + XhsAiProvider provider, + IReadOnlyList messages, + IReadOnlyList? tools = null, + string? modelOverride = null, + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(provider.ApiKeyEnc)) { @@ -35,21 +83,19 @@ public class XhsLlmClient var model = modelOverride ?? provider.ApiSecretEnc; if (string.IsNullOrWhiteSpace(model)) { - throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)"); + throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)或在请求中传入 model"); } var url = BuildChatUrl(provider.BaseUrl); - var payload = new - { - model, - messages = messages.Select(x => new { role = x.Role, content = x.Content }).ToList(), - temperature = 0.7 - }; + var payload = BuildPayload(model, messages, tools); var client = _httpClientFactory.CreateClient(HttpClientName); using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", provider.ApiKeyEnc.Trim()); - request.Content = JsonContent.Create(payload); + request.Content = new StringContent(payload.ToJsonString(new JsonSerializerOptions + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }), Encoding.UTF8, "application/json"); using var response = await client.SendAsync(request, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); @@ -62,10 +108,40 @@ public class XhsLlmClient var root = doc.RootElement; if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0 && - choices[0].TryGetProperty("message", out var message) && - message.TryGetProperty("content", out var content)) + choices[0].TryGetProperty("message", out var message)) { - return content.GetString()?.Trim() ?? string.Empty; + var result = new LlmChatResult(); + if (message.TryGetProperty("content", out var content) && content.ValueKind != JsonValueKind.Null) + { + result.Content = content.GetString(); + } + + if (message.TryGetProperty("tool_calls", out var toolCalls) && + toolCalls.ValueKind == JsonValueKind.Array) + { + foreach (var call in toolCalls.EnumerateArray()) + { + var id = call.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "" : ""; + var fn = call.TryGetProperty("function", out var fnEl) ? fnEl : default; + var name = fn.ValueKind == JsonValueKind.Object && fn.TryGetProperty("name", out var n) + ? n.GetString() ?? "" + : ""; + var args = fn.ValueKind == JsonValueKind.Object && fn.TryGetProperty("arguments", out var a) + ? a.GetString() ?? "{}" + : "{}"; + if (!string.IsNullOrWhiteSpace(name)) + { + result.ToolCalls.Add(new LlmToolCall + { + Id = string.IsNullOrWhiteSpace(id) ? Guid.NewGuid().ToString("N") : id, + FunctionName = name, + ArgumentsJson = string.IsNullOrWhiteSpace(args) ? "{}" : args + }); + } + } + } + + return result; } if (root.TryGetProperty("error", out var error) && @@ -77,6 +153,78 @@ public class XhsLlmClient throw new BusinessException("AI 响应格式无法解析"); } + private static JsonObject BuildPayload( + string model, + IReadOnlyList messages, + IReadOnlyList? tools) + { + var msgArr = new JsonArray(); + foreach (var m in messages) + { + var obj = new JsonObject { ["role"] = m.Role }; + if (m.Role == "tool") + { + obj["tool_call_id"] = m.ToolCallId ?? ""; + if (!string.IsNullOrWhiteSpace(m.Name)) obj["name"] = m.Name; + obj["content"] = m.Content ?? ""; + } + else if (m.ToolCalls is { Count: > 0 }) + { + if (m.Content != null) obj["content"] = m.Content; + var calls = new JsonArray(); + foreach (var c in m.ToolCalls) + { + calls.Add(new JsonObject + { + ["id"] = c.Id, + ["type"] = "function", + ["function"] = new JsonObject + { + ["name"] = c.FunctionName, + ["arguments"] = c.ArgumentsJson + } + }); + } + obj["tool_calls"] = calls; + } + else + { + obj["content"] = m.Content ?? ""; + } + + msgArr.Add(obj); + } + + var payload = new JsonObject + { + ["model"] = model, + ["messages"] = msgArr, + ["temperature"] = 0.3 + }; + + if (tools is { Count: > 0 }) + { + var toolArr = new JsonArray(); + foreach (var t in tools) + { + toolArr.Add(new JsonObject + { + ["type"] = "function", + ["function"] = new JsonObject + { + ["name"] = t.Name, + ["description"] = t.Description, + ["parameters"] = t.Parameters.DeepClone() + } + }); + } + payload["tools"] = toolArr; + payload["tool_choice"] = "auto"; + } + + return payload; + } + private static string BuildChatUrl(string baseUrl) { var url = (baseUrl ?? string.Empty).Trim().TrimEnd('/'); @@ -90,8 +238,6 @@ public class XhsLlmClient return url; } - // OpenAI / DeepSeek:.../v1 → .../v1/chat/completions - // 智谱:.../api/paas/v4 → .../v4/chat/completions(勿再拼 /v1) if (url.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) || url.EndsWith("/v4", StringComparison.OrdinalIgnoreCase)) { @@ -120,6 +266,10 @@ public static class XhsLlmServiceExtensions { client.Timeout = TimeSpan.FromMinutes(3); }); + services.AddHttpClient(McpToolExecutor.TrackHttpClientName, client => + { + client.Timeout = TimeSpan.FromSeconds(45); + }); services.AddTransient(); return services; } diff --git a/src/Oms.Domain/McpAggregate/Entities/McpTool.cs b/src/Oms.Domain/McpAggregate/Entities/McpTool.cs new file mode 100644 index 0000000..ef2c5fb --- /dev/null +++ b/src/Oms.Domain/McpAggregate/Entities/McpTool.cs @@ -0,0 +1,22 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.McpAggregate.Entities; + +[SugarTable("t_mcp_tool")] +public class McpTool : OmsAuditedAggregateRoot +{ + public string ToolKey { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + [SugarColumn(Length = 1000)] + public string Description { get; set; } = string.Empty; + + public bool Enabled { get; set; } = true; + + public int SortOrder { get; set; } + + [SugarColumn(IsNullable = true, Length = 512)] + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/McpAggregate/Repositories/IMcpToolRepository.cs b/src/Oms.Domain/McpAggregate/Repositories/IMcpToolRepository.cs new file mode 100644 index 0000000..7c3d0c9 --- /dev/null +++ b/src/Oms.Domain/McpAggregate/Repositories/IMcpToolRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.McpAggregate.Entities; + +namespace Oms.Domain.McpAggregate.Repositories; + +public interface IMcpToolRepository : IRepository +{ + Task GetByToolKeyAsync(string toolKey, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.SqlSugarCore/Mcp/McpToolRepository.cs b/src/Oms.SqlSugarCore/Mcp/McpToolRepository.cs new file mode 100644 index 0000000..cf7c6c7 --- /dev/null +++ b/src/Oms.SqlSugarCore/Mcp/McpToolRepository.cs @@ -0,0 +1,14 @@ +using Fake.DependencyInjection; +using Oms.Domain.McpAggregate.Entities; +using Oms.Domain.McpAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Mcp; + +public class McpToolRepository : OmsSqlSugarRepository, IMcpToolRepository, IScopedDependency +{ + public Task GetByToolKeyAsync(string toolKey, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.ToolKey == toolKey, cancellationToken); + } +}