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:
2026-10-13 19:57:18 +08:00
parent f54e28cffc
commit 8406c90cf3
@@ -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();
}
}