Initial project import
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// OCR/语音/图片辅助解析。客户端确认后再调用交易接口正式入账。
|
||||
/// 图片模式由后端多模态模型提取一笔或多笔账单草稿。
|
||||
/// </summary>
|
||||
[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<ActionResult<OcrParseResponse>> Parse(OcrParseRequest req)
|
||||
{
|
||||
return await ParseWithAgent(req, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
private async Task<ActionResult<OcrParseResponse>> 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<ParsedBill> 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"));
|
||||
}
|
||||
|
||||
/// <summary>上传截屏或小票,提取其中全部独立交易。</summary>
|
||||
[HttpPost("image")]
|
||||
[EnableRateLimiting("upload")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
|
||||
public async Task<ActionResult<ImageParseResponse>> 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<ImageParseResult>? 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<ImageParseItemResponse>();
|
||||
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<bool> 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<Category> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user