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 这不是"工作流失败"(那种是 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 优先取 {@code workflow_finished} 的 {@code data.error},
+ * 其次看顶层的 {@code error}。
+ */
+ private String creativeFailureReason(Map 只做"上游容量不足 → 换一句更贴切的固定文案"这一件事;
+ * 其余失败(凭证失效、模型停用、内容审核)仍用统一的
+ * {@link #CREATIVE_FAILED_MESSAGE},避免把上游原文的语义透给端上。
+ */
+ private String creativeFailureAnswer(Map 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 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();
+ }
}
+ *
+ *
+ *