12 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
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());
        }
    }
 
}