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 perCall = new ArrayList<>(); List warnings = new ArrayList<>(); List 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; } /* ------------------------------ 调用执行 ------------------------------ */ /** * 发一次模型调用并解析回复。 *

* 所有异常都在这里收口成一个确定的错误码:超时与其它失败分开,因为用户的下一步动作完全不同—— * 前者该减文件,后者该去查 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()); } } }