using Service.AI; namespace WebApi.Controllers; [ApiController] [Route("api/[controller]/[action]")] public class TransactionCategoryController( ITransactionCategoryRepository categoryRepository, ITransactionRecordRepository transactionRecordRepository, ILogger logger, IBudgetRepository budgetRepository, IOpenAiService openAiService ) : ControllerBase { /// /// 获取分类列表(支持按类型筛选) /// [HttpGet] public async Task>> GetListAsync([FromQuery] TransactionType? type = null) { try { List categories; if (type.HasValue) { categories = await categoryRepository.GetCategoriesByTypeAsync(type.Value); } else { categories = (await categoryRepository.GetAllAsync()).ToList(); } return categories.Ok(); } catch (Exception ex) { logger.LogError(ex, "获取分类列表失败"); return $"获取分类列表失败: {ex.Message}".Fail>(); } } /// /// 根据ID获取分类详情 /// [HttpGet("{id}")] public async Task> GetByIdAsync(long id) { try { var category = await categoryRepository.GetByIdAsync(id); if (category == null) { return "分类不存在".Fail(); } return category.Ok(); } catch (Exception ex) { logger.LogError(ex, "获取分类详情失败, Id: {Id}", id); return $"获取分类详情失败: {ex.Message}".Fail(); } } /// /// 创建分类 /// [HttpPost] public async Task> CreateAsync([FromBody] CreateCategoryDto dto) { try { // 检查同名分类 var existing = await categoryRepository.GetByNameAndTypeAsync(dto.Name, dto.Type); if (existing != null) { return "已存在相同名称的分类".Fail(); } var category = new TransactionCategory { Name = dto.Name, Type = dto.Type }; var result = await categoryRepository.AddAsync(category); if (result) { return category.Id.Ok(); } return "创建分类失败".Fail(); } catch (Exception ex) { logger.LogError(ex, "创建分类失败, Dto: {@Dto}", dto); return $"创建分类失败: {ex.Message}".Fail(); } } /// /// 更新分类 /// [HttpPost] public async Task UpdateAsync([FromBody] UpdateCategoryDto dto) { try { var category = await categoryRepository.GetByIdAsync(dto.Id); if (category == null) { return "分类不存在".Fail(); } // 如果修改了名称,检查同名 if (category.Name != dto.Name) { var existing = await categoryRepository.GetByNameAndTypeAsync(dto.Name, category.Type); if (existing != null && existing.Id != dto.Id) { return "已存在相同名称的分类".Fail(); } // 同步更新交易记录中的分类名称 await transactionRecordRepository.UpdateCategoryNameAsync(category.Name, dto.Name, category.Type); await budgetRepository.UpdateBudgetCategoryNameAsync(category.Name, dto.Name, category.Type); } category.Name = dto.Name; category.UpdateTime = DateTime.Now; var success = await categoryRepository.UpdateAsync(category); if (success) { return "更新分类成功".Ok(); } return "更新分类失败".Fail(); } catch (Exception ex) { logger.LogError(ex, "更新分类失败, Dto: {@Dto}", dto); return $"更新分类失败: {ex.Message}".Fail(); } } /// /// 删除分类 /// [HttpPost] public async Task DeleteAsync([FromQuery] long id) { try { // 检查是否被使用 var inUse = await categoryRepository.IsCategoryInUseAsync(id); if (inUse) { return "该分类已被使用,无法删除".Fail(); } var success = await categoryRepository.DeleteAsync(id); if (success) { return BaseResponse.Done(); } return "删除分类失败,分类不存在".Fail(); } catch (Exception ex) { logger.LogError(ex, "删除分类失败, Id: {Id}", id); return $"删除分类失败: {ex.Message}".Fail(); } } /// /// 批量创建分类(用于初始化) /// [HttpPost] public async Task> BatchCreateAsync([FromBody] List dtoList) { try { var categories = dtoList.Select(dto => new TransactionCategory { Name = dto.Name, Type = dto.Type }).ToList(); var result = await categoryRepository.AddRangeAsync(categories); if (result) { return categories.Count.Ok(); } return "批量创建分类失败".Fail(); } catch (Exception ex) { logger.LogError(ex, "批量创建分类失败, Count: {Count}", dtoList.Count); return $"批量创建分类失败: {ex.Message}".Fail(); } } /// /// 为指定分类生成新的SVG图标 /// [HttpPost] public async Task> GenerateIconAsync([FromBody] GenerateIconDto dto) { try { var category = await categoryRepository.GetByIdAsync(dto.CategoryId); if (category == null) { return "分类不存在".Fail(); } // 使用AI生成简洁、风格鲜明的SVG图标 var systemPrompt = @"你是一个专业的SVG图标设计师。你的任务是为预算分类生成极简风格、视觉识别度高的SVG图标。 ## 核心设计原则 1. **语义相关性**:图标必须直观反映分类本质。例如: - 「餐饮」→ 餐具、碗筷或热腾腾的食物 - 「交通」→ 汽车、地铁或公交车 - 「购物」→ 购物袋或购物车 - 「娱乐」→ 电影票、游戏手柄或麦克风 - 「医疗」→ 十字架或药丸 - 「工资」→ 钱袋或上升箭头 2. **极简风格**: - 线条简洁流畅,避免复杂细节 - 使用几何图形和圆润的边角 - 2-4个主要形状元素即可 - 笔画粗细统一(stroke-width: 2) 3. **视觉识别**: - 轮廓清晰,一眼能认出是什么 - 避免抽象符号,优先具象图形 - 留白合理,图标不要过于密集 ## 技术规范 - viewBox=""0 0 24 24"" - 尺寸为 24×24 - 使用单色:fill=""currentColor"" 或 stroke=""currentColor"" - 优先使用 stroke(描边)而非 fill(填充),更显轻盈 - stroke-width=""2"" stroke-linecap=""round"" stroke-linejoin=""round"" - 只返回 标签及其内容,不要其他说明 ## 回退方案 如果该分类实在无法用具象图形表达(如「其他」「杂项」等),则生成包含该分类**首字**的文字图标: ```xml {首字} ``` ## 示例 **好的图标**: - 「咖啡」→ 咖啡杯+热气 - 「房租」→ 房子外轮廓 - 「健身」→ 哑铃 **差的图标**: - 过于复杂的写实风格 - 无法识别的抽象符号 - 图形过小或过密"; var transactionTypeDesc = category.Type switch { TransactionType.Expense => "支出", TransactionType.Income => "收入", _ => "不计收支" }; var userPrompt = $@"请为「{category.Name}」分类生成图标({transactionTypeDesc}类别)。 要求: 1. 分析这个分类的核心含义 2. 选择最具代表性的视觉元素 3. 用极简线条勾勒出图标(优先使用 stroke 描边风格) 4. 如果实在无法用图形表达,则生成包含「{(category.Name.Length > 0 ? category.Name[0] : '?')}」的文字图标 直接返回SVG代码,无需解释。"; var svgContent = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 30); if (string.IsNullOrWhiteSpace(svgContent)) { return "AI生成图标失败".Fail(); } // 提取SVG标签内容 var svgMatch = System.Text.RegularExpressions.Regex.Match(svgContent, @"]*>.*?", System.Text.RegularExpressions.RegexOptions.Singleline); if (!svgMatch.Success) { return "生成的内容不包含有效的SVG标签".Fail(); } var svg = svgMatch.Value; // 解析现有图标数组 var icons = string.IsNullOrWhiteSpace(category.Icon) ? new List() : JsonSerializer.Deserialize>(category.Icon) ?? new List(); // 添加新图标 icons.Add(svg); // 更新数据库 category.Icon = JsonSerializer.Serialize(icons); category.UpdateTime = DateTime.Now; var success = await categoryRepository.UpdateAsync(category); if (!success) { return "更新分类图标失败".Fail(); } return svg.Ok(); } catch (Exception ex) { logger.LogError(ex, "生成图标失败, CategoryId: {CategoryId}", dto.CategoryId); return $"生成图标失败: {ex.Message}".Fail(); } } /// /// 更新分类的选中图标索引 /// [HttpPost] public async Task UpdateSelectedIconAsync([FromBody] UpdateSelectedIconDto dto) { try { var category = await categoryRepository.GetByIdAsync(dto.CategoryId); if (category == null) { return "分类不存在".Fail(); } // 验证索引有效性 if (string.IsNullOrWhiteSpace(category.Icon)) { return "该分类没有可用图标".Fail(); } var icons = JsonSerializer.Deserialize>(category.Icon); if (icons == null || dto.SelectedIndex < 0 || dto.SelectedIndex >= icons.Count) { return "无效的图标索引".Fail(); } // 这里可以添加一个SelectedIconIndex字段到实体中,或者将选中的图标移到数组第一位 // 暂时采用移动到第一位的方式 var selectedIcon = icons[dto.SelectedIndex]; icons.RemoveAt(dto.SelectedIndex); icons.Insert(0, selectedIcon); category.Icon = JsonSerializer.Serialize(icons); category.UpdateTime = DateTime.Now; var success = await categoryRepository.UpdateAsync(category); if (success) { return "更新图标成功".Ok(); } return "更新图标失败".Fail(); } catch (Exception ex) { logger.LogError(ex, "更新选中图标失败, Dto: {@Dto}", dto); return $"更新选中图标失败: {ex.Message}".Fail(); } } } /// /// 创建分类DTO /// public record CreateCategoryDto( string Name, TransactionType Type ); /// /// 更新分类DTO /// public record UpdateCategoryDto( long Id, string Name ); /// /// 生成图标DTO /// public record GenerateIconDto( long CategoryId ); /// /// 更新选中图标DTO /// public record UpdateSelectedIconDto( long CategoryId, int SelectedIndex );