feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理 - 支持输入草稿恢复、图片交互和模型图片能力约束 - 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package tech.easyflow.agent.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Agent 图片上传、草稿和对象存储配置。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "easyflow.agent.media")
|
||||
public class AgentMediaProperties {
|
||||
|
||||
/** x-file-storage 中的私有 Agent 媒体平台。 */
|
||||
private String platform = "minio-agent-media";
|
||||
/** 单次对话最多图片数。 */
|
||||
private int maxImageCount = 5;
|
||||
/** 单张图片最大字节数。 */
|
||||
private long maxImageBytes = 10L * 1024L * 1024L;
|
||||
/** 单张图片最大像素数。 */
|
||||
private long maxImagePixels = 40_000_000L;
|
||||
/** 临时图片有效期。 */
|
||||
private Duration uploadTtl = Duration.ofHours(24);
|
||||
/** 输入草稿有效期。 */
|
||||
private Duration composerDraftTtl = Duration.ofHours(24);
|
||||
/** 过期对象清理周期。 */
|
||||
private Duration cleanupInterval = Duration.ofMinutes(10);
|
||||
|
||||
/** @return 私有媒体平台名称 */
|
||||
public String getPlatform() { return platform; }
|
||||
/** @param platform 私有媒体平台名称 */
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
/** @return 单次图片上限 */
|
||||
public int getMaxImageCount() { return maxImageCount; }
|
||||
/** @param maxImageCount 单次图片上限 */
|
||||
public void setMaxImageCount(int maxImageCount) { this.maxImageCount = maxImageCount; }
|
||||
/** @return 单图字节上限 */
|
||||
public long getMaxImageBytes() { return maxImageBytes; }
|
||||
/** @param maxImageBytes 单图字节上限 */
|
||||
public void setMaxImageBytes(long maxImageBytes) { this.maxImageBytes = maxImageBytes; }
|
||||
/** @return 单图像素上限 */
|
||||
public long getMaxImagePixels() { return maxImagePixels; }
|
||||
/** @param maxImagePixels 单图像素上限 */
|
||||
public void setMaxImagePixels(long maxImagePixels) { this.maxImagePixels = maxImagePixels; }
|
||||
/** @return 临时图片 TTL */
|
||||
public Duration getUploadTtl() { return uploadTtl; }
|
||||
/** @param uploadTtl 临时图片 TTL */
|
||||
public void setUploadTtl(Duration uploadTtl) { this.uploadTtl = uploadTtl; }
|
||||
/** @return 草稿 TTL */
|
||||
public Duration getComposerDraftTtl() { return composerDraftTtl; }
|
||||
/** @param composerDraftTtl 草稿 TTL */
|
||||
public void setComposerDraftTtl(Duration composerDraftTtl) { this.composerDraftTtl = composerDraftTtl; }
|
||||
/** @return 清理周期 */
|
||||
public Duration getCleanupInterval() { return cleanupInterval; }
|
||||
/** @param cleanupInterval 清理周期 */
|
||||
public void setCleanupInterval(Duration cleanupInterval) { this.cleanupInterval = cleanupInterval; }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* Agent 模块自动配置。
|
||||
@@ -11,6 +12,7 @@ import org.springframework.context.annotation.ComponentScan;
|
||||
@AutoConfiguration
|
||||
@MapperScan("tech.easyflow.agent.mapper")
|
||||
@ComponentScan("tech.easyflow.agent")
|
||||
@EnableConfigurationProperties(AgentRuntimeProperties.class)
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties({AgentRuntimeProperties.class, AgentMediaProperties.class})
|
||||
public class AgentModuleConfig {
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public class AgentChatRequest {
|
||||
private BigInteger agentId;
|
||||
private BigInteger sessionId;
|
||||
private String prompt;
|
||||
private List<String> imageUploadIds = new ArrayList<>();
|
||||
private List<AgentChatCapability> capabilities = new ArrayList<>();
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,22 @@ public class AgentChatRequest {
|
||||
*/
|
||||
public void setPrompt(String prompt) { this.prompt = prompt; }
|
||||
|
||||
/**
|
||||
* 获取本轮临时图片上传 ID。
|
||||
*
|
||||
* @return 图片上传 ID
|
||||
*/
|
||||
public List<String> getImageUploadIds() { return imageUploadIds; }
|
||||
|
||||
/**
|
||||
* 设置本轮临时图片上传 ID。
|
||||
*
|
||||
* @param imageUploadIds 图片上传 ID
|
||||
*/
|
||||
public void setImageUploadIds(List<String> imageUploadIds) {
|
||||
this.imageUploadIds = imageUploadIds == null ? new ArrayList<>() : new ArrayList<>(imageUploadIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本次聊天启用的临时能力。
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Agent 草稿态纯文本试用请求。
|
||||
@@ -16,6 +17,7 @@ public class AgentDraftChatRequest {
|
||||
private List<AgentKnowledgeBinding> knowledgeBindings;
|
||||
private String sessionId;
|
||||
private String prompt;
|
||||
private List<String> imageUploadIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 获取 Agent 草稿快照。
|
||||
@@ -106,4 +108,22 @@ public class AgentDraftChatRequest {
|
||||
public void setPrompt(String prompt) {
|
||||
this.prompt = prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本轮临时图片上传 ID。
|
||||
*
|
||||
* @return 图片上传 ID
|
||||
*/
|
||||
public List<String> getImageUploadIds() {
|
||||
return imageUploadIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置本轮临时图片上传 ID。
|
||||
*
|
||||
* @param imageUploadIds 图片上传 ID
|
||||
*/
|
||||
public void setImageUploadIds(List<String> imageUploadIds) {
|
||||
this.imageUploadIds = imageUploadIds == null ? new ArrayList<>() : new ArrayList<>(imageUploadIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
||||
import com.easyagents.agent.runtime.message.AgentMediaBlock;
|
||||
import com.easyagents.agent.runtime.message.AgentTextBlock;
|
||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
|
||||
import org.slf4j.Logger;
|
||||
@@ -27,6 +29,9 @@ import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
|
||||
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||
import tech.easyflow.agent.runtime.session.EasyFlowAgentSessionStore;
|
||||
import tech.easyflow.agent.runtime.media.AgentBoundMedia;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaUploadRecord;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Mcp;
|
||||
@@ -36,6 +41,7 @@ import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.McpService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
@@ -56,6 +62,8 @@ import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Agent 管理端运行服务。
|
||||
@@ -107,6 +115,10 @@ public class AgentRunService {
|
||||
private McpService mcpService;
|
||||
@Resource
|
||||
private DocumentCollectionService documentCollectionService;
|
||||
@Resource
|
||||
private ModelService modelService;
|
||||
@Resource
|
||||
private AgentMediaService agentMediaService;
|
||||
|
||||
/**
|
||||
* 启动 Agent 聊天。
|
||||
@@ -134,6 +146,10 @@ public class AgentRunService {
|
||||
AgentChatCapabilityService.AgentChatCapabilityResolution capabilityResolution =
|
||||
agentChatCapabilityService.apply(agent, chatRequest.getCapabilities(), account);
|
||||
agent = capabilityResolution.agent();
|
||||
assertImageCapability(agent, chatRequest.getImageUploadIds());
|
||||
List<AgentMediaUploadRecord> mediaUploads = agentMediaService.requireUploads(
|
||||
chatRequest.getImageUploadIds(), AgentMediaService.MODE_FORMAL,
|
||||
chatRequest.getAgentId().toString(), sessionId.toString(), account);
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
String traceId = UUID.randomUUID().toString();
|
||||
// 组建会话上下文必要信息
|
||||
@@ -143,7 +159,7 @@ public class AgentRunService {
|
||||
}
|
||||
applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession);
|
||||
// 执行对话
|
||||
return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(),
|
||||
return run(agent, chatRequest.getPrompt(), mediaUploads, account, requestId, traceId, sessionId.toString(),
|
||||
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore);
|
||||
}
|
||||
|
||||
@@ -163,16 +179,22 @@ public class AgentRunService {
|
||||
if (runtimeSessionId == null || runtimeSessionId.isBlank()) {
|
||||
runtimeSessionId = "agent-draft-" + new SnowFlakeIDKeyGenerator().nextId();
|
||||
}
|
||||
assertImageCapability(agent, draftRequest.getImageUploadIds());
|
||||
List<AgentMediaUploadRecord> mediaUploads = agentMediaService.requireUploads(
|
||||
draftRequest.getImageUploadIds(), AgentMediaService.MODE_DRAFT,
|
||||
agent.getId().toString(), runtimeSessionId, account);
|
||||
BigInteger chatSessionId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId());
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
String traceId = UUID.randomUUID().toString();
|
||||
ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, chatSessionId, draftRequest.getPrompt(), account, DRAFT_ASSISTANT_CODE);
|
||||
return run(agent, draftRequest.getPrompt(), requestId, traceId, runtimeSessionId,
|
||||
return run(agent, draftRequest.getPrompt(), mediaUploads, account, requestId, traceId, runtimeSessionId,
|
||||
DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore);
|
||||
}
|
||||
|
||||
private SseEmitter run(Agent agent,
|
||||
String prompt,
|
||||
List<AgentMediaUploadRecord> mediaUploads,
|
||||
LoginAccount account,
|
||||
String requestId,
|
||||
String traceId,
|
||||
String runtimeSessionId,
|
||||
@@ -185,6 +207,7 @@ public class AgentRunService {
|
||||
AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId);
|
||||
boolean submitted = false;
|
||||
try {
|
||||
List<AgentBoundMedia> boundMedia;
|
||||
if (persistChatlog) {
|
||||
// 持久化会话初始信息
|
||||
chatRuntimeManager.prepareSession(chatContext);
|
||||
@@ -192,9 +215,23 @@ public class AgentRunService {
|
||||
chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开,Agent 运行已取消"));
|
||||
return chatSseEmitter.getEmitter();
|
||||
}
|
||||
chatRuntimeManager.recordUserMessage(chatContext, buildUserRuntimeMessage(chatContext, prompt));
|
||||
BigInteger messageId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId());
|
||||
boundMedia = agentMediaService.bindFormal(mediaUploads, chatContext.getSessionId().toString(),
|
||||
messageId.toString(), account);
|
||||
chatRuntimeManager.recordUserMessage(chatContext,
|
||||
buildUserRuntimeMessage(chatContext, messageId, prompt, boundMedia));
|
||||
if (!sendInputAccepted(chatSseEmitter, chatContext.getSessionId(), messageId, boundMedia)) {
|
||||
chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开,Agent 运行已取消"));
|
||||
return chatSseEmitter.getEmitter();
|
||||
}
|
||||
} else {
|
||||
boundMedia = agentMediaService.bindDraft(mediaUploads);
|
||||
if (!sendInputAccepted(chatSseEmitter, null, null, boundMedia)) {
|
||||
return chatSseEmitter.getEmitter();
|
||||
}
|
||||
}
|
||||
threadPoolTaskExecutor.execute(() -> startRuntime(agent, prompt, requestId, traceId, runtimeSessionId,
|
||||
AgentMessage userMessage = buildAgentMessage(prompt, boundMedia);
|
||||
threadPoolTaskExecutor.execute(() -> startRuntime(agent, userMessage, account, requestId, traceId, runtimeSessionId,
|
||||
assistantCode, chatContext, chatSseEmitter, persistChatlog, runtimeSessionStore, lockHandle));
|
||||
submitted = true;
|
||||
return chatSseEmitter.getEmitter();
|
||||
@@ -342,7 +379,8 @@ public class AgentRunService {
|
||||
}
|
||||
|
||||
private void startRuntime(Agent agent,
|
||||
String prompt,
|
||||
AgentMessage userMessage,
|
||||
LoginAccount account,
|
||||
String requestId,
|
||||
String traceId,
|
||||
String runtimeSessionId,
|
||||
@@ -374,6 +412,7 @@ public class AgentRunService {
|
||||
request.setToolInvokers(bundle.getToolInvokers());
|
||||
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
|
||||
request.setSessionStore(runtimeSessionStore);
|
||||
request.setMediaResolver(agentMediaService.runtimeResolver(account));
|
||||
request.getMetadata().put("assistantCode", assistantCode);
|
||||
runtime.init(request);
|
||||
// 注册会话运行时管理
|
||||
@@ -409,7 +448,7 @@ public class AgentRunService {
|
||||
return;
|
||||
}
|
||||
agentRunRegistry.bindSubscription(requestId,
|
||||
runtime.stream(AgentMessage.text(AgentMessageRole.USER, prompt)).subscribe(
|
||||
runtime.stream(userMessage).subscribe(
|
||||
runContext.eventConsumer(),
|
||||
runContext.errorConsumer(),
|
||||
runContext.completionHandler()
|
||||
@@ -1012,8 +1051,8 @@ public class AgentRunService {
|
||||
* @return 最长 200 字符的会话标题
|
||||
*/
|
||||
private String toSessionTitle(String prompt) {
|
||||
if (prompt == null) {
|
||||
return null;
|
||||
if (prompt == null || prompt.isBlank()) {
|
||||
return "图片对话";
|
||||
}
|
||||
return prompt.length() > 200 ? prompt.substring(0, 200) : prompt;
|
||||
}
|
||||
@@ -1028,17 +1067,43 @@ public class AgentRunService {
|
||||
return context;
|
||||
}
|
||||
|
||||
private ChatRuntimeMessage buildUserRuntimeMessage(ChatRuntimeContext context, String prompt) {
|
||||
private ChatRuntimeMessage buildUserRuntimeMessage(ChatRuntimeContext context,
|
||||
BigInteger messageId,
|
||||
String prompt,
|
||||
List<AgentBoundMedia> media) {
|
||||
ChatRuntimeMessage message = new ChatRuntimeMessage();
|
||||
message.setMessageId(messageId);
|
||||
message.setRole("user");
|
||||
message.setContentType("TEXT");
|
||||
message.setContentType(media == null || media.isEmpty() ? "TEXT" : "MULTIMODAL");
|
||||
message.setContentText(prompt);
|
||||
if (media != null && !media.isEmpty()) {
|
||||
message.getContentPayload().put("images", media.stream().map(AgentBoundMedia::payload).toList());
|
||||
}
|
||||
message.setCreatedAt(new Date());
|
||||
message.setSenderId(context.getUserId());
|
||||
message.setSenderName(context.getUserName());
|
||||
return message;
|
||||
}
|
||||
|
||||
private AgentMessage buildAgentMessage(String prompt, List<AgentBoundMedia> media) {
|
||||
AgentMessage message = new AgentMessage();
|
||||
message.setRole(AgentMessageRole.USER);
|
||||
List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>();
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
blocks.add(new AgentTextBlock(prompt));
|
||||
}
|
||||
if (media != null) {
|
||||
for (AgentBoundMedia item : media) {
|
||||
AgentMediaBlock image = new AgentMediaBlock("image");
|
||||
image.setReference(item.reference());
|
||||
image.setMimeType(item.mimeType());
|
||||
blocks.add(image);
|
||||
}
|
||||
}
|
||||
message.setContentBlocks(blocks);
|
||||
return message;
|
||||
}
|
||||
|
||||
private ChatRuntimeMessage buildAssistantRuntimeMessage(ChatRuntimeContext context, String content) {
|
||||
return buildAssistantRuntimeMessage(context, content, new ChatAssistantAccumulator(), List.of());
|
||||
}
|
||||
@@ -1092,11 +1157,29 @@ public class AgentRunService {
|
||||
Map.of("sessionId", sessionId.toString()));
|
||||
}
|
||||
|
||||
private boolean sendInputAccepted(ChatSseEmitter chatSseEmitter,
|
||||
BigInteger sessionId,
|
||||
BigInteger messageId,
|
||||
List<AgentBoundMedia> boundMedia) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
if (sessionId != null) {
|
||||
payload.put("sessionId", sessionId.toString());
|
||||
}
|
||||
if (messageId != null) {
|
||||
payload.put("messageId", messageId.toString());
|
||||
}
|
||||
if (boundMedia != null && !boundMedia.isEmpty()) {
|
||||
payload.put("images", boundMedia.stream().map(AgentBoundMedia::payload).toList());
|
||||
}
|
||||
return sendEnvelope(chatSseEmitter, ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED, payload);
|
||||
}
|
||||
|
||||
private void validateChatRequest(AgentChatRequest request) {
|
||||
if (request == null || request.getAgentId() == null) {
|
||||
throw new BusinessException("Agent ID 不能为空");
|
||||
}
|
||||
if (request.getPrompt() == null || request.getPrompt().isBlank()) {
|
||||
if ((request.getPrompt() == null || request.getPrompt().isBlank())
|
||||
&& (request.getImageUploadIds() == null || request.getImageUploadIds().isEmpty())) {
|
||||
throw new BusinessException("Agent 输入不能为空");
|
||||
}
|
||||
}
|
||||
@@ -1108,11 +1191,24 @@ public class AgentRunService {
|
||||
if (request.getAgent().getModelId() == null) {
|
||||
throw new BusinessException("Agent 模型不能为空");
|
||||
}
|
||||
if (request.getPrompt() == null || request.getPrompt().isBlank()) {
|
||||
if ((request.getPrompt() == null || request.getPrompt().isBlank())
|
||||
&& (request.getImageUploadIds() == null || request.getImageUploadIds().isEmpty())) {
|
||||
throw new BusinessException("Agent 输入不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertImageCapability(Agent agent, List<String> imageUploadIds) {
|
||||
if (imageUploadIds == null || imageUploadIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
tech.easyflow.ai.entity.Model model = agent == null || agent.getModelId() == null
|
||||
? null
|
||||
: modelService.getModelInstance(agent.getModelId());
|
||||
if (model == null || !Boolean.TRUE.equals(model.getSupportImage())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "当前 Agent 模型未启用多模态图片能力");
|
||||
}
|
||||
}
|
||||
|
||||
private LoginAccount requireCurrentLoginAccount() {
|
||||
try {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
|
||||
@@ -112,6 +112,8 @@ public class AgentRuntimeCompiler {
|
||||
spec.setBaseUrl(stringValue(config, "baseUrl", model.getEndpoint()));
|
||||
spec.setEndpointPath(stringValue(config, "endpointPath", model.getRequestPath()));
|
||||
spec.setApiKey(stringValue(config, "apiKey", model.getApiKey()));
|
||||
spec.setSupportImage(Boolean.TRUE.equals(model.getSupportImage()));
|
||||
spec.setSupportImageBase64Only(Boolean.TRUE.equals(model.getSupportImageB64Only()));
|
||||
spec.getMetadata().put("modelId", model.getId());
|
||||
return spec;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package tech.easyflow.agent.runtime.composer;
|
||||
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可跨刷新恢复的 Agent 聊天输入草稿。
|
||||
*/
|
||||
public class AgentComposerDraft {
|
||||
|
||||
private String mode;
|
||||
private String agentId;
|
||||
private String sessionId;
|
||||
private String text;
|
||||
private List<String> imageUploadIds = new ArrayList<>();
|
||||
private List<AgentMediaUploadView> images = new ArrayList<>();
|
||||
private long revision;
|
||||
private Instant expiresAt;
|
||||
|
||||
/** @return 聊天模式 */
|
||||
public String getMode() { return mode; }
|
||||
/** @param mode 聊天模式 */
|
||||
public void setMode(String mode) { this.mode = mode; }
|
||||
/** @return Agent ID */
|
||||
public String getAgentId() { return agentId; }
|
||||
/** @param agentId Agent ID */
|
||||
public void setAgentId(String agentId) { this.agentId = agentId; }
|
||||
/** @return 会话 ID */
|
||||
public String getSessionId() { return sessionId; }
|
||||
/** @param sessionId 会话 ID */
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
/** @return 输入文本 */
|
||||
public String getText() { return text; }
|
||||
/** @param text 输入文本 */
|
||||
public void setText(String text) { this.text = text; }
|
||||
/** @return 图片上传 ID */
|
||||
public List<String> getImageUploadIds() { return imageUploadIds; }
|
||||
/** @param imageUploadIds 图片上传 ID */
|
||||
public void setImageUploadIds(List<String> imageUploadIds) {
|
||||
this.imageUploadIds = imageUploadIds == null ? new ArrayList<>() : new ArrayList<>(imageUploadIds);
|
||||
}
|
||||
/** @return 图片展示信息 */
|
||||
public List<AgentMediaUploadView> getImages() { return images; }
|
||||
/** @param images 图片展示信息 */
|
||||
public void setImages(List<AgentMediaUploadView> images) {
|
||||
this.images = images == null ? new ArrayList<>() : new ArrayList<>(images);
|
||||
}
|
||||
/** @return 草稿修订号 */
|
||||
public long getRevision() { return revision; }
|
||||
/** @param revision 草稿修订号 */
|
||||
public void setRevision(long revision) { this.revision = revision; }
|
||||
/** @return 过期时间 */
|
||||
public Instant getExpiresAt() { return expiresAt; }
|
||||
/** @param expiresAt 过期时间 */
|
||||
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package tech.easyflow.agent.runtime.composer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import tech.easyflow.agent.config.AgentMediaProperties;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaUploadRecord;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 基于 Redis 的 Agent 输入草稿与当前未发送会话管理服务。
|
||||
*/
|
||||
@Service
|
||||
public class AgentComposerDraftService {
|
||||
|
||||
private static final String DRAFT_PREFIX = "easyflow:agent:composer:draft:";
|
||||
private static final String REVISION_PREFIX = "easyflow:agent:composer:revision:";
|
||||
private static final String ACTIVE_PREFIX = "easyflow:agent:composer:active:";
|
||||
private static final DefaultRedisScript<Long> SAVE_SCRIPT = new DefaultRedisScript<>("""
|
||||
local current = redis.call('GET', KEYS[2])
|
||||
if current and tonumber(current) ~= tonumber(ARGV[1]) then
|
||||
return -1
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[4])
|
||||
redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[4])
|
||||
redis.call('SET', KEYS[3], ARGV[5], 'EX', ARGV[4])
|
||||
return tonumber(ARGV[3])
|
||||
""", Long.class);
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentMediaProperties properties;
|
||||
private final AgentMediaService mediaService;
|
||||
|
||||
/**
|
||||
* 创建输入草稿服务。
|
||||
*
|
||||
* @param redisTemplate Redis 模板
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param properties Agent 媒体配置
|
||||
* @param mediaService Agent 媒体服务
|
||||
*/
|
||||
public AgentComposerDraftService(StringRedisTemplate redisTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
AgentMediaProperties properties,
|
||||
AgentMediaService mediaService) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
this.mediaService = mediaService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为正式或草稿聊天预分配稳定会话 ID。
|
||||
*
|
||||
* @param mode 聊天模式
|
||||
* @return 会话信息
|
||||
*/
|
||||
public AgentComposerSession allocateSession(String mode) {
|
||||
String safeMode = mode(mode);
|
||||
long id = new SnowFlakeIDKeyGenerator().nextId();
|
||||
return new AgentComposerSession(safeMode,
|
||||
AgentMediaService.MODE_DRAFT.equals(safeMode) ? "agent-draft-" + id : String.valueOf(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存输入草稿并对相关图片续期。
|
||||
*
|
||||
* @param draft 草稿内容
|
||||
* @param account 当前账号
|
||||
* @return 保存后的草稿
|
||||
*/
|
||||
public AgentComposerDraft save(AgentComposerDraft draft, LoginAccount account) {
|
||||
if (draft == null) {
|
||||
throw badRequest("聊天草稿不能为空");
|
||||
}
|
||||
Identity identity = identity(account);
|
||||
String safeMode = mode(draft.getMode());
|
||||
String agentId = text(draft.getAgentId(), "Agent ID 不能为空");
|
||||
String sessionId = text(draft.getSessionId(), "会话 ID 不能为空");
|
||||
validateSessionId(safeMode, sessionId);
|
||||
List<AgentMediaUploadRecord> uploads = mediaService.requireUploads(draft.getImageUploadIds(), safeMode,
|
||||
agentId, sessionId, account);
|
||||
for (AgentMediaUploadRecord upload : uploads) {
|
||||
mediaService.bindDraft(List.of(upload));
|
||||
}
|
||||
draft.setMode(safeMode);
|
||||
draft.setAgentId(agentId);
|
||||
draft.setSessionId(sessionId);
|
||||
draft.setText(draft.getText() == null ? "" : draft.getText());
|
||||
if (draft.getText().length() > 100_000) {
|
||||
throw badRequest("输入内容过长");
|
||||
}
|
||||
draft.setImages(uploads.stream().map(this::toView).toList());
|
||||
long expectedRevision = Math.max(0L, draft.getRevision());
|
||||
long nextRevision = expectedRevision + 1L;
|
||||
draft.setRevision(nextRevision);
|
||||
Duration ttl = draftTtl();
|
||||
draft.setExpiresAt(Instant.now().plus(ttl));
|
||||
String scope = scope(identity, safeMode, agentId, sessionId);
|
||||
try {
|
||||
Long result = redisTemplate.execute(SAVE_SCRIPT,
|
||||
List.of(DRAFT_PREFIX + scope, REVISION_PREFIX + scope, activeKey(identity, safeMode, agentId)),
|
||||
String.valueOf(expectedRevision), objectMapper.writeValueAsString(draft),
|
||||
String.valueOf(nextRevision), String.valueOf(Math.max(1L, ttl.toSeconds())), sessionId);
|
||||
if (result == null || result < 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "聊天草稿已在其他窗口更新,请刷新后重试");
|
||||
}
|
||||
return draft;
|
||||
} catch (ResponseStatusException error) {
|
||||
throw error;
|
||||
} catch (Exception error) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "聊天草稿保存失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定会话或最近未发送会话的草稿,读取不延长 TTL。
|
||||
*
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID,可为空
|
||||
* @param account 当前账号
|
||||
* @return 草稿
|
||||
*/
|
||||
public Optional<AgentComposerDraft> get(String mode,
|
||||
String agentId,
|
||||
String sessionId,
|
||||
LoginAccount account) {
|
||||
Identity identity = identity(account);
|
||||
String safeMode = mode(mode);
|
||||
String safeAgentId = text(agentId, "Agent ID 不能为空");
|
||||
String resolvedSessionId = sessionId;
|
||||
if (!StringUtils.hasText(resolvedSessionId)) {
|
||||
resolvedSessionId = redisTemplate.opsForValue().get(activeKey(identity, safeMode, safeAgentId));
|
||||
}
|
||||
if (!StringUtils.hasText(resolvedSessionId)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
validateSessionId(safeMode, resolvedSessionId);
|
||||
String value = redisTemplate.opsForValue().get(DRAFT_PREFIX
|
||||
+ scope(identity, safeMode, safeAgentId, resolvedSessionId));
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
AgentComposerDraft draft = objectMapper.readValue(value, AgentComposerDraft.class);
|
||||
List<AgentMediaUploadRecord> uploads = availableUploads(draft.getImageUploadIds(), safeMode,
|
||||
safeAgentId, resolvedSessionId, account);
|
||||
draft.setImageUploadIds(uploads.stream().map(AgentMediaUploadRecord::getUploadId).toList());
|
||||
draft.setImages(uploads.stream().map(this::toView).toList());
|
||||
return Optional.of(draft);
|
||||
} catch (Exception error) {
|
||||
if (error instanceof ResponseStatusException responseError) {
|
||||
throw responseError;
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "聊天草稿读取失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取仍有效的草稿图片,跳过自然过期项并保留权限或归属异常。
|
||||
*
|
||||
* @param uploadIds 图片上传 ID
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param account 当前账号
|
||||
* @return 仍有效且保持原顺序的图片
|
||||
*/
|
||||
private List<AgentMediaUploadRecord> availableUploads(List<String> uploadIds,
|
||||
String mode,
|
||||
String agentId,
|
||||
String sessionId,
|
||||
LoginAccount account) {
|
||||
List<AgentMediaUploadRecord> available = new ArrayList<>();
|
||||
for (String uploadId : uploadIds == null ? List.<String>of() : uploadIds) {
|
||||
try {
|
||||
available.addAll(mediaService.requireUploads(List.of(uploadId), mode, agentId, sessionId, account));
|
||||
} catch (ResponseStatusException error) {
|
||||
if (error.getStatusCode() == HttpStatus.BAD_REQUEST
|
||||
&& "图片已过期,请重新上传".equals(error.getReason())) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除已发送或主动清空的输入草稿。
|
||||
*
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param account 当前账号
|
||||
*/
|
||||
public void delete(String mode, String agentId, String sessionId, LoginAccount account) {
|
||||
delete(mode, agentId, sessionId, List.of(), true, account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除输入草稿,并按调用语义选择是否清理临时图片。
|
||||
*
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param requestedUploadIds 调用方当前持有的上传 ID
|
||||
* @param deleteUploads 是否删除临时图片
|
||||
* @param account 当前账号
|
||||
*/
|
||||
public void delete(String mode,
|
||||
String agentId,
|
||||
String sessionId,
|
||||
List<String> requestedUploadIds,
|
||||
boolean deleteUploads,
|
||||
LoginAccount account) {
|
||||
Identity identity = identity(account);
|
||||
String safeMode = mode(mode);
|
||||
String safeAgentId = text(agentId, "Agent ID 不能为空");
|
||||
String safeSessionId = text(sessionId, "会话 ID 不能为空");
|
||||
validateSessionId(safeMode, safeSessionId);
|
||||
String scope = scope(identity, safeMode, safeAgentId, safeSessionId);
|
||||
String draftKey = DRAFT_PREFIX + scope;
|
||||
if (deleteUploads) {
|
||||
LinkedHashSet<String> uploadIds = new LinkedHashSet<>();
|
||||
String serializedDraft = redisTemplate.opsForValue().get(draftKey);
|
||||
if (StringUtils.hasText(serializedDraft)) {
|
||||
try {
|
||||
AgentComposerDraft storedDraft = objectMapper.readValue(serializedDraft, AgentComposerDraft.class);
|
||||
if (storedDraft.getImageUploadIds() != null) {
|
||||
uploadIds.addAll(storedDraft.getImageUploadIds());
|
||||
}
|
||||
} catch (Exception error) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "聊天草稿删除失败", error);
|
||||
}
|
||||
}
|
||||
if (requestedUploadIds != null) {
|
||||
uploadIds.addAll(requestedUploadIds);
|
||||
}
|
||||
mediaService.deleteUploadsForScope(new ArrayList<>(uploadIds), safeMode,
|
||||
safeAgentId, safeSessionId, account);
|
||||
}
|
||||
redisTemplate.delete(List.of(draftKey, REVISION_PREFIX + scope));
|
||||
String activeKey = activeKey(identity, safeMode, safeAgentId);
|
||||
String activeSession = redisTemplate.opsForValue().get(activeKey);
|
||||
if (safeSessionId.equals(activeSession)) {
|
||||
redisTemplate.delete(activeKey);
|
||||
}
|
||||
}
|
||||
|
||||
private AgentMediaUploadView toView(AgentMediaUploadRecord record) {
|
||||
AgentMediaUploadView view = new AgentMediaUploadView();
|
||||
view.setUploadId(record.getUploadId());
|
||||
view.setName(record.getOriginalName());
|
||||
view.setMimeType(record.getMimeType());
|
||||
view.setSize(record.getSize());
|
||||
view.setWidth(record.getWidth());
|
||||
view.setHeight(record.getHeight());
|
||||
view.setExpiresAt(record.getExpiresAt());
|
||||
view.setPreviewUrl("/api/v1/agent/media/content?reference=draft%3A" + record.getUploadId());
|
||||
return view;
|
||||
}
|
||||
|
||||
private void validateSessionId(String mode, String sessionId) {
|
||||
boolean valid = AgentMediaService.MODE_DRAFT.equals(mode)
|
||||
? sessionId.matches("agent-draft-\\d+")
|
||||
: sessionId.matches("\\d+");
|
||||
if (!valid) {
|
||||
throw badRequest("会话 ID 无效");
|
||||
}
|
||||
}
|
||||
|
||||
private String scope(Identity identity, String mode, String agentId, String sessionId) {
|
||||
return identity.tenantId + ":" + identity.userId + ":" + mode + ":" + agentId + ":" + sessionId;
|
||||
}
|
||||
|
||||
private String activeKey(Identity identity, String mode, String agentId) {
|
||||
return ACTIVE_PREFIX + identity.tenantId + ":" + identity.userId + ":" + mode + ":" + agentId;
|
||||
}
|
||||
|
||||
private String mode(String value) {
|
||||
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
if (!AgentMediaService.MODE_FORMAL.equals(normalized) && !AgentMediaService.MODE_DRAFT.equals(normalized)) {
|
||||
throw badRequest("聊天模式无效");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String text(String value, String message) {
|
||||
if (!StringUtils.hasText(value) || value.length() > 200) {
|
||||
throw badRequest(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private Duration draftTtl() {
|
||||
Duration ttl = properties.getComposerDraftTtl();
|
||||
return ttl == null || ttl.isZero() || ttl.isNegative() ? Duration.ofHours(24) : ttl;
|
||||
}
|
||||
|
||||
private Identity identity(LoginAccount account) {
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "当前登录状态失效");
|
||||
}
|
||||
return new Identity(account.getTenantId().toString(), account.getId().toString());
|
||||
}
|
||||
|
||||
private ResponseStatusException badRequest(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
private record Identity(String tenantId, String userId) { }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.runtime.composer;
|
||||
|
||||
/**
|
||||
* 输入框使用的预分配会话标识。
|
||||
*
|
||||
* @param mode 聊天模式
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public record AgentComposerSession(String mode, String sessionId) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 已绑定到聊天消息的图片引用与展示载荷。
|
||||
*
|
||||
* @param reference 运行时稳定引用
|
||||
* @param mimeType MIME 类型
|
||||
* @param payload 聊天历史展示载荷
|
||||
*/
|
||||
public record AgentBoundMedia(String reference, String mimeType, Map<String, Object> payload) {
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 校验并规范化后的 Agent 图片。
|
||||
*
|
||||
* @param bytes 图片字节
|
||||
* @param mimeType MIME 类型
|
||||
* @param extension 规范化扩展名
|
||||
* @param width 宽度
|
||||
* @param height 高度
|
||||
* @param sha256 SHA-256 摘要
|
||||
*/
|
||||
public record AgentImageData(byte[] bytes,
|
||||
String mimeType,
|
||||
String extension,
|
||||
int width,
|
||||
int height,
|
||||
String sha256) {
|
||||
|
||||
/**
|
||||
* 创建不可变图片数据。
|
||||
*/
|
||||
public AgentImageData {
|
||||
bytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
|
||||
}
|
||||
|
||||
/** @return 图片字节副本 */
|
||||
@Override
|
||||
public byte[] bytes() { return Arrays.copyOf(bytes, bytes.length); }
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import tech.easyflow.agent.config.AgentMediaProperties;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* Agent 聊天图片格式识别、尺寸校验与规范化处理器。
|
||||
*/
|
||||
@Component
|
||||
public class AgentImageProcessor {
|
||||
|
||||
private final AgentMediaProperties properties;
|
||||
|
||||
/**
|
||||
* 创建图片处理器。
|
||||
*
|
||||
* @param properties Agent 媒体配置
|
||||
*/
|
||||
public AgentImageProcessor(AgentMediaProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化上传图片。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @return 规范化图片
|
||||
*/
|
||||
public AgentImageData process(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw badRequest("请选择要上传的图片");
|
||||
}
|
||||
if (file.getSize() > properties.getMaxImageBytes()) {
|
||||
throw badRequest("单张图片不能超过 10 MiB");
|
||||
}
|
||||
try {
|
||||
byte[] source = file.getBytes();
|
||||
ImageFormat format = detectFormat(source);
|
||||
Dimensions dimensions = format == ImageFormat.WEBP
|
||||
? webpDimensions(source)
|
||||
: imageIoDimensions(source);
|
||||
validateDimensions(dimensions);
|
||||
byte[] normalized = source;
|
||||
String mimeType = format.mimeType;
|
||||
String extension = format.extension;
|
||||
// 动图仅保留首帧,BMP 转 PNG,避免模型端格式兼容差异。
|
||||
if (format == ImageFormat.GIF || format == ImageFormat.BMP) {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(source));
|
||||
if (image == null) {
|
||||
throw badRequest("图片内容无法解析");
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
if (!ImageIO.write(image, "png", output)) {
|
||||
throw badRequest("图片格式转换失败");
|
||||
}
|
||||
normalized = output.toByteArray();
|
||||
mimeType = "image/png";
|
||||
extension = "png";
|
||||
}
|
||||
if (normalized.length > properties.getMaxImageBytes()) {
|
||||
throw badRequest("处理后的图片不能超过 10 MiB");
|
||||
}
|
||||
return new AgentImageData(normalized, mimeType, extension,
|
||||
dimensions.width, dimensions.height, sha256(normalized));
|
||||
} catch (ResponseStatusException error) {
|
||||
throw error;
|
||||
} catch (Exception error) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "图片处理失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
private ImageFormat detectFormat(byte[] bytes) {
|
||||
if (bytes.length >= 8
|
||||
&& bytes[0] == (byte) 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) {
|
||||
return ImageFormat.PNG;
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] == (byte) 0xff && bytes[1] == (byte) 0xd8 && bytes[2] == (byte) 0xff) {
|
||||
return ImageFormat.JPEG;
|
||||
}
|
||||
if (bytes.length >= 6) {
|
||||
String header = new String(bytes, 0, 6, StandardCharsets.US_ASCII);
|
||||
if ("GIF87a".equals(header) || "GIF89a".equals(header)) {
|
||||
return ImageFormat.GIF;
|
||||
}
|
||||
}
|
||||
if (bytes.length >= 2 && bytes[0] == 'B' && bytes[1] == 'M') {
|
||||
return ImageFormat.BMP;
|
||||
}
|
||||
if (bytes.length >= 12
|
||||
&& "RIFF".equals(ascii(bytes, 0, 4))
|
||||
&& "WEBP".equals(ascii(bytes, 8, 4))) {
|
||||
return ImageFormat.WEBP;
|
||||
}
|
||||
throw badRequest("仅支持 PNG、JPG、JPEG、WebP、GIF、BMP 图片");
|
||||
}
|
||||
|
||||
private Dimensions imageIoDimensions(byte[] bytes) throws Exception {
|
||||
try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
|
||||
if (!readers.hasNext()) {
|
||||
throw badRequest("图片内容无法解析");
|
||||
}
|
||||
ImageReader reader = readers.next();
|
||||
try {
|
||||
reader.setInput(input, true, true);
|
||||
return new Dimensions(reader.getWidth(0), reader.getHeight(0));
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Dimensions webpDimensions(byte[] bytes) {
|
||||
int offset = 12;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
String chunk = ascii(bytes, offset, 4);
|
||||
int size = littleEndianInt(bytes, offset + 4);
|
||||
int data = offset + 8;
|
||||
if (size < 0 || data + size > bytes.length) {
|
||||
break;
|
||||
}
|
||||
if ("VP8X".equals(chunk) && size >= 10) {
|
||||
return new Dimensions(1 + littleEndian24(bytes, data + 4),
|
||||
1 + littleEndian24(bytes, data + 7));
|
||||
}
|
||||
if ("VP8 ".equals(chunk) && size >= 10
|
||||
&& bytes[data + 3] == (byte) 0x9d && bytes[data + 4] == 0x01 && bytes[data + 5] == 0x2a) {
|
||||
return new Dimensions(littleEndian16(bytes, data + 6) & 0x3fff,
|
||||
littleEndian16(bytes, data + 8) & 0x3fff);
|
||||
}
|
||||
if ("VP8L".equals(chunk) && size >= 5 && bytes[data] == 0x2f) {
|
||||
int b1 = unsigned(bytes[data + 1]);
|
||||
int b2 = unsigned(bytes[data + 2]);
|
||||
int b3 = unsigned(bytes[data + 3]);
|
||||
int b4 = unsigned(bytes[data + 4]);
|
||||
int width = 1 + ((b1 | (b2 << 8)) & 0x3fff);
|
||||
int height = 1 + (((b2 >> 6) | (b3 << 2) | (b4 << 10)) & 0x3fff);
|
||||
return new Dimensions(width, height);
|
||||
}
|
||||
offset = data + size + (size & 1);
|
||||
}
|
||||
throw badRequest("WebP 图片内容无法解析");
|
||||
}
|
||||
|
||||
private void validateDimensions(Dimensions dimensions) {
|
||||
if (dimensions.width <= 0 || dimensions.height <= 0) {
|
||||
throw badRequest("图片尺寸无效");
|
||||
}
|
||||
long pixels = (long) dimensions.width * dimensions.height;
|
||||
if (pixels > properties.getMaxImagePixels()) {
|
||||
throw badRequest("图片像素过大,请压缩后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private String sha256(byte[] bytes) throws Exception {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
}
|
||||
|
||||
private String ascii(byte[] bytes, int offset, int length) {
|
||||
return new String(bytes, offset, length, StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private int littleEndian16(byte[] bytes, int offset) {
|
||||
return unsigned(bytes[offset]) | (unsigned(bytes[offset + 1]) << 8);
|
||||
}
|
||||
|
||||
private int littleEndian24(byte[] bytes, int offset) {
|
||||
return unsigned(bytes[offset]) | (unsigned(bytes[offset + 1]) << 8) | (unsigned(bytes[offset + 2]) << 16);
|
||||
}
|
||||
|
||||
private int littleEndianInt(byte[] bytes, int offset) {
|
||||
return unsigned(bytes[offset]) | (unsigned(bytes[offset + 1]) << 8)
|
||||
| (unsigned(bytes[offset + 2]) << 16) | (unsigned(bytes[offset + 3]) << 24);
|
||||
}
|
||||
|
||||
private int unsigned(byte value) { return value & 0xff; }
|
||||
|
||||
private ResponseStatusException badRequest(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
private record Dimensions(int width, int height) { }
|
||||
|
||||
private enum ImageFormat {
|
||||
PNG("image/png", "png"),
|
||||
JPEG("image/jpeg", "jpg"),
|
||||
WEBP("image/webp", "webp"),
|
||||
GIF("image/gif", "gif"),
|
||||
BMP("image/bmp", "bmp");
|
||||
|
||||
private final String mimeType;
|
||||
private final String extension;
|
||||
|
||||
ImageFormat(String mimeType, String extension) {
|
||||
this.mimeType = mimeType;
|
||||
this.extension = extension;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 定期删除 Redis 凭据已过期的 MinIO 临时图片。
|
||||
*/
|
||||
@Component
|
||||
public class AgentMediaCleanupScheduler {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentMediaCleanupScheduler.class);
|
||||
private final AgentMediaService mediaService;
|
||||
|
||||
/**
|
||||
* 创建清理任务。
|
||||
*
|
||||
* @param mediaService Agent 媒体服务
|
||||
*/
|
||||
public AgentMediaCleanupScheduler(AgentMediaService mediaService) {
|
||||
this.mediaService = mediaService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理一批过期临时图片。
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${easyflow.agent.media.cleanup-interval:10m}")
|
||||
public void cleanup() {
|
||||
try {
|
||||
int cleaned = mediaService.cleanupExpired(200);
|
||||
if (cleaned > 0) {
|
||||
LOG.info("Cleaned {} expired Agent media objects", cleaned);
|
||||
}
|
||||
} catch (Exception error) {
|
||||
LOG.error("Agent media cleanup failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import io.minio.*;
|
||||
import io.minio.errors.ErrorResponseException;
|
||||
import io.minio.messages.DeleteError;
|
||||
import io.minio.messages.DeleteObject;
|
||||
import io.minio.messages.Item;
|
||||
import org.dromara.x.file.storage.core.FileStorageService;
|
||||
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import tech.easyflow.agent.config.AgentMediaProperties;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 基于独立私有 MinIO 桶的 Agent 媒体对象存储。
|
||||
*/
|
||||
@Service
|
||||
public class AgentMediaObjectStorage {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentMediaObjectStorage.class);
|
||||
private final FileStorageService fileStorageService;
|
||||
private final AgentMediaProperties properties;
|
||||
private final AtomicBoolean bucketReady = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* 创建 Agent 媒体对象存储。
|
||||
*
|
||||
* @param fileStorageService x-file-storage 服务
|
||||
* @param properties Agent 媒体配置
|
||||
*/
|
||||
public AgentMediaObjectStorage(FileStorageService fileStorageService, AgentMediaProperties properties) {
|
||||
this.fileStorageService = fileStorageService;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动后预创建私有桶;失败时保留完整日志,首次使用会再次尝试。
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initializeBucket() {
|
||||
try {
|
||||
ensureBucket();
|
||||
} catch (Exception error) {
|
||||
LOG.error("Agent media bucket initialization failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入对象。
|
||||
*
|
||||
* @param objectKey 对象键
|
||||
* @param data 对象字节
|
||||
* @param mimeType MIME 类型
|
||||
*/
|
||||
public void put(String objectKey, byte[] data, String mimeType) {
|
||||
try {
|
||||
MinioFileStorage storage = storage();
|
||||
ensureBucket();
|
||||
storage.getClient().putObject(PutObjectArgs.builder()
|
||||
.bucket(storage.getBucketName())
|
||||
.object(fullKey(storage, objectKey))
|
||||
.stream(new java.io.ByteArrayInputStream(data), data.length, -1)
|
||||
.contentType(mimeType)
|
||||
.build());
|
||||
} catch (Exception error) {
|
||||
throw storageError("图片上传到对象存储失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取对象并限制最大内存占用。
|
||||
*
|
||||
* @param objectKey 对象键
|
||||
* @param maxBytes 最大读取字节数
|
||||
* @return 对象字节
|
||||
*/
|
||||
public byte[] get(String objectKey, long maxBytes) {
|
||||
try {
|
||||
MinioFileStorage storage = storage();
|
||||
ensureBucket();
|
||||
try (InputStream input = storage.getClient().getObject(GetObjectArgs.builder()
|
||||
.bucket(storage.getBucketName()).object(fullKey(storage, objectKey)).build());
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
long total = 0L;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
total += read;
|
||||
if (total > maxBytes) {
|
||||
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "图片对象超过允许大小");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
} catch (ResponseStatusException error) {
|
||||
throw error;
|
||||
} catch (ErrorResponseException error) {
|
||||
String code = error.errorResponse() == null ? null : error.errorResponse().code();
|
||||
if ("NoSuchKey".equals(code) || "NoSuchObject".equals(code)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "图片不存在或已删除", error);
|
||||
}
|
||||
throw storageError("图片对象读取失败", error);
|
||||
} catch (Exception error) {
|
||||
throw storageError("图片对象读取失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在私有桶内复制对象。
|
||||
*
|
||||
* @param sourceKey 源对象键
|
||||
* @param targetKey 目标对象键
|
||||
*/
|
||||
public void copy(String sourceKey, String targetKey) {
|
||||
try {
|
||||
MinioFileStorage storage = storage();
|
||||
ensureBucket();
|
||||
storage.getClient().copyObject(CopyObjectArgs.builder()
|
||||
.bucket(storage.getBucketName())
|
||||
.object(fullKey(storage, targetKey))
|
||||
.source(CopySource.builder().bucket(storage.getBucketName())
|
||||
.object(fullKey(storage, sourceKey)).build())
|
||||
.build());
|
||||
} catch (Exception error) {
|
||||
throw storageError("图片对象归档失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对象,重复删除视为成功。
|
||||
*
|
||||
* @param objectKey 对象键
|
||||
*/
|
||||
public void delete(String objectKey) {
|
||||
try {
|
||||
MinioFileStorage storage = storage();
|
||||
ensureBucket();
|
||||
storage.getClient().removeObject(RemoveObjectArgs.builder()
|
||||
.bucket(storage.getBucketName()).object(fullKey(storage, objectKey)).build());
|
||||
} catch (Exception error) {
|
||||
throw storageError("图片对象删除失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定前缀下的全部对象。
|
||||
*
|
||||
* @param objectPrefix 对象键前缀
|
||||
*/
|
||||
public void deletePrefix(String objectPrefix) {
|
||||
try {
|
||||
MinioFileStorage storage = storage();
|
||||
ensureBucket();
|
||||
String fullPrefix = fullKey(storage, objectPrefix);
|
||||
List<DeleteObject> batch = new ArrayList<>(1000);
|
||||
for (Result<Item> result : storage.getClient().listObjects(ListObjectsArgs.builder()
|
||||
.bucket(storage.getBucketName())
|
||||
.prefix(fullPrefix)
|
||||
.recursive(true)
|
||||
.build())) {
|
||||
batch.add(new DeleteObject(result.get().objectName()));
|
||||
if (batch.size() == 1000) {
|
||||
removeBatch(storage, batch);
|
||||
batch.clear();
|
||||
}
|
||||
}
|
||||
removeBatch(storage, batch);
|
||||
} catch (Exception error) {
|
||||
throw storageError("图片对象目录删除失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void ensureBucket() throws Exception {
|
||||
if (bucketReady.get()) {
|
||||
return;
|
||||
}
|
||||
MinioFileStorage storage = storage();
|
||||
boolean exists = storage.getClient().bucketExists(
|
||||
BucketExistsArgs.builder().bucket(storage.getBucketName()).build());
|
||||
if (!exists) {
|
||||
storage.getClient().makeBucket(MakeBucketArgs.builder().bucket(storage.getBucketName()).build());
|
||||
}
|
||||
bucketReady.set(true);
|
||||
}
|
||||
|
||||
private MinioFileStorage storage() {
|
||||
MinioFileStorage storage = fileStorageService.getFileStorage(properties.getPlatform());
|
||||
if (storage == null) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"Agent 私有媒体存储平台未配置");
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
private void removeBatch(MinioFileStorage storage, List<DeleteObject> objects) throws Exception {
|
||||
if (objects.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Iterable<Result<DeleteError>> errors = storage.getClient().removeObjects(RemoveObjectsArgs.builder()
|
||||
.bucket(storage.getBucketName())
|
||||
.objects(List.copyOf(objects))
|
||||
.build());
|
||||
for (Result<DeleteError> result : errors) {
|
||||
DeleteError error = result.get();
|
||||
throw new IllegalStateException("删除 MinIO 对象失败: " + error.objectName() + ", " + error.message());
|
||||
}
|
||||
}
|
||||
|
||||
private String fullKey(MinioFileStorage storage, String objectKey) {
|
||||
String basePath = storage.getBasePath();
|
||||
if (basePath == null || basePath.isBlank()) {
|
||||
return objectKey;
|
||||
}
|
||||
return basePath.replaceAll("/+$", "") + "/" + objectKey.replaceAll("^/+", "");
|
||||
}
|
||||
|
||||
private ResponseStatusException storageError(String message, Exception error) {
|
||||
LOG.error(message, error);
|
||||
return new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, message, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import com.easyagents.agent.runtime.media.AgentMediaResolver;
|
||||
import com.easyagents.agent.runtime.media.AgentMediaResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
import tech.easyflow.agent.config.AgentMediaProperties;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Agent 图片上传、归档、鉴权读取和运行时解析服务。
|
||||
*/
|
||||
@Service
|
||||
public class AgentMediaService {
|
||||
|
||||
public static final String MODE_FORMAL = "FORMAL";
|
||||
public static final String MODE_DRAFT = "DRAFT";
|
||||
|
||||
private final AgentImageProcessor imageProcessor;
|
||||
private final AgentMediaObjectStorage objectStorage;
|
||||
private final RedisAgentMediaUploadStore uploadStore;
|
||||
private final AgentMediaProperties properties;
|
||||
|
||||
/**
|
||||
* 创建 Agent 媒体服务。
|
||||
*
|
||||
* @param imageProcessor 图片处理器
|
||||
* @param objectStorage 私有对象存储
|
||||
* @param uploadStore 临时凭据存储
|
||||
* @param properties Agent 媒体配置
|
||||
*/
|
||||
public AgentMediaService(AgentImageProcessor imageProcessor,
|
||||
AgentMediaObjectStorage objectStorage,
|
||||
RedisAgentMediaUploadStore uploadStore,
|
||||
AgentMediaProperties properties) {
|
||||
this.imageProcessor = imageProcessor;
|
||||
this.objectStorage = objectStorage;
|
||||
this.uploadStore = uploadStore;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传一张聊天临时图片。
|
||||
*
|
||||
* @param file 图片文件
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param account 当前账号
|
||||
* @return 上传结果
|
||||
*/
|
||||
public AgentMediaUploadView upload(MultipartFile file,
|
||||
String mode,
|
||||
String agentId,
|
||||
String sessionId,
|
||||
LoginAccount account) {
|
||||
String safeMode = requireMode(mode);
|
||||
String safeAgentId = requireText(agentId, "Agent ID 不能为空");
|
||||
String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
|
||||
Identity identity = identity(account);
|
||||
AgentImageData image = imageProcessor.process(file);
|
||||
String uploadId = UUID.randomUUID().toString().replace("-", "");
|
||||
String objectKey = "temp/%s/%s/%s.%s".formatted(identity.tenantId, identity.userId,
|
||||
uploadId, image.extension());
|
||||
objectStorage.put(objectKey, image.bytes(), image.mimeType());
|
||||
AgentMediaUploadRecord record = new AgentMediaUploadRecord();
|
||||
record.setUploadId(uploadId);
|
||||
record.setTenantId(identity.tenantId);
|
||||
record.setUserId(identity.userId);
|
||||
record.setMode(safeMode);
|
||||
record.setAgentId(safeAgentId);
|
||||
record.setSessionId(safeSessionId);
|
||||
record.setObjectKey(objectKey);
|
||||
record.setOriginalName(safeOriginalName(file.getOriginalFilename(), image.extension()));
|
||||
record.setMimeType(image.mimeType());
|
||||
record.setExtension(image.extension());
|
||||
record.setSize(image.bytes().length);
|
||||
record.setWidth(image.width());
|
||||
record.setHeight(image.height());
|
||||
record.setSha256(image.sha256());
|
||||
record.setCreatedAt(Instant.now());
|
||||
try {
|
||||
uploadStore.create(record);
|
||||
} catch (RuntimeException error) {
|
||||
objectStorage.delete(objectKey);
|
||||
throw error;
|
||||
}
|
||||
return toView(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前账号尚未绑定的临时图片。
|
||||
*
|
||||
* @param uploadId 上传 ID
|
||||
* @param account 当前账号
|
||||
*/
|
||||
public void deleteUpload(String uploadId, LoginAccount account) {
|
||||
AgentMediaUploadRecord record = uploadStore.find(uploadId).orElse(null);
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
assertOwner(record, identity(account));
|
||||
objectStorage.delete(record.getObjectKey());
|
||||
uploadStore.delete(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等删除指定草稿作用域内的临时图片。
|
||||
*
|
||||
* @param uploadIds 上传 ID
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param account 当前账号
|
||||
*/
|
||||
public void deleteUploadsForScope(List<String> uploadIds,
|
||||
String mode,
|
||||
String agentId,
|
||||
String sessionId,
|
||||
LoginAccount account) {
|
||||
Identity identity = identity(account);
|
||||
String safeMode = requireMode(mode);
|
||||
String safeAgentId = requireText(agentId, "Agent ID 不能为空");
|
||||
String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
|
||||
for (String uploadId : uploadIds == null ? List.<String>of() : uploadIds.stream()
|
||||
.filter(StringUtils::hasText).map(String::trim).distinct().toList()) {
|
||||
AgentMediaUploadRecord record = uploadStore.find(uploadId).orElse(null);
|
||||
if (record == null) {
|
||||
continue;
|
||||
}
|
||||
assertOwner(record, identity);
|
||||
if (!safeMode.equals(record.getMode())
|
||||
|| !safeAgentId.equals(record.getAgentId())
|
||||
|| !safeSessionId.equals(record.getSessionId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "图片不属于当前聊天会话");
|
||||
}
|
||||
objectStorage.delete(record.getObjectKey());
|
||||
uploadStore.delete(record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并取得本轮图片上传凭据。
|
||||
*
|
||||
* @param uploadIds 上传 ID
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param account 当前账号
|
||||
* @return 有序上传凭据
|
||||
*/
|
||||
public List<AgentMediaUploadRecord> requireUploads(List<String> uploadIds,
|
||||
String mode,
|
||||
String agentId,
|
||||
String sessionId,
|
||||
LoginAccount account) {
|
||||
List<String> ids = uploadIds == null ? List.of() : uploadIds.stream()
|
||||
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
|
||||
if (ids.size() > properties.getMaxImageCount()) {
|
||||
throw badRequest("每次最多发送 " + properties.getMaxImageCount() + " 张图片");
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Identity identity = identity(account);
|
||||
String safeMode = requireMode(mode);
|
||||
String safeAgentId = requireText(agentId, "Agent ID 不能为空");
|
||||
String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
|
||||
List<AgentMediaUploadRecord> records = new ArrayList<>(ids.size());
|
||||
for (String uploadId : ids) {
|
||||
AgentMediaUploadRecord record = uploadStore.find(uploadId)
|
||||
.orElseThrow(() -> badRequest("图片已过期,请重新上传"));
|
||||
assertOwner(record, identity);
|
||||
if (!safeMode.equals(record.getMode())
|
||||
|| !safeAgentId.equals(record.getAgentId())
|
||||
|| !safeSessionId.equals(record.getSessionId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "图片不属于当前聊天会话");
|
||||
}
|
||||
records.add(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将正式聊天图片从临时区归档到消息目录。
|
||||
*
|
||||
* @param uploads 临时图片
|
||||
* @param sessionId 会话 ID
|
||||
* @param messageId 消息 ID
|
||||
* @param account 当前账号
|
||||
* @return 已绑定图片
|
||||
*/
|
||||
public List<AgentBoundMedia> bindFormal(List<AgentMediaUploadRecord> uploads,
|
||||
String sessionId,
|
||||
String messageId,
|
||||
LoginAccount account) {
|
||||
if (uploads == null || uploads.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Identity identity = identity(account);
|
||||
List<AgentBoundMedia> result = new ArrayList<>(uploads.size());
|
||||
for (int index = 0; index < uploads.size(); index++) {
|
||||
AgentMediaUploadRecord upload = uploads.get(index);
|
||||
assertOwner(upload, identity);
|
||||
String reference = upload.getFormalReference();
|
||||
if (StringUtils.hasText(reference)) {
|
||||
FormalReference formal = parseFormalReference(reference);
|
||||
if (!digits(sessionId).equals(formal.sessionId)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "图片已绑定到其他聊天会话");
|
||||
}
|
||||
result.add(new AgentBoundMedia(reference, upload.getMimeType(), displayPayload(upload, reference)));
|
||||
continue;
|
||||
}
|
||||
reference = formalReference(sessionId, messageId, index, upload.getExtension());
|
||||
String targetKey = formalObjectKey(identity, sessionId, messageId, index, upload.getExtension());
|
||||
objectStorage.copy(upload.getObjectKey(), targetKey);
|
||||
uploadStore.markFormalBinding(upload, reference);
|
||||
result.add(new AgentBoundMedia(reference, upload.getMimeType(), displayPayload(upload, reference)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前账号一个正式会话归档的全部图片。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param account 当前账号
|
||||
*/
|
||||
public void deleteFormalSession(String sessionId, LoginAccount account) {
|
||||
Identity identity = identity(account);
|
||||
objectStorage.deletePrefix("formal/%s/%s/%s/".formatted(
|
||||
identity.tenantId, identity.userId, digits(sessionId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为草稿试运行保留临时图片引用并续期。
|
||||
*
|
||||
* @param uploads 临时图片
|
||||
* @return 已绑定图片
|
||||
*/
|
||||
public List<AgentBoundMedia> bindDraft(List<AgentMediaUploadRecord> uploads) {
|
||||
if (uploads == null || uploads.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<AgentBoundMedia> result = new ArrayList<>(uploads.size());
|
||||
for (AgentMediaUploadRecord upload : uploads) {
|
||||
uploadStore.renew(upload);
|
||||
String reference = "draft:" + upload.getUploadId();
|
||||
result.add(new AgentBoundMedia(reference, upload.getMimeType(), displayPayload(upload, reference)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前用户可访问的图片引用。
|
||||
*
|
||||
* @param reference 稳定图片引用
|
||||
* @param account 当前账号
|
||||
* @return 图片资源
|
||||
*/
|
||||
public AgentMediaResource load(String reference, LoginAccount account) {
|
||||
Identity identity = identity(account);
|
||||
if (reference != null && reference.startsWith("draft:")) {
|
||||
String uploadId = reference.substring("draft:".length());
|
||||
AgentMediaUploadRecord record = uploadStore.find(uploadId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "图片已过期"));
|
||||
assertOwner(record, identity);
|
||||
return new AgentMediaResource(record.getMimeType(),
|
||||
objectStorage.get(record.getObjectKey(), properties.getMaxImageBytes()));
|
||||
}
|
||||
FormalReference formal = parseFormalReference(reference);
|
||||
String key = formalObjectKey(identity, formal.sessionId, formal.messageId, formal.index, formal.extension);
|
||||
return new AgentMediaResource(mimeType(formal.extension),
|
||||
objectStorage.get(key, properties.getMaxImageBytes()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仅能访问当前账号媒体的 easy-agents 解析器。
|
||||
*
|
||||
* @param account 当前账号
|
||||
* @return 运行时媒体解析器
|
||||
*/
|
||||
public AgentMediaResolver runtimeResolver(LoginAccount account) {
|
||||
return reference -> {
|
||||
AgentMediaResource resource = load(reference, account);
|
||||
if (reference != null && reference.startsWith("draft:")) {
|
||||
uploadStore.find(reference.substring("draft:".length())).ifPresent(uploadStore::renew);
|
||||
}
|
||||
return resource;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期临时对象。
|
||||
*
|
||||
* @param limit 单次清理上限
|
||||
* @return 清理数量
|
||||
*/
|
||||
public int cleanupExpired(int limit) {
|
||||
int cleaned = 0;
|
||||
for (AgentMediaUploadRecord indexed : uploadStore.expired(limit)) {
|
||||
Optional<AgentMediaUploadRecord> current = uploadStore.find(indexed.getUploadId());
|
||||
if (current.isPresent() && current.get().getExpiresAt() != null
|
||||
&& current.get().getExpiresAt().isAfter(Instant.now())) {
|
||||
uploadStore.removeExpiryIndex(indexed);
|
||||
continue;
|
||||
}
|
||||
objectStorage.delete(indexed.getObjectKey());
|
||||
current.ifPresent(uploadStore::delete);
|
||||
uploadStore.removeExpiryIndex(indexed);
|
||||
cleaned++;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private AgentMediaUploadView toView(AgentMediaUploadRecord record) {
|
||||
AgentMediaUploadView view = new AgentMediaUploadView();
|
||||
view.setUploadId(record.getUploadId());
|
||||
view.setName(record.getOriginalName());
|
||||
view.setMimeType(record.getMimeType());
|
||||
view.setSize(record.getSize());
|
||||
view.setWidth(record.getWidth());
|
||||
view.setHeight(record.getHeight());
|
||||
view.setExpiresAt(record.getExpiresAt());
|
||||
view.setPreviewUrl("/api/v1/agent/media/content?reference="
|
||||
+ UriUtils.encodeQueryParam("draft:" + record.getUploadId(), StandardCharsets.UTF_8));
|
||||
return view;
|
||||
}
|
||||
|
||||
private Map<String, Object> displayPayload(AgentMediaUploadRecord upload, String reference) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("imageRef", reference);
|
||||
payload.put("name", upload.getOriginalName());
|
||||
payload.put("mimeType", upload.getMimeType());
|
||||
payload.put("size", upload.getSize());
|
||||
payload.put("width", upload.getWidth());
|
||||
payload.put("height", upload.getHeight());
|
||||
payload.put("previewUrl", "/api/v1/agent/media/content?reference="
|
||||
+ UriUtils.encodeQueryParam(reference, StandardCharsets.UTF_8));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String formalReference(String sessionId, String messageId, int index, String extension) {
|
||||
return "formal:" + sessionId + ":" + messageId + ":" + index + ":" + extension;
|
||||
}
|
||||
|
||||
private FormalReference parseFormalReference(String reference) {
|
||||
if (reference == null || !reference.startsWith("formal:")) {
|
||||
throw badRequest("图片引用无效");
|
||||
}
|
||||
String[] parts = reference.split(":", -1);
|
||||
if (parts.length != 5 || !parts[1].matches("\\d+") || !parts[2].matches("\\d+")
|
||||
|| !parts[3].matches("\\d+") || !Set.of("png", "jpg", "webp").contains(parts[4])) {
|
||||
throw badRequest("图片引用无效");
|
||||
}
|
||||
return new FormalReference(parts[1], parts[2], Integer.parseInt(parts[3]), parts[4]);
|
||||
}
|
||||
|
||||
private String formalObjectKey(Identity identity, String sessionId, String messageId, int index, String extension) {
|
||||
return "formal/%s/%s/%s/%s/%d.%s".formatted(identity.tenantId, identity.userId,
|
||||
digits(sessionId), digits(messageId), index, extension);
|
||||
}
|
||||
|
||||
private String digits(String value) {
|
||||
if (value == null || !value.matches("\\d+")) {
|
||||
throw badRequest("聊天消息标识无效");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String mimeType(String extension) {
|
||||
return switch (extension) {
|
||||
case "jpg" -> "image/jpeg";
|
||||
case "webp" -> "image/webp";
|
||||
default -> "image/png";
|
||||
};
|
||||
}
|
||||
|
||||
private void assertOwner(AgentMediaUploadRecord record, Identity identity) {
|
||||
if (record == null || !identity.tenantId.equals(record.getTenantId())
|
||||
|| !identity.userId.equals(record.getUserId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该图片");
|
||||
}
|
||||
}
|
||||
|
||||
private Identity identity(LoginAccount account) {
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "当前登录状态失效");
|
||||
}
|
||||
return new Identity(account.getTenantId().toString(), account.getId().toString());
|
||||
}
|
||||
|
||||
private String requireMode(String mode) {
|
||||
String normalized = mode == null ? "" : mode.trim().toUpperCase(Locale.ROOT);
|
||||
if (!MODE_FORMAL.equals(normalized) && !MODE_DRAFT.equals(normalized)) {
|
||||
throw badRequest("聊天模式无效");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requireText(String value, String message) {
|
||||
if (!StringUtils.hasText(value) || value.length() > 200) {
|
||||
throw badRequest(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private String safeOriginalName(String value, String extension) {
|
||||
String name = StringUtils.hasText(value) ? value.replaceAll("[\\r\\n\\t]", " ").trim() : "image." + extension;
|
||||
return name.length() > 200 ? name.substring(0, 200) : name;
|
||||
}
|
||||
|
||||
private ResponseStatusException badRequest(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
private record Identity(String tenantId, String userId) { }
|
||||
private record FormalReference(String sessionId, String messageId, int index, String extension) { }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Redis 中保存的 Agent 临时图片上传凭据。
|
||||
*/
|
||||
public class AgentMediaUploadRecord {
|
||||
|
||||
private String uploadId;
|
||||
private String tenantId;
|
||||
private String userId;
|
||||
private String mode;
|
||||
private String agentId;
|
||||
private String sessionId;
|
||||
private String objectKey;
|
||||
private String originalName;
|
||||
private String mimeType;
|
||||
private String extension;
|
||||
private long size;
|
||||
private int width;
|
||||
private int height;
|
||||
private String sha256;
|
||||
private String formalReference;
|
||||
private Instant createdAt;
|
||||
private Instant expiresAt;
|
||||
|
||||
/** @return 上传 ID */
|
||||
public String getUploadId() { return uploadId; }
|
||||
/** @param uploadId 上传 ID */
|
||||
public void setUploadId(String uploadId) { this.uploadId = uploadId; }
|
||||
/** @return 租户 ID */
|
||||
public String getTenantId() { return tenantId; }
|
||||
/** @param tenantId 租户 ID */
|
||||
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
|
||||
/** @return 用户 ID */
|
||||
public String getUserId() { return userId; }
|
||||
/** @param userId 用户 ID */
|
||||
public void setUserId(String userId) { this.userId = userId; }
|
||||
/** @return 聊天模式 */
|
||||
public String getMode() { return mode; }
|
||||
/** @param mode 聊天模式 */
|
||||
public void setMode(String mode) { this.mode = mode; }
|
||||
/** @return Agent ID */
|
||||
public String getAgentId() { return agentId; }
|
||||
/** @param agentId Agent ID */
|
||||
public void setAgentId(String agentId) { this.agentId = agentId; }
|
||||
/** @return 会话 ID */
|
||||
public String getSessionId() { return sessionId; }
|
||||
/** @param sessionId 会话 ID */
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
/** @return 对象键 */
|
||||
public String getObjectKey() { return objectKey; }
|
||||
/** @param objectKey 对象键 */
|
||||
public void setObjectKey(String objectKey) { this.objectKey = objectKey; }
|
||||
/** @return 原文件名 */
|
||||
public String getOriginalName() { return originalName; }
|
||||
/** @param originalName 原文件名 */
|
||||
public void setOriginalName(String originalName) { this.originalName = originalName; }
|
||||
/** @return MIME 类型 */
|
||||
public String getMimeType() { return mimeType; }
|
||||
/** @param mimeType MIME 类型 */
|
||||
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
|
||||
/** @return 扩展名 */
|
||||
public String getExtension() { return extension; }
|
||||
/** @param extension 扩展名 */
|
||||
public void setExtension(String extension) { this.extension = extension; }
|
||||
/** @return 文件大小 */
|
||||
public long getSize() { return size; }
|
||||
/** @param size 文件大小 */
|
||||
public void setSize(long size) { this.size = size; }
|
||||
/** @return 图片宽度 */
|
||||
public int getWidth() { return width; }
|
||||
/** @param width 图片宽度 */
|
||||
public void setWidth(int width) { this.width = width; }
|
||||
/** @return 图片高度 */
|
||||
public int getHeight() { return height; }
|
||||
/** @param height 图片高度 */
|
||||
public void setHeight(int height) { this.height = height; }
|
||||
/** @return SHA-256 */
|
||||
public String getSha256() { return sha256; }
|
||||
/** @param sha256 SHA-256 */
|
||||
public void setSha256(String sha256) { this.sha256 = sha256; }
|
||||
/** @return 已完成归档的正式图片引用 */
|
||||
public String getFormalReference() { return formalReference; }
|
||||
/** @param formalReference 已完成归档的正式图片引用 */
|
||||
public void setFormalReference(String formalReference) { this.formalReference = formalReference; }
|
||||
/** @return 创建时间 */
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
/** @param createdAt 创建时间 */
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
/** @return 过期时间 */
|
||||
public Instant getExpiresAt() { return expiresAt; }
|
||||
/** @param expiresAt 过期时间 */
|
||||
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 返回给聊天输入框的临时图片信息。
|
||||
*/
|
||||
public class AgentMediaUploadView {
|
||||
|
||||
private String uploadId;
|
||||
private String name;
|
||||
private String mimeType;
|
||||
private long size;
|
||||
private int width;
|
||||
private int height;
|
||||
private String previewUrl;
|
||||
private Instant expiresAt;
|
||||
|
||||
/** @return 上传 ID */
|
||||
public String getUploadId() { return uploadId; }
|
||||
/** @param uploadId 上传 ID */
|
||||
public void setUploadId(String uploadId) { this.uploadId = uploadId; }
|
||||
/** @return 文件名 */
|
||||
public String getName() { return name; }
|
||||
/** @param name 文件名 */
|
||||
public void setName(String name) { this.name = name; }
|
||||
/** @return MIME 类型 */
|
||||
public String getMimeType() { return mimeType; }
|
||||
/** @param mimeType MIME 类型 */
|
||||
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
|
||||
/** @return 文件大小 */
|
||||
public long getSize() { return size; }
|
||||
/** @param size 文件大小 */
|
||||
public void setSize(long size) { this.size = size; }
|
||||
/** @return 图片宽度 */
|
||||
public int getWidth() { return width; }
|
||||
/** @param width 图片宽度 */
|
||||
public void setWidth(int width) { this.width = width; }
|
||||
/** @return 图片高度 */
|
||||
public int getHeight() { return height; }
|
||||
/** @param height 图片高度 */
|
||||
public void setHeight(int height) { this.height = height; }
|
||||
/** @return 鉴权预览地址 */
|
||||
public String getPreviewUrl() { return previewUrl; }
|
||||
/** @param previewUrl 鉴权预览地址 */
|
||||
public void setPreviewUrl(String previewUrl) { this.previewUrl = previewUrl; }
|
||||
/** @return 过期时间 */
|
||||
public Instant getExpiresAt() { return expiresAt; }
|
||||
/** @param expiresAt 过期时间 */
|
||||
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.agent.config.AgentMediaProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Agent 临时图片凭据与过期对象索引的 Redis 存储。
|
||||
*/
|
||||
@Service
|
||||
public class RedisAgentMediaUploadStore {
|
||||
|
||||
private static final String KEY_PREFIX = "easyflow:agent:image-upload:";
|
||||
private static final String EXPIRY_INDEX = "easyflow:agent:image-upload:expiry";
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentMediaProperties properties;
|
||||
|
||||
/**
|
||||
* 创建临时图片凭据存储。
|
||||
*
|
||||
* @param redisTemplate Redis 模板
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param properties Agent 媒体配置
|
||||
*/
|
||||
public RedisAgentMediaUploadStore(StringRedisTemplate redisTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
AgentMediaProperties properties) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时图片凭据和清理索引。
|
||||
*
|
||||
* @param record 上传凭据
|
||||
*/
|
||||
public void create(AgentMediaUploadRecord record) {
|
||||
Duration ttl = uploadTtl();
|
||||
Instant expiresAt = Instant.now().plus(ttl);
|
||||
record.setExpiresAt(expiresAt);
|
||||
write(record, ttl);
|
||||
redisTemplate.opsForZSet().add(EXPIRY_INDEX, cleanupMember(record), expiresAt.toEpochMilli());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找上传凭据,读取操作不延长 TTL。
|
||||
*
|
||||
* @param uploadId 上传 ID
|
||||
* @return 上传凭据
|
||||
*/
|
||||
public Optional<AgentMediaUploadRecord> find(String uploadId) {
|
||||
if (!StringUtils.hasText(uploadId)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String value = redisTemplate.opsForValue().get(key(uploadId));
|
||||
return StringUtils.hasText(value) ? Optional.of(read(value)) : Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期仍被草稿或试运行会话使用的图片。
|
||||
*
|
||||
* @param record 上传凭据
|
||||
*/
|
||||
public void renew(AgentMediaUploadRecord record) {
|
||||
Duration ttl = uploadTtl();
|
||||
String previousMember = cleanupMember(record);
|
||||
record.setExpiresAt(Instant.now().plus(ttl));
|
||||
write(record, ttl);
|
||||
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, previousMember);
|
||||
redisTemplate.opsForZSet().add(EXPIRY_INDEX, cleanupMember(record), record.getExpiresAt().toEpochMilli());
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录临时上传已归档到正式图片,且不延长原有 TTL。
|
||||
*
|
||||
* @param record 上传凭据
|
||||
* @param formalReference 正式图片引用
|
||||
*/
|
||||
public void markFormalBinding(AgentMediaUploadRecord record, String formalReference) {
|
||||
if (record == null || !StringUtils.hasText(formalReference)) {
|
||||
throw new IllegalArgumentException("正式图片绑定信息不能为空");
|
||||
}
|
||||
String previousMember = cleanupMember(record);
|
||||
Instant expiresAt = record.getExpiresAt();
|
||||
if (expiresAt == null) {
|
||||
expiresAt = Instant.now().plus(uploadTtl());
|
||||
record.setExpiresAt(expiresAt);
|
||||
}
|
||||
Duration remaining = Duration.between(Instant.now(), expiresAt);
|
||||
if (remaining.isZero() || remaining.isNegative()) {
|
||||
throw new IllegalStateException("图片上传凭据已过期");
|
||||
}
|
||||
record.setFormalReference(formalReference);
|
||||
write(record, remaining);
|
||||
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, previousMember);
|
||||
redisTemplate.opsForZSet().add(EXPIRY_INDEX, cleanupMember(record),
|
||||
record.getExpiresAt().toEpochMilli());
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除已归档的临时凭据和清理索引。
|
||||
*
|
||||
* @param record 上传凭据
|
||||
*/
|
||||
public void delete(AgentMediaUploadRecord record) {
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
redisTemplate.delete(key(record.getUploadId()));
|
||||
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, cleanupMember(record));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已到期的清理索引成员。
|
||||
*
|
||||
* @param limit 最大数量
|
||||
* @return 到期上传凭据
|
||||
*/
|
||||
public List<AgentMediaUploadRecord> expired(int limit) {
|
||||
Set<String> members = redisTemplate.opsForZSet()
|
||||
.rangeByScore(EXPIRY_INDEX, 0, Instant.now().toEpochMilli(), 0, Math.max(1, limit));
|
||||
List<AgentMediaUploadRecord> records = new ArrayList<>();
|
||||
if (members == null) {
|
||||
return records;
|
||||
}
|
||||
for (String member : members) {
|
||||
try {
|
||||
records.add(objectMapper.readValue(member, AgentMediaUploadRecord.class));
|
||||
} catch (Exception error) {
|
||||
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, member);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个清理索引成员。
|
||||
*
|
||||
* @param record 上传凭据
|
||||
*/
|
||||
public void removeExpiryIndex(AgentMediaUploadRecord record) {
|
||||
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, cleanupMember(record));
|
||||
}
|
||||
|
||||
private void write(AgentMediaUploadRecord record, Duration ttl) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(key(record.getUploadId()), objectMapper.writeValueAsString(record),
|
||||
Math.max(1L, ttl.toSeconds()), TimeUnit.SECONDS);
|
||||
} catch (Exception error) {
|
||||
throw new IllegalStateException("写入 Agent 图片上传凭据失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
private AgentMediaUploadRecord read(String value) {
|
||||
try {
|
||||
return objectMapper.readValue(value, AgentMediaUploadRecord.class);
|
||||
} catch (Exception error) {
|
||||
throw new IllegalStateException("读取 Agent 图片上传凭据失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
private String cleanupMember(AgentMediaUploadRecord record) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(record);
|
||||
} catch (Exception error) {
|
||||
throw new IllegalStateException("写入 Agent 图片清理索引失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
private String key(String uploadId) { return KEY_PREFIX + uploadId; }
|
||||
|
||||
private Duration uploadTtl() {
|
||||
Duration ttl = properties.getUploadTtl();
|
||||
return ttl == null || ttl.isNegative() || ttl.isZero() ? Duration.ofHours(24) : ttl;
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
summary.put("title", model.getTitle());
|
||||
summary.put("modelName", model.getModelName());
|
||||
summary.put("providerType", model.getModelProvider() == null ? null : model.getModelProvider().getProviderType());
|
||||
summary.put("supportImage", Boolean.TRUE.equals(model.getSupportImage()));
|
||||
summary.put("supportImageB64Only", Boolean.TRUE.equals(model.getSupportImageB64Only()));
|
||||
return summary;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
@@ -21,6 +22,8 @@ import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
||||
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
|
||||
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||
import tech.easyflow.agent.runtime.media.AgentBoundMedia;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
@@ -399,6 +402,33 @@ public class AgentRunServiceDraftAndHitlTest {
|
||||
Assert.assertEquals(Boolean.TRUE, payload.get(0).get("faqCollection"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证输入接收事件会回传正式图片展示信息。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void sendInputAcceptedShouldExposeBoundImages() throws Exception {
|
||||
AgentRunService service = new AgentRunService();
|
||||
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||
AgentBoundMedia image = new AgentBoundMedia("formal:101:201:0:png", "image/png",
|
||||
Map.of("imageRef", "formal:101:201:0:png",
|
||||
"previewUrl", "/api/v1/agent/media/content?reference=formal:101:201:0:png"));
|
||||
|
||||
boolean sent = invoke(service, "sendInputAccepted",
|
||||
new Class<?>[]{ChatSseEmitter.class, BigInteger.class, BigInteger.class, List.class},
|
||||
emitter, BigInteger.valueOf(101), BigInteger.valueOf(201), List.of(image));
|
||||
|
||||
Assert.assertTrue(sent);
|
||||
Assert.assertEquals(1, emitter.envelopes.size());
|
||||
Assert.assertEquals(ChatType.INPUT_ACCEPTED, emitter.envelopes.get(0).getType());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
|
||||
Assert.assertEquals("101", payload.get("sessionId"));
|
||||
Assert.assertEquals("201", payload.get("messageId"));
|
||||
Assert.assertEquals(List.of(image.payload()), payload.get("images"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未保存草稿会生成临时 Agent ID,并把绑定指向该运行 ID。
|
||||
*
|
||||
@@ -463,12 +493,16 @@ public class AgentRunServiceDraftAndHitlTest {
|
||||
Agent agent = new Agent();
|
||||
agent.setId(BigInteger.valueOf(100));
|
||||
ChatRuntimeContext context = chatContext();
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.ONE);
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
|
||||
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
|
||||
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
|
||||
String.class, ChatRuntimeContext.class, boolean.class, AgentSessionStore.class},
|
||||
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true,
|
||||
new InMemoryAgentSessionStore()));
|
||||
new Class<?>[]{Agent.class, String.class, List.class, LoginAccount.class, String.class,
|
||||
String.class, String.class, String.class, ChatRuntimeContext.class, boolean.class,
|
||||
AgentSessionStore.class},
|
||||
agent, "你好", List.of(), account, "request-lock", "trace-lock", "session-lock", "AGENT",
|
||||
context, true, new InMemoryAgentSessionStore()));
|
||||
|
||||
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
|
||||
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
|
||||
@@ -490,14 +524,21 @@ public class AgentRunServiceDraftAndHitlTest {
|
||||
setField(service, "agentRuntimeCompiler", compiler);
|
||||
setField(service, "agentRuntimeFactory", runtimeFactory);
|
||||
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
||||
AgentMediaService mediaService = Mockito.mock(AgentMediaService.class);
|
||||
Mockito.when(mediaService.runtimeResolver(Mockito.any())).thenReturn(reference -> null);
|
||||
setField(service, "agentMediaService", mediaService);
|
||||
|
||||
Agent agent = new Agent();
|
||||
agent.setId(BigInteger.valueOf(100));
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.ONE);
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
invoke(service, "startRuntime",
|
||||
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class, String.class,
|
||||
ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class, AgentSessionStore.class,
|
||||
AgentRunLock.Handle.class},
|
||||
agent, "你好", "request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
|
||||
new Class<?>[]{Agent.class, AgentMessage.class, LoginAccount.class, String.class, String.class,
|
||||
String.class, String.class, ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class,
|
||||
AgentSessionStore.class, AgentRunLock.Handle.class},
|
||||
agent, AgentMessage.text(AgentMessageRole.USER, "你好"), account,
|
||||
"request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
|
||||
chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);
|
||||
|
||||
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package tech.easyflow.agent.runtime.composer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import tech.easyflow.agent.config.AgentMediaProperties;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link AgentComposerDraftService} 草稿清理测试。
|
||||
*/
|
||||
public class AgentComposerDraftServiceTest {
|
||||
|
||||
private static final String DRAFT_KEY = "easyflow:agent:composer:draft:2:7:FORMAL:9:101";
|
||||
private static final String ACTIVE_KEY = "easyflow:agent:composer:active:2:7:FORMAL:9";
|
||||
|
||||
private StringRedisTemplate redisTemplate;
|
||||
private ValueOperations<String, String> valueOperations;
|
||||
private AgentMediaService mediaService;
|
||||
private AgentComposerDraftService service;
|
||||
private LoginAccount account;
|
||||
|
||||
/**
|
||||
* 初始化草稿服务测试依赖。
|
||||
*/
|
||||
@Before
|
||||
@SuppressWarnings("unchecked")
|
||||
public void setUp() {
|
||||
redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
valueOperations = Mockito.mock(ValueOperations.class);
|
||||
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
mediaService = Mockito.mock(AgentMediaService.class);
|
||||
service = new AgentComposerDraftService(redisTemplate, new ObjectMapper(),
|
||||
new AgentMediaProperties(), mediaService);
|
||||
account = new LoginAccount();
|
||||
account.setTenantId(BigInteger.valueOf(2));
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证删除草稿时合并服务端与调用方上传 ID 并清理临时图片。
|
||||
*
|
||||
* @throws Exception JSON 编码失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void deleteShouldCleanupDraftUploadsBeforeRedisKeys() throws Exception {
|
||||
AgentComposerDraft draft = new AgentComposerDraft();
|
||||
draft.setImageUploadIds(List.of("upload-1", "upload-2"));
|
||||
Mockito.when(valueOperations.get(DRAFT_KEY))
|
||||
.thenReturn(new ObjectMapper().writeValueAsString(draft));
|
||||
Mockito.when(valueOperations.get(ACTIVE_KEY)).thenReturn("101");
|
||||
|
||||
service.delete("FORMAL", "9", "101", List.of("upload-2", "upload-3"), true, account);
|
||||
|
||||
Mockito.verify(mediaService).deleteUploadsForScope(
|
||||
List.of("upload-1", "upload-2", "upload-3"), "FORMAL", "9", "101", account);
|
||||
Mockito.verify(redisTemplate).delete(List.of(DRAFT_KEY,
|
||||
"easyflow:agent:composer:revision:2:7:FORMAL:9:101"));
|
||||
Mockito.verify(redisTemplate).delete(ACTIVE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证关闭上传清理时只删除草稿缓存。
|
||||
*/
|
||||
@Test
|
||||
public void deleteShouldKeepUploadsWhenCleanupIsDisabled() {
|
||||
Mockito.when(valueOperations.get(ACTIVE_KEY)).thenReturn("101");
|
||||
|
||||
service.delete("FORMAL", "9", "101", List.of("upload-1"), false, account);
|
||||
|
||||
Mockito.verifyNoInteractions(mediaService);
|
||||
Mockito.verify(redisTemplate).delete(List.of(DRAFT_KEY,
|
||||
"easyflow:agent:composer:revision:2:7:FORMAL:9:101"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证旧版草稿未保存图片 ID 字段时仍能完成清理。
|
||||
*
|
||||
* @throws Exception JSON 编码失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void deleteShouldHandleLegacyDraftWithoutImageUploadIds() throws Exception {
|
||||
AgentComposerDraft draft = new AgentComposerDraft();
|
||||
Mockito.when(valueOperations.get(DRAFT_KEY))
|
||||
.thenReturn(new ObjectMapper().writeValueAsString(draft));
|
||||
|
||||
service.delete("FORMAL", "9", "101", List.of(), true, account);
|
||||
|
||||
Mockito.verify(mediaService).deleteUploadsForScope(
|
||||
List.of(), "FORMAL", "9", "101", account);
|
||||
Mockito.verify(redisTemplate).delete(List.of(DRAFT_KEY,
|
||||
"easyflow:agent:composer:revision:2:7:FORMAL:9:101"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import tech.easyflow.agent.config.AgentMediaProperties;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Agent 图片格式识别、规范化和资源限制测试。
|
||||
*/
|
||||
public class AgentImageProcessorTest {
|
||||
|
||||
/**
|
||||
* 验证 PNG 会保留格式、尺寸和稳定摘要。
|
||||
*
|
||||
* @throws Exception 图片生成失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void processShouldAcceptPngAndExposeMetadata() throws Exception {
|
||||
AgentImageProcessor processor = new AgentImageProcessor(new AgentMediaProperties());
|
||||
|
||||
AgentImageData image = processor.process(file("sample.png", "image/png", imageBytes("png", 3, 2)));
|
||||
|
||||
Assert.assertEquals("image/png", image.mimeType());
|
||||
Assert.assertEquals("png", image.extension());
|
||||
Assert.assertEquals(3, image.width());
|
||||
Assert.assertEquals(2, image.height());
|
||||
Assert.assertEquals(64, image.sha256().length());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 GIF 首帧会转换为模型兼容的 PNG。
|
||||
*
|
||||
* @throws Exception 图片生成失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void processShouldNormalizeGifToPng() throws Exception {
|
||||
AgentImageProcessor processor = new AgentImageProcessor(new AgentMediaProperties());
|
||||
|
||||
AgentImageData image = processor.process(file("sample.gif", "image/gif", imageBytes("gif", 4, 3)));
|
||||
|
||||
Assert.assertEquals("image/png", image.mimeType());
|
||||
Assert.assertEquals("png", image.extension());
|
||||
Assert.assertEquals(4, image.width());
|
||||
Assert.assertEquals(3, image.height());
|
||||
Assert.assertArrayEquals(new byte[]{(byte) 0x89, 0x50, 0x4e, 0x47},
|
||||
java.util.Arrays.copyOf(image.bytes(), 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证扩展名无法绕过真实图片内容校验。
|
||||
*/
|
||||
@Test
|
||||
public void processShouldRejectUnsupportedContent() {
|
||||
AgentImageProcessor processor = new AgentImageProcessor(new AgentMediaProperties());
|
||||
|
||||
ResponseStatusException error = Assert.assertThrows(ResponseStatusException.class,
|
||||
() -> processor.process(file("fake.png", "image/png", "not-an-image".getBytes())));
|
||||
|
||||
Assert.assertEquals(400, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证像素上限会在图片解码前置阶段生效。
|
||||
*
|
||||
* @throws Exception 图片生成失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void processShouldRejectImageOverPixelLimit() throws Exception {
|
||||
AgentMediaProperties properties = new AgentMediaProperties();
|
||||
properties.setMaxImagePixels(3);
|
||||
AgentImageProcessor processor = new AgentImageProcessor(properties);
|
||||
|
||||
ResponseStatusException error = Assert.assertThrows(ResponseStatusException.class,
|
||||
() -> processor.process(file("large.png", "image/png", imageBytes("png", 2, 2))));
|
||||
|
||||
Assert.assertEquals(400, error.getStatusCode().value());
|
||||
Assert.assertTrue(error.getReason().contains("像素过大"));
|
||||
}
|
||||
|
||||
private byte[] imageBytes(String format, int width, int height) throws IOException {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
Assert.assertTrue(ImageIO.write(image, format, output));
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private MultipartFile file(String name, String contentType, byte[] bytes) {
|
||||
return new ByteArrayMultipartFile(name, contentType, bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无磁盘依赖的测试 MultipartFile。
|
||||
*/
|
||||
private static final class ByteArrayMultipartFile implements MultipartFile {
|
||||
private final String name;
|
||||
private final String contentType;
|
||||
private final byte[] bytes;
|
||||
|
||||
private ByteArrayMultipartFile(String name, String contentType, byte[] bytes) {
|
||||
this.name = name;
|
||||
this.contentType = contentType;
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() { return "file"; }
|
||||
|
||||
@Override
|
||||
public String getOriginalFilename() { return name; }
|
||||
|
||||
@Override
|
||||
public String getContentType() { return contentType; }
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() { return bytes.length == 0; }
|
||||
|
||||
@Override
|
||||
public long getSize() { return bytes.length; }
|
||||
|
||||
@Override
|
||||
public byte[] getBytes() { return bytes.clone(); }
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
|
||||
|
||||
@Override
|
||||
public void transferTo(File destination) throws IOException {
|
||||
java.nio.file.Files.write(destination.toPath(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package tech.easyflow.agent.runtime.media;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import tech.easyflow.agent.config.AgentMediaProperties;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link AgentMediaService} 正式图片绑定测试。
|
||||
*/
|
||||
public class AgentMediaServiceTest {
|
||||
|
||||
private AgentMediaObjectStorage objectStorage;
|
||||
private RedisAgentMediaUploadStore uploadStore;
|
||||
private AgentMediaService service;
|
||||
private LoginAccount account;
|
||||
private AgentMediaUploadRecord upload;
|
||||
|
||||
/**
|
||||
* 初始化媒体服务测试依赖。
|
||||
*/
|
||||
@Before
|
||||
public void setUp() {
|
||||
objectStorage = Mockito.mock(AgentMediaObjectStorage.class);
|
||||
uploadStore = Mockito.mock(RedisAgentMediaUploadStore.class);
|
||||
service = new AgentMediaService(Mockito.mock(AgentImageProcessor.class), objectStorage,
|
||||
uploadStore, new AgentMediaProperties());
|
||||
account = new LoginAccount();
|
||||
account.setTenantId(BigInteger.valueOf(2));
|
||||
account.setId(BigInteger.valueOf(7));
|
||||
upload = buildUpload();
|
||||
Mockito.doAnswer(invocation -> {
|
||||
upload.setFormalReference(invocation.getArgument(1));
|
||||
return null;
|
||||
}).when(uploadStore).markFormalBinding(Mockito.same(upload), Mockito.anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证重复绑定同一上传凭据时复用首次正式引用。
|
||||
*/
|
||||
@Test
|
||||
public void bindFormalShouldReuseExistingBinding() {
|
||||
List<AgentBoundMedia> first = service.bindFormal(List.of(upload), "101", "201", account);
|
||||
List<AgentBoundMedia> second = service.bindFormal(List.of(upload), "101", "202", account);
|
||||
|
||||
Assert.assertEquals("formal:101:201:0:png", first.get(0).reference());
|
||||
Assert.assertEquals(first.get(0).reference(), second.get(0).reference());
|
||||
Assert.assertEquals("/api/v1/agent/media/content?reference=formal:101:201:0:png",
|
||||
second.get(0).payload().get("previewUrl"));
|
||||
Mockito.verify(objectStorage, Mockito.times(1))
|
||||
.copy("temp/2/7/upload-1.png", "formal/2/7/101/201/0.png");
|
||||
Mockito.verify(uploadStore, Mockito.times(1))
|
||||
.markFormalBinding(upload, "formal:101:201:0:png");
|
||||
Mockito.verify(objectStorage, Mockito.never()).delete(Mockito.anyString());
|
||||
Mockito.verify(uploadStore, Mockito.never()).delete(Mockito.any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证已绑定图片不能被复用到其他正式会话。
|
||||
*/
|
||||
@Test
|
||||
public void bindFormalShouldRejectAnotherSession() {
|
||||
upload.setFormalReference("formal:101:201:0:png");
|
||||
|
||||
ResponseStatusException error = Assert.assertThrows(ResponseStatusException.class,
|
||||
() -> service.bindFormal(List.of(upload), "102", "202", account));
|
||||
|
||||
Assert.assertEquals(HttpStatus.CONFLICT, error.getStatusCode());
|
||||
Mockito.verify(objectStorage, Mockito.never()).copy(Mockito.anyString(), Mockito.anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一条有效的临时图片凭据。
|
||||
*
|
||||
* @return 临时图片凭据
|
||||
*/
|
||||
private AgentMediaUploadRecord buildUpload() {
|
||||
AgentMediaUploadRecord record = new AgentMediaUploadRecord();
|
||||
record.setUploadId("upload-1");
|
||||
record.setTenantId("2");
|
||||
record.setUserId("7");
|
||||
record.setMode(AgentMediaService.MODE_FORMAL);
|
||||
record.setAgentId("9");
|
||||
record.setSessionId("101");
|
||||
record.setObjectKey("temp/2/7/upload-1.png");
|
||||
record.setOriginalName("sample.png");
|
||||
record.setMimeType("image/png");
|
||||
record.setExtension("png");
|
||||
record.setSize(128L);
|
||||
record.setWidth(32);
|
||||
record.setHeight(32);
|
||||
record.setExpiresAt(Instant.now().plusSeconds(3600));
|
||||
return record;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user