using System.Security.Claims; using MiaoJiZhang.Api.Contracts; using MiaoJiZhang.Api.Services; using MiaoJiZhang.Domain.Entities; using MiaoJiZhang.Domain.Enums; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace MiaoJiZhang.Api.Controllers; /// /// OCR/语音/图片辅助解析。客户端确认后再调用交易接口正式入账。 /// 图片模式由后端多模态模型提取一笔或多笔账单草稿。 /// [ApiController] [Authorize] [Route("api/parse")] [RequireAiPermission] public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent) : ControllerBase { private long Uid => long.Parse( User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!); [HttpPost] [EnableRateLimiting("ai")] public async Task> Parse(OcrParseRequest req) { return await ParseWithAgent(req, HttpContext.RequestAborted); } private async Task> ParseWithAgent( OcrParseRequest req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(req.Text)) return BadRequest(new ApiError("TEXT_EMPTY", "内容不能为空")); var featureKey = req.Source == "voice" ? "feature.voice_enabled" : "feature.ocr_enabled"; if (!await FeatureEnabled(featureKey)) return StatusCode(403, new ApiError("FEATURE_DISABLED", "该解析功能已由后台关闭")); IReadOnlyList drafts; try { drafts = await agent.ParseDraftsAsync(Uid, req.Text.Trim(), ct); } catch (InvalidOperationException ex) { return StatusCode( 503, new ApiError("AGENT_PARSE_UNAVAILABLE", ex.Message)); } catch (Exception) { return StatusCode( 502, new ApiError( "AGENT_PARSE_FAILED", "AI 暂时无法解析,没有生成账单草稿")); } if (drafts.Count == 0) { var fallback = await db.Categories.FirstAsync( c => c.Name == "其他" && c.Type == TransactionType.Expense && c.UserId == null, ct); return Ok(new OcrParseResponse( false, fallback.Id, fallback.Name, fallback.IconKey, 0, null, req.Text, "expense")); } var bill = drafts[0]; var category = await db.Categories.FirstAsync( c => c.Id == bill.CategoryId && c.Type == bill.Type, ct); return Ok(new OcrParseResponse( true, category.Id, category.Name, category.IconKey, bill.Amount, bill.PaymentMethod, bill.Note, bill.Type == TransactionType.Income ? "income" : "expense")); } /// 上传截屏或小票,提取其中全部独立交易。 [HttpPost("image")] [EnableRateLimiting("upload")] [RequestSizeLimit(10 * 1024 * 1024)] [RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)] public async Task> ParseImage( IFormFile file, [FromQuery] string source = "image", CancellationToken ct = default) { var featureKey = source == "screenshot" ? "feature.screenshot_bookkeeping_enabled" : "feature.ocr_enabled"; if (!await FeatureEnabled(featureKey)) return StatusCode(403, new ApiError("FEATURE_DISABLED", "该图片识别功能已由后台关闭")); if (file is null || file.Length == 0) return BadRequest(new ApiError("FILE_EMPTY", "请上传图片")); if (!file.ContentType.StartsWith("image/")) return BadRequest(new ApiError("NOT_IMAGE", "仅支持图片文件")); if (file.Length > 10 * 1024 * 1024) return BadRequest(new ApiError("FILE_TOO_LARGE", "图片不能超过 10MB")); if (!llm.IsEnabled) { return StatusCode( 503, new ApiError( "LLM_NOT_CONFIGURED", "AI 图片解析未配置,请在管理后台设置 LLM API Key")); } using var stream = new MemoryStream(); await file.CopyToAsync(stream, ct); IReadOnlyList? results; try { results = await llm.AnalyzeImageAsync( stream.ToArray(), file.ContentType, ct); } catch (InvalidOperationException ex) { return StatusCode(502, new ApiError("LLM_IMAGE_ERROR", ex.Message)); } if (results is null) return StatusCode(500, new ApiError("LLM_ERROR", "AI 解析失败,请稍后重试")); var categories = await db.Categories .Where(c => !c.IsDeleted && (c.UserId == null || c.UserId == Uid)) .ToListAsync(ct); if (results.Count == 0) { return Ok(new ImageParseResponse( false, 0, "待确认", "tag", 0, null, "", "unknown", [], null)); } var items = new List(); foreach (var result in results.Take(20)) { var normalizedType = result.Type.Trim().ToLowerInvariant(); if (normalizedType is not ("income" or "expense")) { items.Add(new ImageParseItemResponse( false, 0, "待确认", "tag", "unknown", result.Amount, result.PaymentMethod, result.Note, result.OccurredAt)); continue; } var type = normalizedType == "income" ? TransactionType.Income : TransactionType.Expense; var category = FindCategory(categories, type, result.CategoryName); items.Add(new ImageParseItemResponse( true, category.Id, category.Name, category.IconKey, normalizedType, result.Amount, result.PaymentMethod, result.Note, result.OccurredAt)); } var first = items[0]; return Ok(new ImageParseResponse( true, first.CategoryId, first.CategoryName, first.CategoryIcon, first.Amount, first.PaymentMethod, first.Note, first.Type, items, first.OccurredAt)); } private async Task FeatureEnabled(string key) { var value = await db.AppConfigs .Where(config => config.Key == key) .Select(config => config.Value) .FirstOrDefaultAsync(); return value is null || !value.Equals("false", StringComparison.OrdinalIgnoreCase); } private Category FindCategory( List categories, TransactionType type, string requestedName) { if (type == TransactionType.Income && requestedName is "退款" or "返现" or "退回") { requestedName = "报销"; } return categories .Where(c => c.Type == type && c.Name == requestedName) .OrderByDescending(c => c.UserId == Uid) .FirstOrDefault() ?? categories.First( c => c.Type == type && c.Name == "其他" && c.UserId == null); } }