feat: 新增轻量 MCP 工具与 AI Agent,并限制 token 滥用

提供查价格/对客说明/查轨迹工具与管理接口;扩展 LLM function calling;按用户 Redis 限流并压缩上下文与工具结果。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-29 12:03:45 +08:00
parent 53c986c746
commit 7c410b1625
11 changed files with 1190 additions and 14 deletions

32
sql/20260729_mcp_tool.sql Normal file
View File

@ -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`);

View File

@ -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 工具配置表';

View File

@ -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; }
/// <summary>内置参数 JSON Schema(只读)</summary>
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;
/// <summary>工具参数 JSON 对象字符串或字典序列化</summary>
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<AiAgentChatMessageInput> Messages { get; set; } = [];
/// <summary>为空则使用全部已启用工具</summary>
public List<string>? 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<AiAgentToolTraceOutput> 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; }
}

View File

@ -0,0 +1,116 @@
using Fake.Application;
using Oms.Application.Helpers;
namespace Oms.Application.Mcp;
/// <summary>
/// Agent 防滥用(Redis 计数,不落库日志):按用户限制每分钟/每天调用次数与 LLM 轮次预算。
/// </summary>
public static class AiAgentAbuseGuard
{
/// <summary>每用户每分钟最多 Agent 请求次数</summary>
public const int MaxRequestsPerMinute = 6;
/// <summary>每用户每天最多 Agent 请求次数</summary>
public const int MaxRequestsPerDay = 60;
/// <summary>每用户每天最多 LLM 调用次数(含工具轮次)</summary>
public const int MaxLlmCallsPerDay = 180;
/// <summary>单次请求最多工具轮次(含最终总结)</summary>
public const int MaxToolRoundsHardCap = 2;
/// <summary>单次请求最多执行工具次数</summary>
public const int MaxToolInvocationsPerRequest = 3;
/// <summary>单轮最多并行 tool_calls</summary>
public const int MaxToolCallsPerRound = 2;
/// <summary>送入模型的对话条数上限(不含 system)</summary>
public const int MaxHistoryMessages = 8;
/// <summary>单条消息字符上限</summary>
public const int MaxMessageChars = 1200;
/// <summary>工具结果写入上下文的字符上限</summary>
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} 次),请明天再试或联系客服");
}
/// <summary>预扣 / 累计一次 LLM 调用;超限抛错。</summary>
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] + "…";
}
}

View File

@ -0,0 +1,78 @@
namespace Oms.Application.Mcp;
public record McpToolDefinition(
string ToolKey,
string DisplayName,
string Description,
string InputSchemaJson,
int DefaultSortOrder);
/// <summary>内置 MCP 工具目录(代码权威;库表可覆盖名称/描述/启用)。</summary>
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<McpToolDefinition> 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));
}

View File

@ -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<McpToolExecutor> 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<Oms.Domain.ChannelsAggregate.Entities.SalesProduct>();
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<object>();
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];
}

View File

@ -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<IMcpToolRepository>();
private IMcpToolExecutor ToolExecutor => LazyServiceProvider.GetRequiredService<IMcpToolExecutor>();
private IXhsAiProviderRepository AiProviderRepository => LazyServiceProvider.GetRequiredService<IXhsAiProviderRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private XhsLlmClient LlmClient => LazyServiceProvider.GetRequiredService<XhsLlmClient>();
/// <summary>列出 MCP 工具(内置目录 + 库表覆盖)</summary>
[HttpPost("/mcp/tool/list")]
public async Task<OmsItemsResult<McpToolOutput>> 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<McpToolOutput> { Items = items };
}
/// <summary>保存工具启用状态与描述</summary>
[HttpPost("/mcp/tool/save")]
public async Task<McpToolOutput> 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
};
}
/// <summary>手动试跑工具</summary>
[HttpPost("/mcp/tool/call")]
public async Task<McpToolCallOutput> 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
};
}
/// <summary>AI Agent 对话(可自动调用已启用 MCP 工具;带防滥用限流)</summary>
[HttpPost("/ai/agent/chat")]
public async Task<AiAgentChatOutput> 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<int>()}");
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<LlmChatMessage>
{
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<AiAgentToolTraceOutput>();
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<AiAgentChatMessageInput> TrimHistory(List<AiAgentChatMessageInput> 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<List<McpToolOutput>> ResolveEnabledToolsAsync(List<string>? toolKeys)
{
var all = (await ListAsync()).Items;
var enabled = all.Where(x => x.Enabled).ToList();
if (toolKeys is { Count: > 0 })
{
var set = new HashSet<string>(toolKeys, StringComparer.OrdinalIgnoreCase);
enabled = enabled.Where(x => set.Contains(x.ToolKey)).ToList();
}
return enabled;
}
private async Task<Oms.Domain.XhsAggregate.Entities.XhsAiProvider> 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;
}
}

View File

@ -1,12 +1,44 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using Fake.Application; using Fake.Application;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Oms.Application.Mcp;
using Oms.Domain.XhsAggregate.Entities; using Oms.Domain.XhsAggregate.Entities;
namespace Oms.Application.Shared; namespace Oms.Application.Shared;
public class LlmChatMessage
{
public string Role { get; set; } = "user";
public string? Content { get; set; }
public List<LlmToolCall>? 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<LlmToolCall> ToolCalls { get; set; } = [];
}
/// <summary> /// <summary>
/// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。 /// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。
/// </summary> /// </summary>
@ -26,6 +58,22 @@ public class XhsLlmClient
IReadOnlyList<(string Role, string Content)> messages, IReadOnlyList<(string Role, string Content)> messages,
string? modelOverride = null, string? modelOverride = null,
CancellationToken cancellationToken = default) 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<LlmChatResult> ChatCompletionDetailedAsync(
XhsAiProvider provider,
IReadOnlyList<LlmChatMessage> messages,
IReadOnlyList<LlmToolDefinition>? tools = null,
string? modelOverride = null,
CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(provider.ApiKeyEnc)) if (string.IsNullOrWhiteSpace(provider.ApiKeyEnc))
{ {
@ -35,21 +83,19 @@ public class XhsLlmClient
var model = modelOverride ?? provider.ApiSecretEnc; var model = modelOverride ?? provider.ApiSecretEnc;
if (string.IsNullOrWhiteSpace(model)) if (string.IsNullOrWhiteSpace(model))
{ {
throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)"); throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)或在请求中传入 model");
} }
var url = BuildChatUrl(provider.BaseUrl); var url = BuildChatUrl(provider.BaseUrl);
var payload = new var payload = BuildPayload(model, messages, tools);
{
model,
messages = messages.Select(x => new { role = x.Role, content = x.Content }).ToList(),
temperature = 0.7
};
var client = _httpClientFactory.CreateClient(HttpClientName); var client = _httpClientFactory.CreateClient(HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, url); using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", provider.ApiKeyEnc.Trim()); 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); using var response = await client.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken);
@ -62,10 +108,40 @@ public class XhsLlmClient
var root = doc.RootElement; var root = doc.RootElement;
if (root.TryGetProperty("choices", out var choices) && if (root.TryGetProperty("choices", out var choices) &&
choices.GetArrayLength() > 0 && choices.GetArrayLength() > 0 &&
choices[0].TryGetProperty("message", out var message) && choices[0].TryGetProperty("message", out var message))
message.TryGetProperty("content", out var content))
{ {
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) && if (root.TryGetProperty("error", out var error) &&
@ -77,6 +153,78 @@ public class XhsLlmClient
throw new BusinessException("AI 响应格式无法解析"); throw new BusinessException("AI 响应格式无法解析");
} }
private static JsonObject BuildPayload(
string model,
IReadOnlyList<LlmChatMessage> messages,
IReadOnlyList<LlmToolDefinition>? 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) private static string BuildChatUrl(string baseUrl)
{ {
var url = (baseUrl ?? string.Empty).Trim().TrimEnd('/'); var url = (baseUrl ?? string.Empty).Trim().TrimEnd('/');
@ -90,8 +238,6 @@ public class XhsLlmClient
return url; return url;
} }
// OpenAI / DeepSeek:.../v1 → .../v1/chat/completions
// 智谱:.../api/paas/v4 → .../v4/chat/completions(勿再拼 /v1)
if (url.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) || if (url.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) ||
url.EndsWith("/v4", StringComparison.OrdinalIgnoreCase)) url.EndsWith("/v4", StringComparison.OrdinalIgnoreCase))
{ {
@ -120,6 +266,10 @@ public static class XhsLlmServiceExtensions
{ {
client.Timeout = TimeSpan.FromMinutes(3); client.Timeout = TimeSpan.FromMinutes(3);
}); });
services.AddHttpClient(McpToolExecutor.TrackHttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(45);
});
services.AddTransient<XhsLlmClient>(); services.AddTransient<XhsLlmClient>();
return services; return services;
} }

View File

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

View File

@ -0,0 +1,9 @@
using Fake.Domain.Repositories;
using Oms.Domain.McpAggregate.Entities;
namespace Oms.Domain.McpAggregate.Repositories;
public interface IMcpToolRepository : IRepository<McpTool>
{
Task<McpTool?> GetByToolKeyAsync(string toolKey, CancellationToken cancellationToken = default);
}

View File

@ -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<McpTool>, IMcpToolRepository, IScopedDependency
{
public Task<McpTool?> GetByToolKeyAsync(string toolKey, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.ToolKey == toolKey, cancellationToken);
}
}