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