151 lines
6.0 KiB
C#
151 lines
6.0 KiB
C#
using Quartz;
|
||
using Service.AI;
|
||
|
||
namespace Service.Jobs;
|
||
|
||
/// <summary>
|
||
/// 分类图标生成定时任务
|
||
/// 每10分钟扫描一次,为没有图标的分类生成 5 个 SVG 图标
|
||
/// </summary>
|
||
public class CategoryIconGenerationJob(
|
||
ITransactionCategoryRepository categoryRepository,
|
||
IOpenAiService openAiService,
|
||
ILogger<CategoryIconGenerationJob> logger) : IJob
|
||
{
|
||
private static readonly SemaphoreSlim _semaphore = new(1, 1);
|
||
|
||
public async Task Execute(IJobExecutionContext context)
|
||
{
|
||
// 尝试获取锁,如果失败则跳过本次执行
|
||
if (!await _semaphore.WaitAsync(0))
|
||
{
|
||
logger.LogInformation("上一个分类图标生成任务尚未完成,跳过本次执行");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
|
||
logger.LogInformation("开始执行分类图标生成任务");
|
||
|
||
// 查询所有分类,然后过滤出没有图标的
|
||
var allCategories = await categoryRepository.GetAllAsync();
|
||
var categoriesWithoutIcon = allCategories
|
||
.Where(c => string.IsNullOrEmpty(c.Icon))
|
||
.ToList();
|
||
|
||
if (categoriesWithoutIcon.Count == 0)
|
||
{
|
||
logger.LogInformation("所有分类都已有图标,跳过本次任务");
|
||
return;
|
||
}
|
||
|
||
logger.LogInformation("发现 {Count} 个分类没有图标,开始生成", categoriesWithoutIcon.Count);
|
||
|
||
// 为每个分类生成图标
|
||
foreach (var category in categoriesWithoutIcon)
|
||
{
|
||
try
|
||
{
|
||
await GenerateIconsForCategoryAsync(category);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogError(ex, "为分类 {CategoryName}(ID:{CategoryId}) 生成图标失败",
|
||
category.Name, category.Id);
|
||
}
|
||
}
|
||
|
||
logger.LogInformation("分类图标生成任务执行完成");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogError(ex, "分类图标生成任务执行出错");
|
||
throw;
|
||
}
|
||
finally
|
||
{
|
||
_semaphore.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 为单个分类生成 5 个 SVG 图标
|
||
/// </summary>
|
||
private async Task GenerateIconsForCategoryAsync(TransactionCategory category)
|
||
{
|
||
logger.LogInformation("正在为分类 {CategoryName}(ID:{CategoryId}) 生成图标",
|
||
category.Name, category.Id);
|
||
|
||
var typeText = category.Type == TransactionType.Expense ? "支出" : "收入";
|
||
|
||
var systemPrompt = """
|
||
你是一个专业的 SVG 图标设计师,擅长创作精美、富有表现力的图标。
|
||
请根据分类名称和类型,生成 5 个风格迥异、视觉效果突出的 SVG 图标。
|
||
|
||
设计要求:
|
||
1. 尺寸:24x24,viewBox="0 0 24 24"
|
||
2. 色彩:使用丰富的渐变色和多色搭配,让图标更有吸引力和辨识度
|
||
- 可以使用 <linearGradient> 或 <radialGradient> 创建渐变效果
|
||
- 不同元素使用不同颜色,增加层次感
|
||
- 根据分类含义选择合适的配色方案(如餐饮用暖色系、交通用蓝色系等)
|
||
3. 设计风格:5 个图标必须风格明显不同,避免雷同
|
||
- 第1个:扁平化风格,色彩鲜明,使用渐变
|
||
- 第2个:线性风格,多色描边,细节丰富
|
||
- 第3个:3D立体风格,使用阴影和高光效果
|
||
- 第4个:卡通可爱风格,圆润造型,活泼配色
|
||
- 第5个:现代简约风格,几何与曲线结合,优雅配色
|
||
4. 细节丰富:不要只用简单的几何图形,添加特征性的细节元素
|
||
- 例如:餐饮可以加刀叉、蒸汽、食材纹理等
|
||
- 交通可以加轮胎、车窗、尾气等
|
||
- 每个图标要有独特的视觉记忆点
|
||
5. 图标要直观表达分类含义,让人一眼就能识别
|
||
6. 只返回 JSON 数组格式,包含 5 个完整的 SVG 字符串,不要有任何其他文字说明
|
||
|
||
重要:每个 SVG 必须是自包含的完整代码,包含所有必要的 gradient 定义。
|
||
""";
|
||
|
||
var userPrompt = $"""
|
||
分类名称:{category.Name}
|
||
分类类型:{typeText}
|
||
|
||
请为这个分类生成 5 个精美的、风格各异的彩色 SVG 图标。
|
||
确保每个图标都有独特的视觉特征,不会与其他图标混淆。
|
||
|
||
返回格式(纯 JSON 数组,无其他内容):
|
||
["<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>"]
|
||
""";
|
||
|
||
var response = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 60 * 10);
|
||
|
||
if (string.IsNullOrWhiteSpace(response))
|
||
{
|
||
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", category.Name);
|
||
return;
|
||
}
|
||
|
||
// 验证返回的是有效的 JSON 数组
|
||
try
|
||
{
|
||
var icons = JsonSerializer.Deserialize<List<string>>(response);
|
||
if (icons == null || icons.Count != 5)
|
||
{
|
||
logger.LogWarning("AI 返回的图标数量不正确(期望5个),分类: {CategoryName}", category.Name);
|
||
return;
|
||
}
|
||
|
||
// 保存图标到数据库
|
||
category.Icon = response;
|
||
await categoryRepository.UpdateAsync(category);
|
||
|
||
logger.LogInformation("成功为分类 {CategoryName}(ID:{CategoryId}) 生成并保存了 5 个图标",
|
||
category.Name, category.Id);
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
|
||
category.Name, response);
|
||
}
|
||
}
|
||
}
|