fix(ai): 文件上传按应用选择凭证;并入创意助手重试与失败日志(issue 11 / 14)
同一文件内包含两块内容,无法拆分提交:
一、文件上传按应用选择凭证(issue 14,本次)
Dify 的 /files/upload 是按应用鉴权的:实测同一张图、同一个 end user 下,chat Key
上传得到的 file id 喂给创意助手工作流的 file-list 变量,0.2 秒即报
Invalid upload file;用 creativeAssistant Key 上传的同一文件则 succeeded。
而原实现只有两个分支(type 为空/0 用 chat,否则用 manuscript),从不使用创意助手
的 Key,因此任何喂给创意助手的附件都必然失败 —— views/gxmu/creativeAssistant 表单页
用的是同一个接口,它的附件功能同样是坏的(既有缺陷)。
新增 app 参数按应用代号取凭证(复用 DifyProperties.AppKeys#byCode),不传时保持既有
行为;未知代号回退旧逻辑并打 WARN。新增 resolveUploadToken。
二、创意助手出图失败的重试与可观测(issue 11,前次会话已完成验收、此前未提交)
executeCreativeAssistantWithRetry / runCreativeAssistantOnce / CreativeRun /
CreativeApiFailureException / creativeFailureReason / creativeFailureAnswer /
isUpstreamCapacityFailure / isRetryableCreativeFailure / briefUpstreamError:
上游 50500 算力不足时自动重跑一次,凭证失效、模型停用等不重试;每次失败按 ERROR
打结构化日志(含失败节点名与上游原文截断)。
This commit is contained in:
@@ -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 = "生成失败,请稍后重试";
|
||||
|
||||
/**
|
||||
* 上游生成服务容量不足时给用户看的文案。
|
||||
*
|
||||
* <p>「即梦AI-图像生成」偶发 {@code 50500 Internal Error} /
|
||||
* {@code status_code 110001 no more instances to retry},是上游实例不足的**瞬时**故障,
|
||||
* 与凭证失效、内容审核拒绝不是一回事:重试通常即成功,所以文案也把用户往"过一会儿再来"引,
|
||||
* 而不是笼统的"生成失败"。同样只下发这句固定文案,不带任何上游原文。
|
||||
*/
|
||||
private static final String CREATIVE_BUSY_MESSAGE = "生成服务繁忙,请稍后重试";
|
||||
|
||||
/**
|
||||
* 创意助手工作流因上游瞬时故障失败后的自动重试次数。
|
||||
*
|
||||
* <p>一次出图要连调两次「即梦AI-图像生成」,单次约 1/5 概率撞上上游容量不足,
|
||||
* 不重试时整体失败率约三成(2026-10-13 实测:同一 prompt 连跑 6 次,2 次失败)。
|
||||
* 重试一次可把失败率压到约一成,代价是多等一次工作流(实测 45~105 秒),
|
||||
* 距离 {@link #AI_REQUEST_TIMEOUT} 还有充足余量。
|
||||
*
|
||||
* <p>只对 {@link #isRetryableCreativeFailure(String)} 认定的瞬时故障重试;
|
||||
* 配置类失败重试无用,重试反而让用户白等一倍时间。
|
||||
*/
|
||||
private static final int CREATIVE_ASSISTANT_MAX_RETRY = 1;
|
||||
|
||||
/**
|
||||
* 写进日志的上游错误原文长度上限。
|
||||
*
|
||||
* <p>上游报错里裹着完整的 {@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创意助手执行外壳:失败可重试时原样重跑一次工作流。
|
||||
*
|
||||
* <p>为什么需要它:出图分支要连调两次「即梦AI-图像生成」,而上游火山引擎会
|
||||
* 偶发 {@code 50500 no more instances to retry}(实例不足,3 秒内即拒绝),
|
||||
* 实测整体失败率约三成。这类失败与内容、参数、凭证都无关,重跑一次通常就成。
|
||||
*
|
||||
* <p>每次失败都以 ERROR 记录失败节点与上游错误原文:生产日志级别是
|
||||
* {@code com.gxwebsoft: ERROR},原先这里用 {@code logger.warn},于是出现
|
||||
* "端上说生成失败、后端日志里却什么都没有"的情况。
|
||||
*
|
||||
* @param inputs 已构造好的工作流入参
|
||||
* @param loginUserId 用户 id
|
||||
* @return 成功结果为工作流产出;重试耗尽仍失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
|
||||
*/
|
||||
private ApiResult<?> executeCreativeAssistantWithRetry(Map<String, Object> inputs, Integer loginUserId) {
|
||||
int maxAttempts = CREATIVE_ASSISTANT_MAX_RETRY + 1;
|
||||
Map<String, Object> 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 拒绝时抛出的信号。
|
||||
*
|
||||
* <p>这不是"工作流失败"(那种是 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次创意助手工作流的执行结论。
|
||||
*
|
||||
* <p>{@code result} 是要下发端上的载荷;其余字段只用于记日志和决定是否重试,
|
||||
* 绝不可混进 {@code result}——失败态一旦带出上游原文,就会泄露
|
||||
* {@code workflow_id} / {@code req_id} 等内部标识符(见 issue 01)。
|
||||
*/
|
||||
private static final class CreativeRun {
|
||||
private final Map<String, Object> 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<String, Object> 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<String, Object> result, String workflowRunId, String taskId) {
|
||||
return new CreativeRun(result, workflowRunId, taskId, null, null, false, false);
|
||||
}
|
||||
|
||||
private static CreativeRun failed(Map<String, Object> result, String workflowRunId, String taskId,
|
||||
String failedNode, String failureReason) {
|
||||
return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true,
|
||||
isRetryableCreativeFailure(failureReason));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一次创意助手工作流(单次尝试,不含重试)。
|
||||
*/
|
||||
private CreativeRun runCreativeAssistantOnce(Map<String, Object> 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<String, Object> 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 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取下游工作流失败的上游原文(只用于记日志与归类,绝不下发端上)。
|
||||
*
|
||||
* <p>优先取 {@code workflow_finished} 的 {@code data.error},
|
||||
* 其次看顶层的 {@code error}。
|
||||
*/
|
||||
private String creativeFailureReason(Map<String, Object> 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"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败态给用户看的文案。
|
||||
*
|
||||
* <p>只做"上游容量不足 → 换一句更贴切的固定文案"这一件事;
|
||||
* 其余失败(凭证失效、模型停用、内容审核)仍用统一的
|
||||
* {@link #CREATIVE_FAILED_MESSAGE},避免把上游原文的语义透给端上。
|
||||
*/
|
||||
private String creativeFailureAnswer(Map<String, Object> responseData) {
|
||||
if (isUpstreamCapacityFailure(creativeFailureReason(responseData))) {
|
||||
return CREATIVE_BUSY_MESSAGE;
|
||||
}
|
||||
return CREATIVE_FAILED_MESSAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游实例不足(重试有实际收益的那一类)。
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次失败值不值得原样重跑。
|
||||
*
|
||||
* <p>只认能明确归因到上游瞬时的故障。凭证失效({@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<String, Object> 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 文件上传。
|
||||
*
|
||||
* <p><b>Dify 的 /files/upload 是按应用鉴权的</b>:文件绑定在上传时所用的应用凭证上,
|
||||
* 换一个应用的 Key 去工作流里引用同一个 {@code upload_file_id},会被判
|
||||
* {@code Invalid upload file}。2026-10-13 实测(同一张图、同一个 end user):
|
||||
* <ul>
|
||||
* <li>chat Key 上传 → 给「AI 创意助手」的 file-list 变量引用 → 0.2s 失败 {@code Invalid upload file};</li>
|
||||
* <li>creativeAssistant Key 上传 → 同一工作流 succeeded(走完分类器 → LLM → 出图)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>原先这里只有两个分支({@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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user