Add claimed count to workplace stats; refine receiver address parse DTOs/helpers and AI skill seed wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
12 KiB
C#
318 lines
12 KiB
C#
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<IAiProviderRepository>();
|
||
|
||
private AiLlmClient LlmClient => LazyServiceProvider.GetRequiredService<AiLlmClient>();
|
||
|
||
/// <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 skillService = LazyServiceProvider.GetRequiredService<AiSkillService>();
|
||
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<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<AiProvider> 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)];
|
||
}
|
||
}
|