From 238eb5d817cb32c4c0f5216d126f2a8d455015ba Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 30 Jul 2026 20:09:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=94=B6=E4=BB=B6?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=20AI=20=E8=A7=A3=E6=9E=90=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 /order/receiver/parse-address,供 PC/H5 下单地址解析使用。 Co-authored-by: Cursor --- .../Orders/OrderReceiverParseAddressDtos.cs | 38 +++ .../OrderService.ReceiverAddressParse.cs | 299 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 src/Oms.Application/Dtos/Orders/OrderReceiverParseAddressDtos.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs diff --git a/src/Oms.Application/Dtos/Orders/OrderReceiverParseAddressDtos.cs b/src/Oms.Application/Dtos/Orders/OrderReceiverParseAddressDtos.cs new file mode 100644 index 0000000..58113ef --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderReceiverParseAddressDtos.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// 粘贴整段地址 → AI 拆分为收件人字段。 +public class OrderReceiverParseAddressInput +{ + /// 客户粘贴的原始地址文本(姓名/电话/国家/省市区/街道/邮编等)。 + [Required] + [MaxLength(4000)] + public string RawText { get; set; } = string.Empty; + + /// 可选;为空时取客户/平台默认已启用 AI 提供商。 + [MaxLength(64)] + public string? ProviderKey { get; set; } + + /// 可选模型覆盖。 + [MaxLength(128)] + public string? Model { get; set; } +} + +/// 地址解析结果 + 完整性校验。 +public class OrderReceiverParseAddressOutput +{ + public OrderParcelReceiverInput Receiver { get; set; } = new(); + + /// 必填项是否齐全(姓名/手机/国家/邮编/地址1)。 + public bool IsComplete { get; set; } + + /// 缺失的必填字段中文名。 + public List MissingRequired { get; set; } = []; + + /// 建议补全的字段(省/州、城市等)。 + public List MissingRecommended { get; set; } = []; + + /// 模型或规则给出的提示(歧义、格式可疑等)。 + public List Warnings { get; set; } = []; +} diff --git a/src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs b/src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs new file mode 100644 index 0000000..8bf89d1 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.ReceiverAddressParse.cs @@ -0,0 +1,299 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.XhsAggregate.Entities; +using Oms.Domain.XhsAggregate.Repositories; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private static readonly string[] ReceiverRequiredFieldLabels = + [ + "收件人名称", + "收件人手机", + "国家", + "邮编", + "地址1", + ]; + + private IXhsAiProviderRepository AiProviderRepository => + LazyServiceProvider.GetRequiredService(); + + private XhsLlmClient 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 systemPrompt = + """ + 你是国际物流 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 不是本任务重点,原文没有就忽略。 + """; + + 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)]; + } +}