feat: 新增收件地址 AI 解析接口

支持 /order/receiver/parse-address,供 PC/H5 下单地址解析使用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-30 20:09:42 +08:00
parent 644da097bf
commit 238eb5d817
2 changed files with 337 additions and 0 deletions

View File

@ -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; } = [];
}

View File

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