feat: 新增收件地址 AI 解析接口
支持 /order/receiver/parse-address,供 PC/H5 下单地址解析使用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
644da097bf
commit
238eb5d817
@ -0,0 +1,38 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
|
/// <summary>粘贴整段地址 → AI 拆分为收件人字段。</summary>
|
||||||
|
public class OrderReceiverParseAddressInput
|
||||||
|
{
|
||||||
|
/// <summary>客户粘贴的原始地址文本(姓名/电话/国家/省市区/街道/邮编等)。</summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(4000)]
|
||||||
|
public string RawText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>可选;为空时取客户/平台默认已启用 AI 提供商。</summary>
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? ProviderKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>可选模型覆盖。</summary>
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? Model { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>地址解析结果 + 完整性校验。</summary>
|
||||||
|
public class OrderReceiverParseAddressOutput
|
||||||
|
{
|
||||||
|
public OrderParcelReceiverInput Receiver { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>必填项是否齐全(姓名/手机/国家/邮编/地址1)。</summary>
|
||||||
|
public bool IsComplete { get; set; }
|
||||||
|
|
||||||
|
/// <summary>缺失的必填字段中文名。</summary>
|
||||||
|
public List<string> MissingRequired { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>建议补全的字段(省/州、城市等)。</summary>
|
||||||
|
public List<string> MissingRecommended { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>模型或规则给出的提示(歧义、格式可疑等)。</summary>
|
||||||
|
public List<string> Warnings { get; set; } = [];
|
||||||
|
}
|
||||||
@ -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<IXhsAiProviderRepository>();
|
||||||
|
|
||||||
|
private XhsLlmClient LlmClient => LazyServiceProvider.GetRequiredService<XhsLlmClient>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI 解析粘贴的收件地址文本,并校验是否完整。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/order/receiver/parse-address")]
|
||||||
|
public async Task<OrderReceiverParseAddressOutput> 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<string>();
|
||||||
|
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<XhsAiProvider> 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<string> 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<string> MissingRequired, List<string> MissingRecommended)
|
||||||
|
EvaluateCompleteness(OrderParcelReceiverInput receiver)
|
||||||
|
{
|
||||||
|
var missingRequired = new List<string>();
|
||||||
|
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<string>();
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user