diff --git a/Repository/TransactionRecordRepository.cs b/Repository/TransactionRecordRepository.cs index 96216cc..94abdd2 100644 --- a/Repository/TransactionRecordRepository.cs +++ b/Repository/TransactionRecordRepository.cs @@ -60,6 +60,19 @@ public interface ITransactionRecordRepository : IBaseRepository邮件ID /// 交易记录列表 Task> GetByEmailIdAsync(long emailMessageId); + + /// + /// 获取未分类的账单数量 + /// + /// 未分类账单数量 + Task GetUnclassifiedCountAsync(); + + /// + /// 获取未分类的账单列表 + /// + /// 每页数量 + /// 未分类账单列表 + Task> GetUnclassifiedAsync(int pageSize = 10); } public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository(freeSql), ITransactionRecordRepository @@ -180,4 +193,20 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.OccurredAt) .ToListAsync(); } + + public async Task GetUnclassifiedCountAsync() + { + return (int)await FreeSql.Select() + .Where(t => string.IsNullOrEmpty(t.Classify)) + .CountAsync(); + } + + public async Task> GetUnclassifiedAsync(int pageSize = 10) + { + return await FreeSql.Select() + .Where(t => string.IsNullOrEmpty(t.Classify)) + .OrderByDescending(t => t.OccurredAt) + .Page(1, pageSize) + .ToListAsync(); + } } \ No newline at end of file diff --git a/Service/EmailBackgroundService.cs b/Service/EmailBackgroundService.cs index 0314f37..2c94137 100644 --- a/Service/EmailBackgroundService.cs +++ b/Service/EmailBackgroundService.cs @@ -197,8 +197,12 @@ public class EmailBackgroundService( message.TextBody ?? message.HtmlBody ?? string.Empty )) { + #if DEBUG + logger.LogDebug("DEBUG 模式下,跳过标记已读步骤"); + #else // 标记邮件为已读 await emailFetchService.MarkAsReadAsync(uid); + #endif } } catch (Exception ex) diff --git a/Service/OpenAiService.cs b/Service/OpenAiService.cs index 8549cbb..6ceac2f 100644 --- a/Service/OpenAiService.cs +++ b/Service/OpenAiService.cs @@ -5,6 +5,7 @@ namespace Service; public interface IOpenAiService { Task ChatAsync(string systemPrompt, string userPrompt); + IAsyncEnumerable ChatStreamAsync(string systemPrompt, string userPrompt); } public class OpenAiService( @@ -68,4 +69,79 @@ public class OpenAiService( throw; } } + + public async IAsyncEnumerable 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) + { + 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; + } + } + } + } + } } diff --git a/Web/src/api/transactionRecord.js b/Web/src/api/transactionRecord.js index 990dd84..1e716b3 100644 --- a/Web/src/api/transactionRecord.js +++ b/Web/src/api/transactionRecord.js @@ -100,3 +100,63 @@ export const getTransactionsByDate = (date) => { // 注意:分类相关的API已迁移到 transactionCategory.js // 请使用 getCategoryTree 等新接口 + +/** + * 获取未分类的账单数量 + * @returns {Promise<{success: boolean, data: number}>} + */ +export const getUnclassifiedCount = () => { + return request({ + url: '/TransactionRecord/GetUnclassifiedCount', + method: 'get' + }) +} + +/** + * 获取未分类的账单列表 + * @param {number} pageSize - 每页数量,默认10条 + * @returns {Promise<{success: boolean, data: Array}>} + */ +export const getUnclassified = (pageSize = 10) => { + return request({ + url: '/TransactionRecord/GetUnclassified', + method: 'get', + params: { pageSize } + }) +} + +/** + * 智能分类 - 使用AI对账单进行分类(EventSource流式响应) + * @param {number} pageSize - 每次分类的账单数量 + * @returns {EventSource} 返回EventSource对象用于接收流式数据 + */ +export const smartClassify = (pageSize = 10) => { + const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000' + const token = localStorage.getItem('token') + const url = `${baseURL}/api/TransactionRecord/SmartClassify` + + return fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ pageSize }) + }) +} + +/** + * 批量更新账单分类 + * @param {Array} items - 要更新的账单分类数据数组 + * @param {number} items[].id - 账单ID + * @param {string} items[].classify - 一级分类 + * @param {string} items[].subClassify - 子分类 + * @returns {Promise<{success: boolean, message: string}>} + */ +export const batchUpdateClassify = (items) => { + return request({ + url: '/TransactionRecord/BatchUpdateClassify', + method: 'post', + data: items + }) +} diff --git a/Web/src/router/index.js b/Web/src/router/index.js index 6f37a71..1660694 100644 --- a/Web/src/router/index.js +++ b/Web/src/router/index.js @@ -34,6 +34,12 @@ const router = createRouter({ component: () => import('../views/CalendarView.vue'), meta: { requiresAuth: true }, }, + { + path: '/smart-classification', + name: 'smart-classification', + component: () => import('../views/SmartClassification.vue'), + meta: { requiresAuth: true }, + } ], }) diff --git a/Web/src/views/SettingView.vue b/Web/src/views/SettingView.vue index 0d3b0a6..6a22858 100644 --- a/Web/src/views/SettingView.vue +++ b/Web/src/views/SettingView.vue @@ -16,7 +16,7 @@

账单处理

- +
@@ -101,6 +101,10 @@ const handleFileChange = async (event) => { } } +const handleSmartClassification = () => { + router.push({ name: 'smart-classification' }) +} + /** * 处理退出登录 */ diff --git a/Web/src/views/SmartClassification.vue b/Web/src/views/SmartClassification.vue new file mode 100644 index 0000000..3ece848 --- /dev/null +++ b/Web/src/views/SmartClassification.vue @@ -0,0 +1,335 @@ + + + + + \ No newline at end of file diff --git a/WebApi/Controllers/TransactionRecordController.cs b/WebApi/Controllers/TransactionRecordController.cs index 40bca13..06252a1 100644 --- a/WebApi/Controllers/TransactionRecordController.cs +++ b/WebApi/Controllers/TransactionRecordController.cs @@ -4,6 +4,8 @@ [Route("api/[controller]/[action]")] public class TransactionRecordController( ITransactionRecordRepository transactionRepository, + ITransactionCategoryRepository categoryRepository, + IOpenAiService openAiService, ILogger logger ) : ControllerBase { @@ -264,6 +266,187 @@ public class TransactionRecordController( } } + /// + /// 获取未分类的账单数量 + /// + [HttpGet] + public async Task> GetUnclassifiedCountAsync() + { + try + { + var count = await transactionRepository.GetUnclassifiedCountAsync(); + return new BaseResponse + { + Success = true, + Data = count + }; + } + catch (Exception ex) + { + logger.LogError(ex, "获取未分类账单数量失败"); + return BaseResponse.Fail($"获取未分类账单数量失败: {ex.Message}"); + } + } + + /// + /// 获取未分类的账单列表 + /// + [HttpGet] + public async Task>> GetUnclassifiedAsync([FromQuery] int pageSize = 10) + { + try + { + var records = await transactionRepository.GetUnclassifiedAsync(pageSize); + return new BaseResponse> + { + Success = true, + Data = records + }; + } + catch (Exception ex) + { + logger.LogError(ex, "获取未分类账单列表失败"); + return BaseResponse>.Fail($"获取未分类账单列表失败: {ex.Message}"); + } + } + + /// + /// 智能分类 - 使用AI对账单进行分类(流式响应) + /// + [HttpPost] + public async Task SmartClassifyAsync([FromBody] SmartClassifyRequest request) + { + Response.ContentType = "text/event-stream"; + Response.Headers.Append("Cache-Control", "no-cache"); + Response.Headers.Append("Connection", "keep-alive"); + + try + { + // 获取要分类的账单 + var records = await transactionRepository.GetUnclassifiedAsync(request.PageSize); + if (records.Count == 0) + { + await WriteEventAsync("error", "没有需要分类的账单"); + return; + } + + // 获取所有分类 + var categories = await categoryRepository.GetAllAsync(); + + // 构建分类信息 + var categoryInfo = string.Join("\n", categories + .Select(c => + { + var children = categories.Where(x => x.ParentId == c.Id).ToList(); + var childrenStr = children.Count > 0 + ? $",子分类:{string.Join("、", children.Select(x => x.Name))}" + : ""; + return $"- {c.Name} ({(c.Type == TransactionType.Expense ? "支出" : "收入")}){childrenStr}"; + })); + + // 构建账单信息 + var billsInfo = string.Join("\n", records.Select((r, i) => + $"{i + 1}. ID={r.Id}, 摘要={r.Reason}, 金额={r.Amount}, 类型={GetTypeName(r.Type)}")); + + var systemPrompt = $@"你是一个专业的账单分类助手。请根据提供的账单信息和分类列表,为每个账单选择最合适的分类。 + +可用的分类列表: +{categoryInfo} + +分类规则: +1. 根据账单的摘要和金额,选择最匹配的一级分类 +2. 如果有合适的子分类,也要指定子分类 +3. 如果无法确定分类,可以选择""其他"" + +请对每个账单进行分类,每次输出一个账单的分类结果,格式如下: +{{""id"": 账单ID, ""classify"": ""一级分类"", ""subClassify"": ""子分类""}} + +只输出JSON,不要有其他文字说明。"; + + var userPrompt = $@"请为以下账单进行分类: + +{billsInfo} + +请逐个输出分类结果。"; + + // 流式调用AI + await WriteEventAsync("start", $"开始分类 {records.Count} 条账单"); + + await foreach (var chunk in openAiService.ChatStreamAsync(systemPrompt, userPrompt)) + { + await WriteEventAsync("data", chunk); + } + + await WriteEventAsync("end", "分类完成"); + } + catch (Exception ex) + { + logger.LogError(ex, "智能分类失败"); + await WriteEventAsync("error", $"智能分类失败: {ex.Message}"); + } + } + + /// + /// 批量更新账单分类 + /// + [HttpPost] + public async Task BatchUpdateClassifyAsync([FromBody] List items) + { + try + { + var successCount = 0; + var failCount = 0; + + foreach (var item in items) + { + var record = await transactionRepository.GetByIdAsync(item.Id); + if (record != null) + { + record.Classify = item.Classify ?? string.Empty; + record.SubClassify = item.SubClassify ?? string.Empty; + var success = await transactionRepository.UpdateAsync(record); + if (success) + successCount++; + else + failCount++; + } + else + { + failCount++; + } + } + + return new BaseResponse + { + Success = true, + Message = $"批量更新完成,成功 {successCount} 条,失败 {failCount} 条" + }; + } + catch (Exception ex) + { + logger.LogError(ex, "批量更新分类失败"); + return BaseResponse.Fail($"批量更新分类失败: {ex.Message}"); + } + } + + private async Task WriteEventAsync(string eventType, string data) + { + var message = $"event: {eventType}\ndata: {data}\n\n"; + await Response.WriteAsync(message); + await Response.Body.FlushAsync(); + } + + private static string GetTypeName(TransactionType type) + { + return type switch + { + TransactionType.Expense => "支出", + TransactionType.Income => "收入", + TransactionType.None => "不计入收支", + _ => "未知" + }; + } + } @@ -299,4 +482,20 @@ public record DailyStatisticsDto( string Date, int Count, decimal Amount +); + +/// +/// 智能分类请求DTO +/// +public record SmartClassifyRequest( + int PageSize = 10 +); + +/// +/// 批量更新分类项DTO +/// +public record BatchUpdateClassifyItem( + long Id, + string? Classify, + string? SubClassify ); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 6a0e51c..c6da455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,6 @@ restart: always networks: - all_in - ports: - - 14904:8080 environment: - ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_URLS=http://+:8080