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 实现类。 *
* 流程固定为:校验 → 逐个文件抽取 → 排调用计划 → 执行 → 逐次归一 → 合并去重 → 组装。 * 每一步的失败都对应一个确定的错误码,前端拿到的永远是「哪一步、为什么、怎么办」, * 而不是一个 500 加一句「AI 识别失败」。 *
* 与 ERP / CRM 那三处 AI 调用的一处刻意偏离:它们把错误塞进响应体的 {@code rawText} 且返回 HTTP 200, * 前端靠「rawText 有没有值」嗅探失败。本实现改用真正的 ServiceException + 分错误码—— * 靠字段有没有值来判断成败,一旦某天正常返回也带上了 rawText,前端会立刻误判。 */ @Slf4j @Service @Validated public class QcReportAiImportServiceImpl implements QcReportAiImportService { /** * 组件清单的项数上限。 *
* 前端注册表现有 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
* 所有异常都在这里收口成一个确定的错误码:超时与其它失败分开,因为用户的下一步动作完全不同——
* 前者该减文件,后者该去查 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;
}
}
/**
* 判断异常链里有没有超时。
*
* {@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 的字节。
*
* 刻意不删除取到的文件。 {@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 版本。
*
* 这条检查把「积木清单来自 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}。
*
* 调用位置在 {@link #generateDraft} 的最前段、任何 blob 读取之前,所以放大文件数不会带来
* 额外的磁盘/内存开销,注解那层拦截并无必要;而两个数字并存的结果只会是注解更严、配置失去可调性。
*/
private void validateFileCount(int fileCount) {
if (fileCount > properties.getMaxFiles()) {
throw exception(AI_IMPORT_TOO_MANY_FILES, properties.getMaxFiles(), fileCount);
}
}
/**
* 校验累计页数。
*
* 单文件页数在适配器里已经卡过了,这里卡的是「每个文件都没超、加起来却很多」——
* 三个各 5 页的扫描件会变成 15 次多模态调用,代价与耗时都远超预期。
*/
private void validateTotalPages(int totalPages) {
if (totalPages > properties.getMaxPagesPerRequest()) {
throw exception(AI_IMPORT_TOO_MANY_PAGES_TOTAL, totalPages,
properties.getMaxPagesPerRequest(), properties.getMaxPagesPerFile());
}
}
}