package cn.iocoder.yudao.module.qcreport.service.aiimport;
|
|
import cn.hutool.core.util.StrUtil;
|
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
import cn.iocoder.yudao.module.ai.api.chat.AiChatApi;
|
import cn.iocoder.yudao.module.qcreport.config.QcReportAiImportProperties;
|
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftReqVO;
|
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
|
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportComponentSpecVO;
|
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
|
import cn.iocoder.yudao.module.qcreport.service.aiimport.document.QcReportDocumentExtract;
|
import cn.iocoder.yudao.module.qcreport.service.aiimport.document.QcReportDocumentExtractService;
|
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportAiDraftNormalizer;
|
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportAiDraftParser;
|
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportLlmCall;
|
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportLlmCallPlanner;
|
import cn.iocoder.yudao.module.qcreport.service.aiimport.llm.QcReportTemplatePromptBuilder;
|
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
|
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
|
import jakarta.annotation.Resource;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Service;
|
import org.springframework.validation.annotation.Validated;
|
|
import java.io.File;
|
import java.io.IOException;
|
import java.io.InterruptedIOException;
|
import java.nio.file.Files;
|
import java.util.ArrayList;
|
import java.util.List;
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_AI_BUDGET_EXCEEDED;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_AI_TIMEOUT;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_AI_UNAVAILABLE;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_BLOB_NOT_FOUND;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_CATALOG_INVALID;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_DISABLED;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_DRAFT_EMPTY;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_TOO_LARGE;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_SCHEMA_VERSION_UNSUPPORTED;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_TOO_MANY_FILES;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_TOO_MANY_PAGES_TOTAL;
|
|
/**
|
* AI 导入 Service 实现类。
|
* <p>
|
* 流程固定为:校验 → 逐个文件抽取 → 排调用计划 → 执行 → 逐次归一 → 合并去重 → 组装。
|
* 每一步的失败都对应一个确定的错误码,前端拿到的永远是「哪一步、为什么、怎么办」,
|
* 而不是一个 500 加一句「AI 识别失败」。
|
* <p>
|
* <b>与 ERP / CRM 那三处 AI 调用的一处刻意偏离</b>:它们把错误塞进响应体的 {@code rawText} 且返回 HTTP 200,
|
* 前端靠「rawText 有没有值」嗅探失败。本实现改用真正的 ServiceException + 分错误码——
|
* 靠字段有没有值来判断成败,一旦某天正常返回也带上了 rawText,前端会立刻误判。
|
*/
|
@Slf4j
|
@Service
|
@Validated
|
public class QcReportAiImportServiceImpl implements QcReportAiImportService {
|
|
/**
|
* 组件清单的项数上限。
|
* <p>
|
* 前端注册表现有 12 个组件,留出三倍余量。真正的防线是积木清单的序列化长度上限
|
* (在 {@link QcReportTemplatePromptBuilder} 里),这条只是提前拦住明显异常的入参。
|
*/
|
private static final int MAX_CATALOG_ITEMS = 40;
|
|
@Resource
|
private QcReportAiImportProperties properties;
|
|
@Resource
|
private QcReportDocumentExtractService extractService;
|
|
@Resource
|
private AiChatApi aiChatApi;
|
|
@Resource
|
private SystemStorageBlobService storageBlobService;
|
|
@Override
|
public QcReportAiDraftRespVO generateDraft(QcReportAiDraftReqVO reqVO) {
|
long startedAt = System.currentTimeMillis();
|
validateEnabled();
|
validateCatalog(reqVO);
|
validateSchemaVersion(reqVO.getSchemaVersion());
|
validateFileCount(reqVO.getBlobIds().size());
|
|
String systemPrompt = QcReportTemplatePromptBuilder.buildSystemPrompt(reqVO.getCatalog());
|
List<QcReportAiDraftNormalizer.Result> perCall = new ArrayList<>();
|
List<String> warnings = new ArrayList<>();
|
List<String> fileNames = new ArrayList<>();
|
ReportTemplateSchema.Page page = null;
|
String summary = null;
|
int totalPages = 0;
|
int callCount = 0;
|
boolean budgetExceeded = false;
|
long budgetMs = properties.getMaxDurationSeconds() * 1000L;
|
|
for (Long blobId : reqVO.getBlobIds()) {
|
// 进文件前先看预算:已经超了就不要再把下一份文件读进来、更不要渲染它的页,
|
// 那些都是白花的 CPU 和内存
|
if (System.currentTimeMillis() - startedAt >= budgetMs) {
|
budgetExceeded = true;
|
break;
|
}
|
LoadedBlob blob = loadBlob(blobId);
|
QcReportDocumentExtract extract = extractService.extract(blob.content(), blob.originalFilename(),
|
blob.contentType());
|
fileNames.add(blob.originalFilename());
|
totalPages += extract.pageCount();
|
validateTotalPages(totalPages);
|
warnings.addAll(extract.notes());
|
|
for (QcReportLlmCall call : QcReportLlmCallPlanner.plan(extract, blob.originalFilename())) {
|
// 两次调用之间关门。单次调用无法打断(AiChatApi 不收超时参数),
|
// 所以这里拦不住「最后一次调用又耗满一个 yudao.ai.timeout」——
|
// 前端超时因此必须比「本预算 + 单次超时」更宽,否则用户看到的是浏览器断开而非这条提示
|
if (System.currentTimeMillis() - startedAt >= budgetMs) {
|
budgetExceeded = true;
|
break;
|
}
|
QcReportAiDraftRespVO raw = execute(systemPrompt, reqVO.getHint(), call, startedAt);
|
callCount++;
|
if (page == null) {
|
page = raw.getPage();
|
}
|
if (summary == null && StrUtil.isNotBlank(raw.getSummary())) {
|
summary = raw.getSummary();
|
}
|
perCall.add(QcReportAiDraftNormalizer.normalize(
|
raw.getComponents(), reqVO.getCatalog(), call.sourceLabel()));
|
}
|
if (budgetExceeded) {
|
break;
|
}
|
}
|
|
long elapsedSeconds = (System.currentTimeMillis() - startedAt) / 1000;
|
if (budgetExceeded) {
|
// 已经识别出来的部分照常返回:半份草稿对用户仍然有用,
|
// 且这里把「后续没识别」明写出来,不是静默截断
|
warnings.add(StrUtil.format(
|
"本次识别已达到单次导入耗时上限({} 秒,已用时 {} 秒),后续内容未再识别,"
|
+ "以上是已识别到的部分。请减少文件数量或页数后分批导入",
|
properties.getMaxDurationSeconds(), elapsedSeconds));
|
}
|
|
QcReportAiDraftNormalizer.Result merged =
|
QcReportAiDraftNormalizer.mergeAndDedup(perCall, properties.getMaxComponents());
|
warnings.addAll(merged.warnings());
|
if (merged.components().isEmpty()) {
|
// 一个组件都没识别出来时,报错文案要说清到底是因为耗尽了预算,
|
// 还是模型确实认不出——两者用户要采取的动作完全不同
|
if (budgetExceeded) {
|
throw exception(AI_IMPORT_AI_BUDGET_EXCEEDED, properties.getMaxDurationSeconds(),
|
elapsedSeconds, String.join("、", fileNames));
|
}
|
throw exception(AI_IMPORT_DRAFT_EMPTY, String.join("、", fileNames));
|
}
|
|
QcReportAiDraftRespVO resp = new QcReportAiDraftRespVO();
|
resp.setComponents(merged.components());
|
resp.setPage(page);
|
resp.setSummary(summary);
|
resp.setWarnings(warnings);
|
resp.setDurationMs(System.currentTimeMillis() - startedAt);
|
log.info("AI 导入识别完成:files={}, pages={}, calls={}, components={}, budgetExceeded={}, durationMs={}",
|
fileNames, totalPages, callCount, merged.components().size(), budgetExceeded, resp.getDurationMs());
|
return resp;
|
}
|
|
/* ------------------------------ 调用执行 ------------------------------ */
|
|
/**
|
* 发一次模型调用并解析回复。
|
* <p>
|
* 所有异常都在这里收口成一个确定的错误码:超时与其它失败分开,因为用户的下一步动作完全不同——
|
* 前者该减文件,后者该去查 AI 模型配置。把它们混成「AI 识别失败」,用户只能两件事都试一遍。
|
*/
|
private QcReportAiDraftRespVO execute(String systemPrompt, String hint, QcReportLlmCall call, long startedAt) {
|
String userMessage = QcReportTemplatePromptBuilder.buildUserMessage(hint, call.sourceLabel(), call.text());
|
String reply;
|
try {
|
reply = call.kind() == QcReportLlmCall.Kind.IMAGE
|
? aiChatApi.chatWithImage(systemPrompt, userMessage, call.imageBase64(), call.mimeType())
|
: aiChatApi.chat(systemPrompt, userMessage);
|
} catch (Exception e) {
|
long elapsed = System.currentTimeMillis() - startedAt;
|
log.warn("AI 导入调用失败:source={}, kind={}, elapsedMs={}", call.sourceLabel(), call.kind(), elapsed, e);
|
if (isTimeout(e)) {
|
throw exception(AI_IMPORT_AI_TIMEOUT, elapsed / 1000);
|
}
|
throw exception(AI_IMPORT_AI_UNAVAILABLE, elapsed, StrUtil.maxLength(String.valueOf(e.getMessage()), 200));
|
}
|
try {
|
return QcReportAiDraftParser.parse(reply);
|
} catch (ServiceException e) {
|
// 模型返回了什么,是排查这条路上唯一的线索。写进日志而不是塞进响应:
|
// 一坨模型原文给用户看没有意义,而错误码已经讲清了「怎么办」
|
log.warn("AI 导入解析回复失败:source={}, reply={}",
|
call.sourceLabel(), QcReportAiDraftParser.truncate(reply), e);
|
throw e;
|
}
|
}
|
|
/**
|
* 判断异常链里有没有超时。
|
* <p>
|
* {@code AiChatApi} 不暴露底层 HTTP 客户端,超时最终会以 {@link InterruptedIOException}
|
* (OkHttp 的读写超时都是它的子类)或带有 "timeout" 字样的异常出现在原因链上。
|
* 沿链找而不是只看最外层:中间隔了几层包装是常态。
|
*/
|
private static boolean isTimeout(Throwable e) {
|
for (Throwable cause = e; cause != null; cause = cause.getCause()) {
|
if (cause instanceof InterruptedIOException) {
|
return true;
|
}
|
if (cause.getMessage() != null && cause.getMessage().toLowerCase().contains("timeout")) {
|
return true;
|
}
|
if (cause.getCause() == cause) {
|
break;
|
}
|
}
|
return false;
|
}
|
|
/* ------------------------------ 读取文件 ------------------------------ */
|
|
/**
|
* 读一个 blob 的字节。
|
* <p>
|
* <b>刻意不删除取到的文件。</b> {@code getPublicFile} 返回的是磁盘上真实存储的那份 blob,
|
* 不是临时副本——删了就是删用户的真实上传数据。
|
* (ERP 采购来票 AI 与 CRM 报价 AI 曾在 {@code finally} 里犯这个错,已修;本实现绝不复制。)
|
*/
|
private LoadedBlob loadBlob(Long blobId) {
|
SystemStorageBlobDO blob = storageBlobService.getStorageBlob(blobId);
|
if (blob == null) {
|
throw exception(AI_IMPORT_BLOB_NOT_FOUND, blobId);
|
}
|
String originalFilename = StrUtil.blankToDefault(blob.getOriginalFilename(), "file");
|
try {
|
File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
|
byte[] content = Files.readAllBytes(file.toPath());
|
long maxBytes = properties.getMaxFileSizeMb() * 1024L * 1024L;
|
if (content.length > maxBytes) {
|
throw exception(AI_IMPORT_FILE_TOO_LARGE, originalFilename,
|
content.length / 1024L / 1024L, properties.getMaxFileSizeMb());
|
}
|
return new LoadedBlob(content, originalFilename, blob.getContentType());
|
} catch (IOException e) {
|
log.warn("读取上传文件失败,blobId={}", blobId, e);
|
throw exception(AI_IMPORT_BLOB_NOT_FOUND, blobId);
|
}
|
}
|
|
private record LoadedBlob(byte[] content, String originalFilename, String contentType) {
|
}
|
|
/* ------------------------------ 校验 ------------------------------ */
|
|
private void validateEnabled() {
|
if (!Boolean.TRUE.equals(properties.getEnabled())) {
|
throw exception(AI_IMPORT_DISABLED);
|
}
|
}
|
|
private void validateCatalog(QcReportAiDraftReqVO reqVO) {
|
if (reqVO.getCatalog() == null || reqVO.getCatalog().isEmpty()) {
|
throw exception(AI_IMPORT_CATALOG_INVALID, "组件清单为空");
|
}
|
if (reqVO.getCatalog().size() > MAX_CATALOG_ITEMS) {
|
throw exception(AI_IMPORT_CATALOG_INVALID,
|
StrUtil.format("组件清单共 {} 项,超过上限 {} 项", reqVO.getCatalog().size(), MAX_CATALOG_ITEMS));
|
}
|
for (QcReportComponentSpecVO spec : reqVO.getCatalog()) {
|
if (spec == null || StrUtil.isBlank(spec.getType())) {
|
throw exception(AI_IMPORT_CATALOG_INVALID, "存在没有 type 的组件项");
|
}
|
}
|
}
|
|
/**
|
* 校验 Schema 版本。
|
* <p>
|
* 这条检查把「积木清单来自 1.1 语义层」这个隐式耦合变成显式失败:版本不一致说明前端是旧包,
|
* 它生成出来的清单可能与服务端理解的语义层对不上,此时报错让用户刷新,比让模型基于过期清单编组件安全。
|
*/
|
private void validateSchemaVersion(String schemaVersion) {
|
if (!ReportTemplateSchema.SCHEMA_VERSION.equals(schemaVersion)) {
|
throw exception(AI_IMPORT_SCHEMA_VERSION_UNSUPPORTED, schemaVersion,
|
ReportTemplateSchema.SCHEMA_VERSION);
|
}
|
}
|
|
/**
|
* 校验文件个数。**这是文件数上限的唯一闸门**,入参 VO 上刻意不再挂 {@code @Size}。
|
* <p>
|
* 调用位置在 {@link #generateDraft} 的最前段、任何 blob 读取之前,所以放大文件数不会带来
|
* 额外的磁盘/内存开销,注解那层拦截并无必要;而两个数字并存的结果只会是注解更严、配置失去可调性。
|
*/
|
private void validateFileCount(int fileCount) {
|
if (fileCount > properties.getMaxFiles()) {
|
throw exception(AI_IMPORT_TOO_MANY_FILES, properties.getMaxFiles(), fileCount);
|
}
|
}
|
|
/**
|
* 校验累计页数。
|
* <p>
|
* 单文件页数在适配器里已经卡过了,这里卡的是「每个文件都没超、加起来却很多」——
|
* 三个各 5 页的扫描件会变成 15 次多模态调用,代价与耗时都远超预期。
|
*/
|
private void validateTotalPages(int totalPages) {
|
if (totalPages > properties.getMaxPagesPerRequest()) {
|
throw exception(AI_IMPORT_TOO_MANY_PAGES_TOTAL, totalPages,
|
properties.getMaxPagesPerRequest(), properties.getMaxPagesPerFile());
|
}
|
}
|
|
}
|