diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java index 6b4bf8b..cc81487 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java @@ -42,6 +42,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -64,6 +65,37 @@ public class AiController extends BaseController { */ private static final String CREATIVE_FAILED_MESSAGE = "生成失败,请稍后重试"; + /** + * 上游生成服务容量不足时给用户看的文案。 + * + *

「即梦AI-图像生成」偶发 {@code 50500 Internal Error} / + * {@code status_code 110001 no more instances to retry},是上游实例不足的**瞬时**故障, + * 与凭证失效、内容审核拒绝不是一回事:重试通常即成功,所以文案也把用户往"过一会儿再来"引, + * 而不是笼统的"生成失败"。同样只下发这句固定文案,不带任何上游原文。 + */ + private static final String CREATIVE_BUSY_MESSAGE = "生成服务繁忙,请稍后重试"; + + /** + * 创意助手工作流因上游瞬时故障失败后的自动重试次数。 + * + *

一次出图要连调两次「即梦AI-图像生成」,单次约 1/5 概率撞上上游容量不足, + * 不重试时整体失败率约三成(2026-10-13 实测:同一 prompt 连跑 6 次,2 次失败)。 + * 重试一次可把失败率压到约一成,代价是多等一次工作流(实测 45~105 秒), + * 距离 {@link #AI_REQUEST_TIMEOUT} 还有充足余量。 + * + *

只对 {@link #isRetryableCreativeFailure(String)} 认定的瞬时故障重试; + * 配置类失败重试无用,重试反而让用户白等一倍时间。 + */ + private static final int CREATIVE_ASSISTANT_MAX_RETRY = 1; + + /** + * 写进日志的上游错误原文长度上限。 + * + *

上游报错里裹着完整的 {@code b'{"code":50500,...}'},个别情况可达数 KB; + * 日志要能定位问题,但不能被单条错误撑爆,故截断保留头部。 + */ + private static final int UPSTREAM_ERROR_LOG_MAX_LENGTH = 600; + /** * 内部标识符形状:纯 UUID({@code workflow_run_id} / 节点 id 等)。 * 与前端 {@code ai-result.js} 的 {@code isDisplayableAnswer} 同一口径。 @@ -976,7 +1008,126 @@ public class AiController extends BaseController { if (StrUtil.isBlank(userContent)) { return fail("user_content不能为空"); } + return executeCreativeAssistantWithRetry(buildCreativeAssistantInputs(requestBody), loginUserId); + } + /** + * 创意助手执行外壳:失败可重试时原样重跑一次工作流。 + * + *

为什么需要它:出图分支要连调两次「即梦AI-图像生成」,而上游火山引擎会 + * 偶发 {@code 50500 no more instances to retry}(实例不足,3 秒内即拒绝), + * 实测整体失败率约三成。这类失败与内容、参数、凭证都无关,重跑一次通常就成。 + * + *

每次失败都以 ERROR 记录失败节点与上游错误原文:生产日志级别是 + * {@code com.gxwebsoft: ERROR},原先这里用 {@code logger.warn},于是出现 + * "端上说生成失败、后端日志里却什么都没有"的情况。 + * + * @param inputs 已构造好的工作流入参 + * @param loginUserId 用户 id + * @return 成功结果为工作流产出;重试耗尽仍失败时为失败态结果(HTTP 仍是 200,靠 status 表达) + */ + private ApiResult executeCreativeAssistantWithRetry(Map inputs, Integer loginUserId) { + int maxAttempts = CREATIVE_ASSISTANT_MAX_RETRY + 1; + Map failedResult = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + CreativeRun run; + try { + run = runCreativeAssistantOnce(inputs, loginUserId); + } catch (CreativeApiFailureException e) { + logger.error("creative assistant request rejected, attempt={}/{}, message={}", + attempt, maxAttempts, e.getMessage()); + return e.result; + } catch (Exception e) { + logger.error("creative assistant execute failed, attempt={}/{}", attempt, maxAttempts, e); + if (attempt < maxAttempts) { + continue; + } + return fail("出错了,请晚点再试~"); + } + + if (!run.failed) { + if (attempt > 1) { + logger.warn("creative assistant recovered after retry, attempt={}/{}, workflowRunId={}, taskId={}", + attempt, maxAttempts, run.workflowRunId, run.taskId); + } + return success(run.result); + } + + logger.error("creative assistant workflow failed, attempt={}/{}, workflowRunId={}, taskId={}, " + + "failedNode={}, retryable={}, upstreamError={}", + attempt, maxAttempts, run.workflowRunId, run.taskId, + run.failedNode, run.retryable, run.failureReason); + failedResult = run.result; + + if (!run.retryable || attempt >= maxAttempts) { + break; + } + logger.warn("creative assistant retrying after upstream failure, nextAttempt={}/{}, failedNode={}", + attempt + 1, maxAttempts, run.failedNode); + } + return success(failedResult); + } + + /** + * 上游接口直接以非 2xx 拒绝时抛出的信号。 + * + *

这不是"工作流失败"(那种是 HTTP 200 + {@code status=failed}), + * 而是请求根本没被受理,例如入参不合法、应用被停用。原样回给前端且不重试。 + */ + private static final class CreativeApiFailureException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final transient ApiResult result; + + private CreativeApiFailureException(ApiResult result, String message) { + super(message); + this.result = result; + } + } + + /** + * 一次创意助手工作流的执行结论。 + * + *

{@code result} 是要下发端上的载荷;其余字段只用于记日志和决定是否重试, + * 绝不可混进 {@code result}——失败态一旦带出上游原文,就会泄露 + * {@code workflow_id} / {@code req_id} 等内部标识符(见 issue 01)。 + */ + private static final class CreativeRun { + private final Map result; + private final String workflowRunId; + private final String taskId; + private final String failedNode; + private final String failureReason; + private final boolean failed; + private final boolean retryable; + + private CreativeRun(Map result, String workflowRunId, String taskId, + String failedNode, String failureReason, boolean failed, boolean retryable) { + this.result = result; + this.workflowRunId = workflowRunId; + this.taskId = taskId; + this.failedNode = failedNode; + this.failureReason = briefUpstreamError(failureReason); + this.failed = failed; + this.retryable = retryable; + } + + private static CreativeRun succeeded(Map result, String workflowRunId, String taskId) { + return new CreativeRun(result, workflowRunId, taskId, null, null, false, false); + } + + private static CreativeRun failed(Map result, String workflowRunId, String taskId, + String failedNode, String failureReason) { + return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true, + isRetryableCreativeFailure(failureReason)); + } + } + + /** + * 跑一次创意助手工作流(单次尝试,不含重试)。 + */ + private CreativeRun runCreativeAssistantOnce(Map inputs, Integer loginUserId) + throws IOException { HttpURLConnection connection = null; try { URL url = new URL(aiUrl() + "/workflows/run"); @@ -990,7 +1141,7 @@ public class AiController extends BaseController { connection.setReadTimeout(aiTimeout()); Map payload = new LinkedHashMap<>(); - payload.put("inputs", buildCreativeAssistantInputs(requestBody)); + payload.put("inputs", inputs); payload.put("response_mode", "streaming"); payload.put("user", loginUserId == null ? "0" : loginUserId.toString()); logger.info("creative assistant payload: {}", payload); @@ -1009,12 +1160,16 @@ public class AiController extends BaseController { } catch (Exception ignored) { } String message = responseData == null ? responseBody : JSONUtil.toJSONString(responseData); - return fail("AI创意助手调用失败", message); + // 请求根本没被受理(非 2xx),不是"工作流跑失败", + // 原样交给外壳返回,也不重试——重试同样会被拒。 + throw new CreativeApiFailureException(fail("AI创意助手调用失败", message), message); } StringBuilder reply = new StringBuilder(); String workflowRunId = null; String taskId = null; + String failedNodeTitle = null; + String failedNodeError = null; try (BufferedReader reader = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { @@ -1047,6 +1202,19 @@ public class AiController extends BaseController { } String event = stringValue(data.get("event")); + if ("node_finished".equals(event)) { + // 失败出在哪个节点,只有 node_finished 说得清: + // workflow_finished 的 data.error 只有一坨上游原文,没有节点名。 + Object nodeData = data.get("data"); + if (nodeData instanceof Map + && "failed".equalsIgnoreCase(stringValue(((Map) nodeData).get("status")))) { + failedNodeTitle = stringValue(((Map) nodeData).get("title")); + String nodeError = stringValue(((Map) nodeData).get("error")); + if (StrUtil.isNotBlank(nodeError)) { + failedNodeError = nodeError; + } + } + } String answer = extractAnswer(data); if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) { reply.append(answer); @@ -1060,9 +1228,11 @@ public class AiController extends BaseController { // 失败态:buildCreativeAssistantResult 已给好可读文案, // 这里不得再用流式片段或 taskId 覆盖,也不下发 raw。 clearActiveAiTask(loginUserId, taskId); - logger.warn("creative assistant workflow failed, workflowRunId={}, taskId={}", - workflowRunId, taskId); - return success(result); + String reason = creativeFailureReason(data); + if (StrUtil.isBlank(reason)) { + reason = failedNodeError; + } + return CreativeRun.failed(result, workflowRunId, taskId, failedNodeTitle, reason); } if (StrUtil.isBlank(stringValue(result.get("answer"))) && reply.length() > 0) { result.put("answer", reply.toString()); @@ -1074,7 +1244,7 @@ public class AiController extends BaseController { result.put("taskId", taskId); } clearActiveAiTask(loginUserId, taskId); - return success(result); + return CreativeRun.succeeded(result, workflowRunId, taskId); } } } @@ -1088,10 +1258,7 @@ public class AiController extends BaseController { result.put("taskId", taskId); } clearActiveAiTask(loginUserId, taskId); - return success(result); - } catch (Exception e) { - logger.error("creative assistant execute failed", e); - return fail("出错了,请晚点再试~"); + return CreativeRun.succeeded(result, workflowRunId, taskId); } finally { if (connection != null) { connection.disconnect(); @@ -1099,6 +1266,93 @@ public class AiController extends BaseController { } } + /** + * 取下游工作流失败的上游原文(只用于记日志与归类,绝不下发端上)。 + * + *

优先取 {@code workflow_finished} 的 {@code data.error}, + * 其次看顶层的 {@code error}。 + */ + private String creativeFailureReason(Map responseData) { + if (responseData == null) { + return null; + } + Object data = responseData.get("data"); + if (data instanceof Map) { + String error = stringValue(((Map) data).get("error")); + if (StrUtil.isNotBlank(error)) { + return error; + } + } + return stringValue(responseData.get("error")); + } + + /** + * 失败态给用户看的文案。 + * + *

只做"上游容量不足 → 换一句更贴切的固定文案"这一件事; + * 其余失败(凭证失效、模型停用、内容审核)仍用统一的 + * {@link #CREATIVE_FAILED_MESSAGE},避免把上游原文的语义透给端上。 + */ + private String creativeFailureAnswer(Map responseData) { + if (isUpstreamCapacityFailure(creativeFailureReason(responseData))) { + return CREATIVE_BUSY_MESSAGE; + } + return CREATIVE_FAILED_MESSAGE; + } + + /** + * 上游实例不足(重试有实际收益的那一类)。 + * + *

2026-10-13 内网实例实测:同一 prompt 连跑 6 次,2 次失败于 + * 「即梦AI-图像生成」节点,原文为 + * {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}}, + * 整节点仅耗时 3.1~3.5 秒即返回——即上游没有可用算力而立即拒绝,与内容无关。 + */ + private static boolean isUpstreamCapacityFailure(String reason) { + if (StrUtil.isBlank(reason)) { + return false; + } + String lower = reason.toLowerCase(Locale.ROOT); + return lower.contains("no more instances to retry") + || lower.contains("\"code\":50500") + || lower.contains("code: 50500"); + } + + /** + * 这次失败值不值得原样重跑。 + * + *

只认能明确归因到上游瞬时的故障。凭证失效({@code 50400 Access Denied})、 + * 模型被停用({@code Model disabled})、内容审核拒绝重试无用, + * 重试只会把用户的一次失败拖成两次等待,必须排除。 + */ + private static boolean isRetryableCreativeFailure(String reason) { + if (isUpstreamCapacityFailure(reason)) { + return true; + } + if (StrUtil.isBlank(reason)) { + return false; + } + String lower = reason.toLowerCase(Locale.ROOT); + return lower.contains("502 bad gateway") + || lower.contains("503 service unavailable") + || lower.contains("504 gateway") + || lower.contains("connection reset") + || lower.contains("timed out"); + } + + /** + * 把上游原文裁到可写进单行日志的长度。 + */ + private static String briefUpstreamError(String reason) { + if (reason == null) { + return null; + } + if (reason.length() <= UPSTREAM_ERROR_LOG_MAX_LENGTH) { + return reason; + } + return reason.substring(0, UPSTREAM_ERROR_LOG_MAX_LENGTH) + "...(truncated)"; + } + private Map parseInputs(String inputs) { if (StrUtil.isBlank(inputs)) { return new HashMap<>(); @@ -1723,7 +1977,7 @@ public class AiController extends BaseController { // raw 里含 workflow_id / task_id / req_id 与上游异常文本,端上没有任何消费者, // 一旦下发就会被前端兜底逻辑整段贴进聊天气泡(见 issue 01)。 result.put("status", status); - result.put("answer", CREATIVE_FAILED_MESSAGE); + result.put("answer", creativeFailureAnswer(responseData)); return result; } @@ -2162,19 +2416,33 @@ public class AiController extends BaseController { return success(responseBody); } + /** + * AI 文件上传。 + * + *

Dify 的 /files/upload 是按应用鉴权的:文件绑定在上传时所用的应用凭证上, + * 换一个应用的 Key 去工作流里引用同一个 {@code upload_file_id},会被判 + * {@code Invalid upload file}。2026-10-13 实测(同一张图、同一个 end user): + *

+ * + *

原先这里只有两个分支({@code type} 为空/0 用 chat,否则用 manuscript), + * 因此**任何**喂给创意助手的附件都必然失败。现新增 {@code app} 参数按应用代号选择 + * 上传凭证(代号取值见 {@link com.gxwebsoft.ai.config.DifyProperties.AppKeys#byCode(String)}); + * 不传 {@code app} 时保持既有行为,不影响存量调用方。 + */ @PostMapping("/file/upload") public ApiResult uploadFile(@RequestParam("file") MultipartFile file, @RequestParam(value = "user", required = false) String user, - @RequestParam(value = "type", required = false) Integer type) { + @RequestParam(value = "type", required = false) Integer type, + @RequestParam(value = "app", required = false) String app) { if (file == null || file.isEmpty()) { return fail("文件不能为空"); } File tempFile = null; try { - String token = appKeys().getManuscript(); - if (type == null || type == 0) { - token = appKeys().getChat(); - } + String token = resolveUploadToken(app, type); String originalFilename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload.bin"); String suffix = ""; int suffixIndex = originalFilename.lastIndexOf('.'); @@ -2207,4 +2475,21 @@ public class AiController extends BaseController { } } } + + /** + * 选择 AI 文件上传所用的应用凭证。 + * + * @param app 应用代号(如 {@code creativeAssistant});为空时走旧逻辑 + * @param type 旧参数:为空或 0 用 chat,否则用 manuscript + */ + private String resolveUploadToken(String app, Integer type) { + if (StrUtil.isNotBlank(app)) { + String byCode = appKeys().byCode(app); + if (StrUtil.isNotBlank(byCode)) { + return byCode; + } + logger.warn("unknown AI upload app code: {}, fallback to legacy type-based token", app); + } + return type == null || type == 0 ? appKeys().getChat() : appKeys().getManuscript(); + } }