Add claimed count to workplace stats; refine receiver address parse DTOs/helpers and AI skill seed wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
252 lines
9.4 KiB
C#
252 lines
9.4 KiB
C#
using System.Text.Json;
|
||
using Fake.Application;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Oms.Application.Dtos.Ai;
|
||
using Oms.Application.Dtos.Shared;
|
||
using Oms.Domain.AiAggregate.Entities;
|
||
using Oms.Domain.AiAggregate.Repositories;
|
||
|
||
namespace Oms.Application.Services.Ai;
|
||
|
||
/// <summary>
|
||
/// 平台级 AI Skill 管理(提示词/规范正文)。
|
||
/// </summary>
|
||
[ApiExplorerSettings(GroupName = "OMS-Ai")]
|
||
public class AiSkillService : ApplicationService
|
||
{
|
||
public const string ScenePricePublish = "pricePublish";
|
||
public const string SceneAutoTriggerFormula = "autoTriggerFormula";
|
||
public const string SceneBillingConditionFormula = "billingConditionFormula";
|
||
public const string SceneReceiverAddressParse = "receiverAddressParse";
|
||
public const string SceneItfxPrefixScript = "itfxPrefixScript";
|
||
public const string SceneItfxParameterMap = "itfxParameterMap";
|
||
|
||
public const string KeyPricePublish = "oms-sales-price-publish-risk";
|
||
public const string KeyAutoTriggerFormula = "oms-auto-trigger-formula";
|
||
public const string KeyBillingConditionFormula = "oms-billing-condition-formula";
|
||
public const string KeyReceiverAddressParse = "oms-receiver-address-parse";
|
||
public const string KeyItfxPrefixScript = "itfx-prefix-script";
|
||
public const string KeyItfxCreateOrderMap = "itfx-create-order-map";
|
||
|
||
private IAiSkillRepository AiSkillRepository => LazyServiceProvider.GetRequiredService<IAiSkillRepository>();
|
||
|
||
/// <summary>
|
||
/// Skill 列表(可按场景过滤)。
|
||
/// </summary>
|
||
[HttpPost("/ai/skill/list")]
|
||
public async Task<OmsItemsResult<AiSkillOutput>> ListAsync(AiSkillListInput input)
|
||
{
|
||
await EnsureBuiltinSkillsAsync();
|
||
var scene = NormalizeOptional(input.Scene);
|
||
var items = await AiSkillRepository.GetListAsync(x =>
|
||
(scene == null || x.Scene == scene) &&
|
||
(!input.EnabledOnly || x.Enabled));
|
||
var ordered = items.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToList();
|
||
return new OmsItemsResult<AiSkillOutput>
|
||
{
|
||
Items = ordered.Select(x => Map(x, input.IncludeContent)).ToList()
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按 Id 或 SkillKey 获取 Skill(含正文)。
|
||
/// </summary>
|
||
[HttpPost("/ai/skill/get")]
|
||
public async Task<AiSkillOutput> GetAsync(AiSkillGetInput input)
|
||
{
|
||
await EnsureBuiltinSkillsAsync();
|
||
AiSkill? entity = null;
|
||
if (input.Id is > 0)
|
||
{
|
||
entity = await AiSkillRepository.FirstOrDefaultAsync(x => x.Id == input.Id.Value);
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(input.SkillKey))
|
||
{
|
||
entity = await AiSkillRepository.GetBySkillKeyAsync(input.SkillKey.Trim());
|
||
}
|
||
|
||
if (entity == null)
|
||
{
|
||
throw new BusinessException("Skill 不存在");
|
||
}
|
||
|
||
return Map(entity, includeContent: true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新增或更新 Skill。
|
||
/// </summary>
|
||
[HttpPost("/ai/skill/save")]
|
||
public async Task<AiSkillOutput> SaveAsync(AiSkillSaveInput input)
|
||
{
|
||
var skillKey = input.SkillKey.Trim();
|
||
var scene = input.Scene.Trim();
|
||
if (string.IsNullOrWhiteSpace(skillKey) || string.IsNullOrWhiteSpace(scene))
|
||
{
|
||
throw new BusinessException("SkillKey / Scene 不能为空");
|
||
}
|
||
|
||
AiSkill? entity = null;
|
||
if (input.Id is > 0)
|
||
{
|
||
entity = await AiSkillRepository.FirstOrDefaultAsync(x => x.Id == input.Id.Value);
|
||
}
|
||
|
||
entity ??= await AiSkillRepository.GetBySkillKeyAsync(skillKey);
|
||
|
||
if (entity == null)
|
||
{
|
||
entity = new AiSkill { SkillKey = skillKey };
|
||
}
|
||
else if (!string.Equals(entity.SkillKey, skillKey, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var conflict = await AiSkillRepository.GetBySkillKeyAsync(skillKey);
|
||
if (conflict != null && conflict.Id != entity.Id)
|
||
{
|
||
throw new BusinessException($"SkillKey 已存在:{skillKey}");
|
||
}
|
||
|
||
entity.SkillKey = skillKey;
|
||
}
|
||
|
||
entity.Scene = scene;
|
||
entity.Name = input.Name.Trim();
|
||
entity.Description = NormalizeOptional(input.Description);
|
||
entity.Content = input.Content ?? string.Empty;
|
||
entity.SortOrder = input.SortOrder;
|
||
entity.Enabled = input.Enabled;
|
||
|
||
if (entity.Id > 0)
|
||
{
|
||
await AiSkillRepository.UpdateAsync(entity);
|
||
}
|
||
else
|
||
{
|
||
await AiSkillRepository.InsertAsync(entity);
|
||
}
|
||
|
||
return Map(entity, includeContent: true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 软删除 Skill。
|
||
/// </summary>
|
||
[HttpPost("/ai/skill/delete")]
|
||
public async Task DeleteAsync(AiSkillDeleteInput input)
|
||
{
|
||
var entity = await AiSkillRepository.FirstOrDefaultAsync(x => x.Id == input.Id);
|
||
if (entity == null)
|
||
{
|
||
throw new BusinessException("Skill 不存在");
|
||
}
|
||
|
||
await AiSkillRepository.DeleteAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 供内部调用:按 key 取正文,不存在返回 null。
|
||
/// </summary>
|
||
public async Task<string?> TryGetContentByKeyAsync(string skillKey)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(skillKey)) return null;
|
||
await EnsureBuiltinSkillsAsync();
|
||
var entity = await AiSkillRepository.GetBySkillKeyAsync(skillKey.Trim());
|
||
if (entity == null || !entity.Enabled || string.IsNullOrWhiteSpace(entity.Content))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return entity.Content;
|
||
}
|
||
|
||
private async Task EnsureBuiltinSkillsAsync()
|
||
{
|
||
var seeds = LoadSeedDefinitions();
|
||
foreach (var seed in seeds)
|
||
{
|
||
var existing = await AiSkillRepository.GetBySkillKeyAsync(seed.SkillKey);
|
||
if (existing != null) continue;
|
||
|
||
await AiSkillRepository.InsertAsync(new AiSkill
|
||
{
|
||
SkillKey = seed.SkillKey,
|
||
Scene = seed.Scene,
|
||
Name = seed.Name,
|
||
Description = seed.Description,
|
||
Content = seed.Content,
|
||
SortOrder = seed.SortOrder,
|
||
Enabled = true
|
||
});
|
||
}
|
||
}
|
||
|
||
private static List<SeedDef> LoadSeedDefinitions()
|
||
{
|
||
var asm = typeof(AiSkillService).Assembly;
|
||
var resourceName = asm.GetManifestResourceNames()
|
||
.FirstOrDefault(n => n.EndsWith("AiSkillSeedContent.json", StringComparison.OrdinalIgnoreCase));
|
||
if (resourceName == null)
|
||
{
|
||
throw new InvalidOperationException("未找到嵌入资源 AiSkillSeedContent.json");
|
||
}
|
||
|
||
using var stream = asm.GetManifestResourceStream(resourceName)!;
|
||
using var reader = new StreamReader(stream);
|
||
var json = reader.ReadToEnd();
|
||
var payload = JsonSerializer.Deserialize<SeedPayload>(json, new JsonSerializerOptions
|
||
{
|
||
PropertyNameCaseInsensitive = true
|
||
})
|
||
?? throw new InvalidOperationException("AiSkillSeedContent.json 解析失败");
|
||
|
||
return
|
||
[
|
||
new SeedDef(KeyPricePublish, ScenePricePublish, "销售产品价格发布风险",
|
||
"对比线上生效价与待发布价,输出中文风险报告", payload.Price, 10),
|
||
new SeedDef(KeyAutoTriggerFormula, SceneAutoTriggerFormula, "自动触发公式助手",
|
||
"根据中文描述生成可粘贴的自动触发公式(字段目录由前端追加)", payload.Formula, 20),
|
||
new SeedDef(KeyBillingConditionFormula, SceneBillingConditionFormula, "计费条件公式助手",
|
||
"根据中文描述生成价格项计费条件公式(字段目录由前端追加)", payload.BillingCondition, 25),
|
||
new SeedDef(KeyReceiverAddressParse, SceneReceiverAddressParse, "收件地址 AI 解析",
|
||
"解析粘贴的收件地址为结构化字段并做粗校验", payload.Address, 30),
|
||
new SeedDef(KeyItfxPrefixScript, SceneItfxPrefixScript, "接口通前置脚本",
|
||
"按用户需求生成 Natasha C# 前置签名脚本", payload.Itfx, 40),
|
||
new SeedDef(KeyItfxCreateOrderMap, SceneItfxParameterMap, "创建订单自动映射",
|
||
"按订单基本信息/收件人/发件人/申报信息四模块初步匹配上下游参数", payload.ParameterMap, 50),
|
||
];
|
||
}
|
||
|
||
private static AiSkillOutput Map(AiSkill entity, bool includeContent) => new()
|
||
{
|
||
Id = entity.Id,
|
||
SkillKey = entity.SkillKey,
|
||
Scene = entity.Scene,
|
||
Name = entity.Name,
|
||
Description = entity.Description,
|
||
Content = includeContent ? entity.Content : string.Empty,
|
||
SortOrder = entity.SortOrder,
|
||
Enabled = entity.Enabled
|
||
};
|
||
|
||
private static string? NormalizeOptional(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
private sealed record SeedDef(
|
||
string SkillKey,
|
||
string Scene,
|
||
string Name,
|
||
string Description,
|
||
string Content,
|
||
int SortOrder);
|
||
|
||
private sealed class SeedPayload
|
||
{
|
||
public string Price { get; set; } = string.Empty;
|
||
public string Formula { get; set; } = string.Empty;
|
||
public string BillingCondition { get; set; } = string.Empty;
|
||
public string Itfx { get; set; } = string.Empty;
|
||
public string Address { get; set; } = string.Empty;
|
||
public string ParameterMap { get; set; } = string.Empty;
|
||
}
|
||
}
|