diff --git a/Repository/TransactionRecordRepository.cs b/Repository/TransactionRecordRepository.cs index 535d3db..07479ff 100644 --- a/Repository/TransactionRecordRepository.cs +++ b/Repository/TransactionRecordRepository.cs @@ -49,6 +49,32 @@ public interface ITransactionRecordRepository : IBaseRepository交易记录数量 Task GetCountByEmailIdAsync(long emailMessageId); + /// + /// 获取月度统计数据 + /// + /// 年份 + /// 月份 + /// 月度统计数据 + Task GetMonthlyStatisticsAsync(int year, int month); + + /// + /// 获取分类统计数据 + /// + /// 年份 + /// 月份 + /// 交易类型(0:支出, 1:收入) + /// 分类统计列表 + Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type); + + /// + /// 获取多个月的趋势统计数据 + /// + /// 开始年份 + /// 开始月份 + /// 月份数量 + /// 趋势统计列表 + Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount); + /// /// 获取指定邮件的交易记录列表 /// @@ -91,7 +117,21 @@ public interface ITransactionRecordRepository : IBaseRepository /// 关键词 /// 匹配的交易记录列表 - Task> QueryBySqlAsync(string sql); + Task> QueryByWhereAsync(string sql); + + /// + /// 执行完整的SQL查询 + /// + /// 完整的SELECT SQL语句 + /// 查询结果列表 + Task> ExecuteRawSqlAsync(string completeSql); + + /// + /// 执行动态SQL查询,返回动态对象 + /// + /// 完整的SELECT SQL语句 + /// 动态查询结果列表 + Task> ExecuteDynamicSqlAsync(string completeSql); } public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository(freeSql), ITransactionRecordRepository @@ -275,13 +315,151 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository> QueryBySqlAsync(string sql) + public async Task> QueryByWhereAsync(string sql) { return await FreeSql.Select() .Where(sql) .OrderByDescending(t => t.OccurredAt) .ToListAsync(); } + public async Task> ExecuteRawSqlAsync(string completeSql) + { + return await FreeSql.Ado.QueryAsync(completeSql); + } + + public async Task> ExecuteDynamicSqlAsync(string completeSql) + { + var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql); + var result = new List(); + + foreach (System.Data.DataRow row in dt.Rows) + { + var expando = new System.Dynamic.ExpandoObject() as IDictionary; + foreach (System.Data.DataColumn column in dt.Columns) + { + expando[column.ColumnName] = row[column]; + } + result.Add(expando); + } + + return result; + } + public async Task GetMonthlyStatisticsAsync(int year, int month) + { + var startDate = new DateTime(year, month, 1); + var endDate = startDate.AddMonths(1); + + var records = await FreeSql.Select() + .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) + .ToListAsync(); + + var statistics = new MonthlyStatistics + { + Year = year, + Month = month + }; + + foreach (var record in records) + { + var amount = Math.Abs(record.Amount); + + if (record.Type == TransactionType.Expense) + { + statistics.TotalExpense += amount; + statistics.ExpenseCount++; + if (amount > statistics.MaxExpense) + { + statistics.MaxExpense = amount; + } + } + else if (record.Type == TransactionType.Income) + { + statistics.TotalIncome += amount; + statistics.IncomeCount++; + if (amount > statistics.MaxIncome) + { + statistics.MaxIncome = amount; + } + } + } + + statistics.Balance = statistics.TotalIncome - statistics.TotalExpense; + statistics.TotalCount = records.Count; + + return statistics; + } + + public async Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type) + { + var startDate = new DateTime(year, month, 1); + var endDate = startDate.AddMonths(1); + + var records = await FreeSql.Select() + .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate && t.Type == type) + .ToListAsync(); + + var categoryGroups = records + .GroupBy(t => t.Classify ?? "未分类") + .Select(g => new CategoryStatistics + { + Classify = g.Key, + Amount = g.Sum(t => Math.Abs(t.Amount)), + Count = g.Count() + }) + .OrderByDescending(c => c.Amount) + .ToList(); + + // 计算百分比 + var total = categoryGroups.Sum(c => c.Amount); + if (total > 0) + { + foreach (var category in categoryGroups) + { + category.Percent = Math.Round((category.Amount / total) * 100, 1); + } + } + + return categoryGroups; + } + + public async Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount) + { + var trends = new List(); + + for (int i = 0; i < monthCount; i++) + { + var targetYear = startYear; + var targetMonth = startMonth + i; + + // 处理月份溢出 + while (targetMonth > 12) + { + targetMonth -= 12; + targetYear++; + } + + var startDate = new DateTime(targetYear, targetMonth, 1); + var endDate = startDate.AddMonths(1); + + var records = await FreeSql.Select() + .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) + .ToListAsync(); + + var expense = records.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); + var income = records.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); + + trends.Add(new TrendStatistics + { + Year = targetYear, + Month = targetMonth, + Expense = expense, + Income = income, + Balance = income - expense + }); + } + + return trends; + } } /// @@ -308,4 +486,44 @@ public class ReasonGroupDto /// 示例分类(该分组中第一条记录的分类) /// public string SampleClassify { get; set; } = string.Empty; +} + +/// +/// 月度统计数据 +/// +public class MonthlyStatistics +{ + public int Year { get; set; } + public int Month { get; set; } + public decimal TotalExpense { get; set; } + public decimal TotalIncome { get; set; } + public decimal Balance { get; set; } + public int ExpenseCount { get; set; } + public int IncomeCount { get; set; } + public int TotalCount { get; set; } + public decimal MaxExpense { get; set; } + public decimal MaxIncome { get; set; } +} + +/// +/// 分类统计数据 +/// +public class CategoryStatistics +{ + public string Classify { get; set; } = string.Empty; + public decimal Amount { get; set; } + public int Count { get; set; } + public decimal Percent { get; set; } +} + +/// +/// 趋势统计数据 +/// +public class TrendStatistics +{ + public int Year { get; set; } + public int Month { get; set; } + public decimal Expense { get; set; } + public decimal Income { get; set; } + public decimal Balance { get; set; } } \ No newline at end of file diff --git a/Service/OpenAiService.cs b/Service/OpenAiService.cs index 6ceac2f..755b2ea 100644 --- a/Service/OpenAiService.cs +++ b/Service/OpenAiService.cs @@ -5,7 +5,9 @@ namespace Service; public interface IOpenAiService { Task ChatAsync(string systemPrompt, string userPrompt); + Task ChatAsync(string prompt); IAsyncEnumerable ChatStreamAsync(string systemPrompt, string userPrompt); + IAsyncEnumerable ChatStreamAsync(string prompt); } public class OpenAiService( @@ -70,6 +72,134 @@ public class OpenAiService( } } + public async Task 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(30); + 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 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) + { + 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 ChatStreamAsync(string systemPrompt, string userPrompt) { var cfg = aiSettings.Value; diff --git a/Web/src/api/statistics.js b/Web/src/api/statistics.js new file mode 100644 index 0000000..37447ae --- /dev/null +++ b/Web/src/api/statistics.js @@ -0,0 +1,90 @@ +import request from './request' + +/** + * 统计相关 API + * 注:统计接口定义在 TransactionRecordController 中 + */ + +/** + * 获取月度统计数据 + * @param {Object} params - 查询参数 + * @param {number} params.year - 年份 + * @param {number} params.month - 月份 + * @returns {Promise<{success: boolean, data: Object}>} + * @returns {Object} data.totalExpense - 总支出 + * @returns {Object} data.totalIncome - 总收入 + * @returns {Object} data.balance - 结余 + * @returns {Object} data.expenseCount - 支出笔数 + * @returns {Object} data.incomeCount - 收入笔数 + * @returns {Object} data.totalCount - 总笔数 + * @returns {Object} data.maxExpense - 最大单笔支出 + * @returns {Object} data.maxIncome - 最大单笔收入 + */ +export const getMonthlyStatistics = (params) => { + return request({ + url: '/TransactionRecord/GetMonthlyStatistics', + method: 'get', + params + }) +} + +/** + * 获取分类统计数据 + * @param {Object} params - 查询参数 + * @param {number} params.year - 年份 + * @param {number} params.month - 月份 + * @param {number} params.type - 交易类型 (0:支出, 1:收入, 2:不计入收支) + * @returns {Promise<{success: boolean, data: Array}>} + * @returns {Array} data - 分类统计列表 + * @returns {string} data[].classify - 分类名称 + * @returns {number} data[].amount - 金额 + * @returns {number} data[].percent - 百分比 + * @returns {number} data[].count - 交易笔数 + */ +export const getCategoryStatistics = (params) => { + return request({ + url: '/TransactionRecord/GetCategoryStatistics', + method: 'get', + params + }) +} + +/** + * 获取趋势统计数据 + * @param {Object} params - 查询参数 + * @param {number} params.startYear - 开始年份 + * @param {number} params.startMonth - 开始月份 + * @param {number} [params.monthCount=6] - 月份数量,默认6个月 + * @returns {Promise<{success: boolean, data: Array}>} + * @returns {Array} data - 趋势统计列表 + * @returns {number} data[].year - 年份 + * @returns {number} data[].month - 月份 + * @returns {number} data[].expense - 支出金额 + * @returns {number} data[].income - 收入金额 + */ +export const getTrendStatistics = (params) => { + return request({ + url: '/TransactionRecord/GetTrendStatistics', + method: 'get', + params + }) +} + +/** + * 获取指定月份每天的消费统计 + * @param {Object} params - 查询参数 + * @param {number} params.year - 年份 + * @param {number} params.month - 月份 + * @returns {Promise<{success: boolean, data: Array}>} + * @returns {Array} data - 每日统计列表 + * @returns {string} data[].date - 日期 + * @returns {number} data[].count - 交易笔数 + * @returns {number} data[].amount - 交易金额 + */ +export const getDailyStatistics = (params) => { + return request({ + url: '/TransactionRecord/GetDailyStatistics', + method: 'get', + params + }) +} diff --git a/Web/src/router/index.js b/Web/src/router/index.js index 3aee6d8..f013920 100644 --- a/Web/src/router/index.js +++ b/Web/src/router/index.js @@ -57,6 +57,18 @@ const router = createRouter({ name: 'classification-nlp', component: () => import('../views/ClassificationNLP.vue'), meta: { requiresAuth: true }, + }, + { + path: '/statistics', + name: 'statistics', + component: () => import('../views/StatisticsView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/bill-analysis', + name: 'bill-analysis', + component: () => import('../views/BillAnalysisView.vue'), + meta: { requiresAuth: true }, } ], }) diff --git a/Web/src/views/BillAnalysisView.vue b/Web/src/views/BillAnalysisView.vue new file mode 100644 index 0000000..a92d6d3 --- /dev/null +++ b/Web/src/views/BillAnalysisView.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/Web/src/views/StatisticsView.vue b/Web/src/views/StatisticsView.vue new file mode 100644 index 0000000..a29bf77 --- /dev/null +++ b/Web/src/views/StatisticsView.vue @@ -0,0 +1,834 @@ + + + + + \ No newline at end of file diff --git a/WebApi/Controllers/TransactionRecordController.cs b/WebApi/Controllers/TransactionRecordController.cs index ca4210d..e66c54b 100644 --- a/WebApi/Controllers/TransactionRecordController.cs +++ b/WebApi/Controllers/TransactionRecordController.cs @@ -1,5 +1,7 @@ namespace WebApi.Controllers; +using Repository; + [ApiController] [Route("api/[controller]/[action]")] public class TransactionRecordController( @@ -230,13 +232,244 @@ public class TransactionRecordController( } } + /// + /// 获取月度统计数据 + /// + [HttpGet] + public async Task> GetMonthlyStatisticsAsync( + [FromQuery] int year, + [FromQuery] int month + ) + { + try + { + var statistics = await transactionRepository.GetMonthlyStatisticsAsync(year, month); + return new BaseResponse + { + Success = true, + Data = statistics + }; + } + catch (Exception ex) + { + logger.LogError(ex, "获取月度统计数据失败,年份: {Year}, 月份: {Month}", year, month); + return BaseResponse.Fail($"获取月度统计数据失败: {ex.Message}"); + } + } + + /// + /// 获取分类统计数据 + /// + [HttpGet] + public async Task>> GetCategoryStatisticsAsync( + [FromQuery] int year, + [FromQuery] int month, + [FromQuery] TransactionType type + ) + { + try + { + var statistics = await transactionRepository.GetCategoryStatisticsAsync(year, month, type); + return new BaseResponse> + { + Success = true, + Data = statistics + }; + } + catch (Exception ex) + { + logger.LogError(ex, "获取分类统计数据失败,年份: {Year}, 月份: {Month}, 类型: {Type}", year, month, type); + return BaseResponse>.Fail($"获取分类统计数据失败: {ex.Message}"); + } + } + + /// + /// 获取趋势统计数据 + /// + [HttpGet] + public async Task>> GetTrendStatisticsAsync( + [FromQuery] int startYear, + [FromQuery] int startMonth, + [FromQuery] int monthCount = 6 + ) + { + try + { + var statistics = await transactionRepository.GetTrendStatisticsAsync(startYear, startMonth, monthCount); + return new BaseResponse> + { + Success = true, + Data = statistics + }; + } + catch (Exception ex) + { + logger.LogError(ex, "获取趋势统计数据失败,开始年份: {Year}, 开始月份: {Month}, 月份数: {Count}", startYear, startMonth, monthCount); + return BaseResponse>.Fail($"获取趋势统计数据失败: {ex.Message}"); + } + } + + /// + /// 智能分析账单(流式输出) + /// + public async Task AnalyzeBillAsync([FromBody] BillAnalysisRequest request) + { + Response.ContentType = "text/event-stream"; + Response.Headers.Append("Cache-Control", "no-cache"); + Response.Headers.Append("Connection", "keep-alive"); + + try + { + // 第一步:使用AI生成聚合SQL查询 + var now = DateTime.Now; + var sqlPrompt = $""" + 当前日期:{now:yyyy年M月d日}({now:yyyy-MM-dd}) + 用户问题:{request.UserInput} + + 数据库类型:SQLite + 数据库表名:TransactionRecord + 字段说明: + - Id: bigint 主键 + - Card: nvarchar 卡号 + - Reason: nvarchar 交易原因/摘要 + - Amount: decimal 交易金额(支出为负数,收入为正数) + - OccurredAt: datetime 交易发生时间(TEXT类型,格式:'2025-12-26 10:30:00') + - Type: int 交易类型(0=支出, 1=收入, 2=不计入收支) + - Classify: nvarchar 交易分类(如:交通、餐饮、购物等) + + 【核心原则】直接生成用户所需的聚合统计SQL,而不是查询原始记录后再统计 + + 要求: + 1. 根据用户问题判断需要什么维度的聚合数据 + 2. 使用 GROUP BY 按分类、时间等维度分组 + 3. 使用聚合函数:SUM(ABS(Amount)) 计算金额总和、COUNT(*) 计数、AVG()平均、MAX()最大、MIN()最小 + 4. 时间范围使用 OccurredAt 字段,"最近X个月/天"基于当前日期计算 + 5. 支出用 Type = 0,收入用 Type = 1 + 6. 给聚合字段起有意义的别名(如 TotalAmount, TransactionCount, AvgAmount) + 7. 使用 ORDER BY 对结果排序(通常按金额降序) + 8. 只返回SQL语句,不要解释 + + 【重要】SQLite日期函数: + - 提取年份:strftime('%Y', OccurredAt) + - 提取月份:strftime('%m', OccurredAt) + - 提取日期:strftime('%Y-%m-%d', OccurredAt) + - 不要使用 YEAR()、MONTH()、DAY() 函数,SQLite不支持 + + 示例1(按分类统计): + 用户:这三个月坐车花了多少钱? + 返回:SELECT Classify, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-10-01' AND OccurredAt < '2026-01-01' AND (Classify LIKE '%交通%' OR Reason LIKE '%打车%' OR Reason LIKE '%公交%' OR Reason LIKE '%地铁%') GROUP BY Classify ORDER BY TotalAmount DESC + + 示例2(按月统计): + 用户:最近半年每月支出情况 + 返回:SELECT strftime('%Y', OccurredAt) as Year, strftime('%m', OccurredAt) as Month, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-06-01' GROUP BY strftime('%Y', OccurredAt), strftime('%m', OccurredAt) ORDER BY Year, Month + + 示例3(总体统计): + 用户:本月花了多少钱? + 返回:SELECT COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount, MAX(ABS(Amount)) as MaxAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-12-01' AND OccurredAt < '2026-01-01' + + 示例4(详细记录 - 仅在用户明确要求详情时使用): + 用户:单笔超过1000元的支出有哪些? + 返回:SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50 + + 只返回SQL语句。 + """; + + var sqlText = await openAiService.ChatAsync(sqlPrompt); + + // 清理SQL文本 + sqlText = sqlText?.Trim() ?? ""; + sqlText = sqlText.TrimStart('`').TrimEnd('`'); + if (sqlText.StartsWith("sql", StringComparison.OrdinalIgnoreCase)) + { + sqlText = sqlText.Substring(3).Trim(); + } + + logger.LogInformation("AI生成的SQL: {Sql}", sqlText); + + // 第二步:执行动态SQL查询 + List queryResults; + try + { + queryResults = await transactionRepository.ExecuteDynamicSqlAsync(sqlText); + } + catch (Exception ex) + { + logger.LogError(ex, "执行AI生成的SQL失败: {Sql}", sqlText); + // 如果SQL执行失败,返回错误 + var errorData = System.Text.Json.JsonSerializer.Serialize(new { content = "
SQL执行失败,请重新描述您的问题
" }); + await Response.WriteAsync($"data: {errorData}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + // 第三步:将查询结果序列化为JSON,直接传递给AI生成分析报告 + var dataJson = System.Text.Json.JsonSerializer.Serialize(queryResults, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + var dataPrompt = $""" + 当前日期:{DateTime.Now:yyyy年M月d日} + 用户问题:{request.UserInput} + + 查询结果数据(JSON格式): + {dataJson} + + 说明:以上数据是根据用户问题查询出的聚合统计结果,请基于这些数据生成分析报告。 + + 请生成一份专业的数据分析报告,严格遵守以下要求: + + 【格式要求】 + 1. 使用HTML格式(移动端H5页面风格) + 2. 生成清晰的报告标题(基于用户问题) + 3. 使用表格展示统计数据(table > thead/tbody > tr > th/td) + 4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调) + 5. 支出金额用 金额 包裹 + 6. 收入金额用 金额 包裹 + 7. 重要结论用 内容 高亮 + + 【样式限制(重要)】 + 8. 不要包含 html、body、head 标签 + 9. 不要使用任何 style 属性或