This commit is contained in:
SunCheng
2026-02-02 11:07:34 +08:00
parent 61916dc6da
commit 460dcd17ef
9 changed files with 9695 additions and 4967 deletions

View File

@@ -4,8 +4,8 @@ namespace Service.AI;
public interface IOpenAiService
{
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
Task<string?> ChatAsync(string prompt);
Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15);
Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15);
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
IAsyncEnumerable<string> ChatStreamAsync(string prompt);
}
@@ -15,7 +15,7 @@ public class OpenAiService(
ILogger<OpenAiService> logger
) : IOpenAiService
{
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt)
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt, int timeoutSeconds = 15)
{
var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
@@ -27,7 +27,7 @@ public class OpenAiService(
}
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(15);
http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new
@@ -72,7 +72,7 @@ public class OpenAiService(
}
}
public async Task<string?> ChatAsync(string prompt)
public async Task<string?> ChatAsync(string prompt, int timeoutSeconds = 15)
{
var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
@@ -84,7 +84,7 @@ public class OpenAiService(
}
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(60 * 5);
http.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new

View File

@@ -0,0 +1,150 @@
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. 24x24viewBox="0 0 24 24"
2. 使
- 使 <linearGradient> <radialGradient>
- 使
-
3. 5
- 1使
- 2线
- 33D使
- 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);
}
}
}