重构: 将 LogCleanupService 转为 Quartz Job 服务
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

- 创建 LogCleanupJob 替代 LogCleanupService (BackgroundService)
- 在 Expand.cs 中注册 LogCleanupJob (每天凌晨2点执行, 保留30天日志)
- 从 Program.cs 移除 LogCleanupService 的 HostedService 注册
- 删除 Service/LogCleanupService.cs
- 删除 Service/PeriodicBillBackgroundService.cs (已无用的重复服务)

所有后台任务现在统一通过 Quartz.NET 管理, 支持运行时控制
This commit is contained in:
SunCheng
2026-01-28 11:19:23 +08:00
parent b71eadd4f9
commit 3ed9cf5ebd
26 changed files with 133 additions and 183 deletions

273
Service/AI/OpenAiService.cs Normal file
View File

@@ -0,0 +1,273 @@
using System.Net.Http.Headers;
namespace Service.AI;
public interface IOpenAiService
{
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
Task<string?> ChatAsync(string prompt);
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
IAsyncEnumerable<string> ChatStreamAsync(string prompt);
}
public class OpenAiService(
IOptions<AiSettings> aiSettings,
ILogger<OpenAiService> logger
) : IOpenAiService
{
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt)
{
var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model))
{
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
return null;
}
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(15);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new
{
model = cfg.Model,
temperature = 0,
messages = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
using var resp = await http.PostAsync(url, content);
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync();
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
}
var respText = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(respText);
var root = doc.RootElement;
var contentText = root.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
return contentText;
}
catch (Exception ex)
{
logger.LogError(ex, "AI 调用失败");
throw;
}
}
public async Task<string?> ChatAsync(string prompt)
{
var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model))
{
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
return null;
}
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(60 * 5);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new
{
model = cfg.Model,
temperature = 0,
messages = new object[]
{
new { role = "user", content = prompt }
}
};
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
using var resp = await http.PostAsync(url, content);
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync();
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
}
var respText = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(respText);
var root = doc.RootElement;
var contentText = root.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
return contentText;
}
catch (Exception ex)
{
logger.LogError(ex, "AI 调用失败");
throw;
}
}
public async IAsyncEnumerable<string> ChatStreamAsync(string prompt)
{
var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model))
{
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
yield break;
}
using var http = new HttpClient();
http.Timeout = TimeSpan.FromMinutes(5);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new
{
model = cfg.Model,
temperature = 0,
stream = true,
messages = new object[]
{
new { role = "user", content = prompt }
}
};
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = content;
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync();
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
}
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
continue;
var data = line.Substring(6).Trim();
if (data == "[DONE]")
break;
using var doc = JsonDocument.Parse(data);
var root = doc.RootElement;
if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
{
var delta = choices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var contentProp))
{
var contentText = contentProp.GetString();
if (!string.IsNullOrEmpty(contentText))
{
yield return contentText;
}
}
}
}
}
public async IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt)
{
var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model))
{
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
yield break;
}
using var http = new HttpClient();
http.Timeout = TimeSpan.FromMinutes(5);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new
{
model = cfg.Model,
temperature = 0,
stream = true,
messages = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
// 使用 SendAsync 来支持 HttpCompletionOption
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = content;
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync();
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
}
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
continue;
var data = line.Substring(6).Trim();
if (data == "[DONE]")
break;
// 解析JSON时不使用try-catch因为在async iterator中不能使用
using var doc = JsonDocument.Parse(data);
var root = doc.RootElement;
if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
{
var delta = choices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var contentProp))
{
var contentText = contentProp.GetString();
if (!string.IsNullOrEmpty(contentText))
{
yield return contentText;
}
}
}
}
}
}