admin/src/Oms.Application/Services/Ai/AiSkillService.cs
zzh 654a65aba4 feat: workplace claimed stats and receiver address parse improvements
Add claimed count to workplace stats; refine receiver address parse DTOs/helpers and AI skill seed wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:06:49 +08:00

252 lines
9.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}