using System.Text.Json; using System.Text.RegularExpressions; using Fake.Application; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Ai; using Oms.Application.Shared; using Oms.Domain.AiAggregate.Entities; using Oms.Domain.AiAggregate.Repositories; namespace Oms.Application.Services.Orders; public partial class OrderService { private static readonly string[] ReceiverRequiredFieldLabels = [ "收件人名称", "收件人手机", "国家", "邮编", "地址1", ]; private const string DefaultReceiverAddressParseSkill = """ 你是国际物流 OMS 的收件地址解析助手(跨境打单场景)。 目标:把客户粘贴的整段中/英收件信息拆成结构化字段,并做「粗校验」提示——只关心下单/预报大概能不能用,不做地图精校、不验证门牌是否真实、不臆造邮编与城市匹配。 只输出 JSON,不要 Markdown,不要解释。字段: { "recipientsName": "", "companyName": "", "phone": "", "telePhone": "", "email": "", "countryCode": "", "province": "", "city": "", "streetNumber": "", "streetAddress1": "", "streetAddress2": "", "streetAddress3": "", "zipCode": "", "warnings": [] } 规则: 1) countryCode 必须是 ISO 二字码大写(中国→CN,美国→US,英国→GB 不要用 UK,香港→HK,台湾→TW,澳门→MO)。无法判断则留空并在 warnings 说明。 2) phone 优先填手机;固定电话放 telePhone;保留 + 国家码。 3) 街道门牌放入 streetAddress1;过长拆到 streetAddress2/3;单独门牌号可放 streetNumber。 4) 中国地址:省→province,市→city,区县街道合并进 streetAddress1(不要把省市区塞进姓名)。 5) 英文地址:State→province,City→city,Street→streetAddress*,ZIP/Postcode→zipCode。 6) 粗风险才写 warnings(短中文):缺国家/邮编/电话不像号码/街道与姓名粘连拆不清等。禁止写「地图不存在」「邮编与城市不匹配」这类无依据结论。 7) 不确定的字段留空;不要编造原文没有的省市区邮编。 8) 税号/EORI/VAT 不是本任务重点,原文没有就忽略。 """; private IAiProviderRepository AiProviderRepository => LazyServiceProvider.GetRequiredService(); private AiLlmClient LlmClient => LazyServiceProvider.GetRequiredService(); /// /// AI 解析粘贴的收件地址文本,并校验是否完整。 /// [HttpPost("/order/receiver/parse-address")] public async Task ParseReceiverAddressAsync( [FromBody] OrderReceiverParseAddressInput input, CancellationToken cancellationToken = default) { var raw = (input.RawText ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(raw)) { throw new BusinessException("请先粘贴收件地址文本"); } if (raw.Length > 4000) { throw new BusinessException("地址文本过长,请精简后再解析"); } var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser); var provider = await ResolveAiProviderAsync(customerId, input.ProviderKey); var skillService = LazyServiceProvider.GetRequiredService(); var skillKey = string.IsNullOrWhiteSpace(input.SkillKey) ? AiSkillService.KeyReceiverAddressParse : input.SkillKey.Trim(); var systemPrompt = await skillService.TryGetContentByKeyAsync(skillKey); if (string.IsNullOrWhiteSpace(systemPrompt)) { if (skillKey == AiSkillService.KeyReceiverAddressParse) { systemPrompt = DefaultReceiverAddressParseSkill; } else { throw new BusinessException($"Skill「{skillKey}」未配置或未启用"); } } var userPrompt = "请解析以下收件信息:\n" + raw; string modelText; try { modelText = await LlmClient.ChatCompletionAsync( provider, [("system", systemPrompt), ("user", userPrompt)], string.IsNullOrWhiteSpace(input.Model) ? null : input.Model.Trim(), cancellationToken); } catch (BusinessException) { throw; } catch (Exception ex) { throw new BusinessException($"AI 地址解析失败:{ex.Message}"); } var receiver = ParseReceiverJson(modelText, out var modelWarnings); NormalizeReceiver(receiver); var (isComplete, missingRequired, missingRecommended) = EvaluateCompleteness(receiver); var warnings = new List(); warnings.AddRange(modelWarnings); if (!string.IsNullOrWhiteSpace(receiver.CountryCode) && receiver.CountryCode!.Length != 2) { warnings.Add("国家代码格式异常,请手动确认"); } return new OrderReceiverParseAddressOutput { Receiver = receiver, IsComplete = isComplete, MissingRequired = missingRequired, MissingRecommended = missingRecommended, Warnings = warnings.Distinct(StringComparer.Ordinal).ToList(), }; } private async Task ResolveAiProviderAsync(int customerId, string? providerKey) { var key = (providerKey ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(key)) { var byKey = await AiProviderRepository.GetByProviderKeyAsync(customerId, key); if (byKey is not { Enabled: true }) { byKey = await AiProviderRepository.GetByProviderKeyAsync(0, key); } if (byKey is not { Enabled: true }) { throw new BusinessException($"AI 提供商 {key} 未配置或未启用"); } return byKey; } var list = await AiProviderRepository.GetListAsync(x => (x.CustomerId == customerId || x.CustomerId == 0) && x.Enabled); var preferred = list .OrderByDescending(x => x.CustomerId == customerId) .ThenByDescending(x => x.IsDefault) .ThenBy(x => x.Id) .FirstOrDefault(); if (preferred == null) { throw new BusinessException("未配置可用的 AI 提供商,请先在系统中启用 AI 服务"); } return preferred; } private static OrderParcelReceiverInput ParseReceiverJson(string modelText, out List warnings) { warnings = []; var json = ExtractJsonObject(modelText); if (string.IsNullOrWhiteSpace(json)) { throw new BusinessException("AI 未返回可识别的地址结构,请调整粘贴内容后重试"); } using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object) { throw new BusinessException("AI 返回格式异常,请重试"); } if (root.TryGetProperty("warnings", out var warnNode) && warnNode.ValueKind == JsonValueKind.Array) { foreach (var item in warnNode.EnumerateArray()) { var w = item.GetString()?.Trim(); if (!string.IsNullOrWhiteSpace(w)) { warnings.Add(w); } } } return new OrderParcelReceiverInput { RecipientsName = ReadStr(root, "recipientsName"), CompanyName = ReadStr(root, "companyName"), Phone = ReadStr(root, "phone"), TelePhone = ReadStr(root, "telePhone"), Email = ReadStr(root, "email"), CountryCode = ReadStr(root, "countryCode"), Province = ReadStr(root, "province"), City = ReadStr(root, "city"), StreetNumber = ReadStr(root, "streetNumber"), StreetAddress1 = ReadStr(root, "streetAddress1"), StreetAddress2 = ReadStr(root, "streetAddress2"), StreetAddress3 = ReadStr(root, "streetAddress3"), ZipCode = ReadStr(root, "zipCode"), }; } private static void NormalizeReceiver(OrderParcelReceiverInput receiver) { receiver.RecipientsName = CleanText(receiver.RecipientsName); receiver.CompanyName = CleanText(receiver.CompanyName); receiver.Phone = CleanText(receiver.Phone); receiver.TelePhone = CleanText(receiver.TelePhone); receiver.Email = CleanText(receiver.Email); receiver.Province = CleanText(receiver.Province); receiver.City = CleanText(receiver.City); receiver.StreetNumber = CleanText(receiver.StreetNumber); receiver.StreetAddress1 = CleanText(receiver.StreetAddress1); receiver.StreetAddress2 = CleanText(receiver.StreetAddress2); receiver.StreetAddress3 = CleanText(receiver.StreetAddress3); receiver.ZipCode = CleanText(receiver.ZipCode); var cc = CleanText(receiver.CountryCode); if (!string.IsNullOrWhiteSpace(cc)) { receiver.CountryCode = cc.ToUpperInvariant(); } else { receiver.CountryCode = null; } } private static (bool IsComplete, List MissingRequired, List MissingRecommended) EvaluateCompleteness(OrderParcelReceiverInput receiver) { var missingRequired = new List(); if (string.IsNullOrWhiteSpace(receiver.RecipientsName)) missingRequired.Add(ReceiverRequiredFieldLabels[0]); if (string.IsNullOrWhiteSpace(receiver.Phone)) missingRequired.Add(ReceiverRequiredFieldLabels[1]); if (string.IsNullOrWhiteSpace(receiver.CountryCode)) missingRequired.Add(ReceiverRequiredFieldLabels[2]); if (string.IsNullOrWhiteSpace(receiver.ZipCode)) missingRequired.Add(ReceiverRequiredFieldLabels[3]); if (string.IsNullOrWhiteSpace(receiver.StreetAddress1)) missingRequired.Add(ReceiverRequiredFieldLabels[4]); var missingRecommended = new List(); if (string.IsNullOrWhiteSpace(receiver.Province)) missingRecommended.Add("省/州"); if (string.IsNullOrWhiteSpace(receiver.City)) missingRecommended.Add("城市"); return (missingRequired.Count == 0, missingRequired, missingRecommended); } private static string? ReadStr(JsonElement root, string name) { if (!root.TryGetProperty(name, out var node)) { // 兼容 PascalCase var pascal = char.ToUpperInvariant(name[0]) + name[1..]; if (!root.TryGetProperty(pascal, out node)) { return null; } } return node.ValueKind switch { JsonValueKind.String => node.GetString(), JsonValueKind.Number => node.ToString(), JsonValueKind.Null => null, _ => node.ToString(), }; } private static string? CleanText(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string? ExtractJsonObject(string text) { if (string.IsNullOrWhiteSpace(text)) { return null; } var trimmed = text.Trim(); var fence = Regex.Match( trimmed, @"```(?:json)?\s*([\s\S]*?)```", RegexOptions.IgnoreCase); if (fence.Success) { trimmed = fence.Groups[1].Value.Trim(); } var start = trimmed.IndexOf('{'); var end = trimmed.LastIndexOf('}'); if (start < 0 || end <= start) { return null; } return trimmed[start..(end + 1)]; } }