diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index a4fea314..5130c159 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -481,7 +481,8 @@ public class ChannelManager { case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, - approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler); + approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler, + generatedFileCache); case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper); case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper); case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 802db239..ecff71fd 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -469,7 +469,10 @@ public class ChannelMessageRouter { ResolveOutcome denyOutcome = approvalService.resolve( pending.getPendingId(), message.getSenderId(), "denied"); conversationService.removeApprovalPlaceholders(conversationId); - adapter.sendMessage(replyTarget, "⛔ 已拒绝执行工具: " + pending.getToolName()); + String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName(); + persistAndBroadcastApprovalHint(conversationId, denyHint, + "denied", pending.getPendingId(), pending.getToolName()); + adapter.sendMessage(replyTarget, denyHint); log.info("[{}] Approval DENIED via IM command: pendingId={}, tool={}, msgRewritten={}", adapter.getChannelType(), pending.getPendingId(), pending.getToolName(), denyOutcome.messagesRewritten()); @@ -479,7 +482,10 @@ public class ChannelMessageRouter { // Non-approval message while a pending exists → treat as implicit deny. approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied"); conversationService.removeApprovalPlaceholders(conversationId); - adapter.sendMessage(replyTarget, "⛔ 审批已取消。将继续处理您的新消息。"); + String cancelHint = "⛔ 审批已取消。将继续处理您的新消息。"; + persistAndBroadcastApprovalHint(conversationId, cancelHint, + "cancelled", pending.getPendingId(), pending.getToolName()); + adapter.sendMessage(replyTarget, cancelHint); log.info("[{}] Approval auto-cancelled (non-approval message): pendingId={}", adapter.getChannelType(), pending.getPendingId()); // Fall through to process the new message normally. @@ -763,8 +769,14 @@ public class ChannelMessageRouter { String replyTarget = resolveReplyTarget(triggerMessage); Long agentId = channelEntity.getAgentId(); - // 通知用户审批已通过 - adapter.sendMessage(replyTarget, "✅ 已批准执行工具: " + consumed.getToolName()); + // Notify the user that the approval went through. Persist + broadcast so a + // Web mirror of the same conversationId sees the resolution; otherwise this + // hint would only land in the IM channel and the Web admin console would + // show the replay reply with no preceding "approved" marker. + String approveHint = "✅ 已批准执行工具: " + consumed.getToolName(); + persistAndBroadcastApprovalHint(conversationId, approveHint, + "approved", consumed.getPendingId(), consumed.getToolName()); + adapter.sendMessage(replyTarget, approveHint); // 清理 DB 中残留的审批占位消息 conversationService.removeApprovalPlaceholders(conversationId); @@ -800,7 +812,62 @@ public class ChannelMessageRouter { adapter.getChannelType(), consumed.getToolName(), reply.length()); } catch (Exception e) { log.error("[approval-replay] Replay failed: {}", e.getMessage(), e); - adapter.sendMessage(replyTarget, "❌ 工具执行失败: " + e.getMessage()); + String errHint = "❌ 工具执行失败: " + e.getMessage(); + persistAndBroadcastApprovalHint(conversationId, errHint, null, null, null); + adapter.sendMessage(replyTarget, errHint); + } + } + + /** + * Persist an approval-related hint as an assistant message and best-effort + * broadcast it to any live SSE viewer of the conversation. + *
+ * Without this, IM-driven approve/deny only reaches the originating IM + * channel via {@code adapter.sendMessage(...)} — a Web mirror of the same + * conversationId has no record of the resolution because nothing lands in + * {@code mate_message} and no SSE event is emitted. The hint then "vanishes" + * from the Web admin console even though it shows up on the user's phone. + *
+ * Persistence is the load-bearing fix (Web reload picks it up). Broadcast
+ * is best-effort: if no SSE stream is currently registered for the
+ * conversation, the broadcast no-ops silently — that's the common case
+ * since IM-driven clicks rarely race with an active web subscriber.
+ *
+ * @param conversationId conversation owning the hint
+ * @param hint text to render as an assistant bubble
+ * @param decision "approved" / "denied" / "cancelled" / null (skips the
+ * structured resolved event when null, e.g. on replay error)
+ * @param pendingId pending approval id; null when not applicable
+ * @param toolName tool name for the structured event; null when not applicable
+ */
+ private void persistAndBroadcastApprovalHint(String conversationId, String hint,
+ String decision, String pendingId,
+ String toolName) {
+ try {
+ conversationService.saveMessage(conversationId, "assistant", hint, null, "completed");
+ } catch (Exception e) {
+ log.warn("[approval-hint] saveMessage failed for conv={}: {}",
+ conversationId, e.getMessage());
+ }
+ try {
+ if (decision != null) {
+ streamTracker.broadcastObject(conversationId, "tool_approval_resolved", Map.of(
+ "pendingId", pendingId == null ? "" : pendingId,
+ "decision", decision,
+ "toolName", toolName == null ? "" : toolName,
+ "timestamp", System.currentTimeMillis()
+ ));
+ }
+ streamTracker.broadcastObject(conversationId, "message_start",
+ Map.of("role", "assistant"));
+ streamTracker.broadcastObject(conversationId, "content_delta",
+ Map.of("delta", hint));
+ streamTracker.broadcastObject(conversationId, "message_complete",
+ Map.of("status", "completed"));
+ } catch (Exception e) {
+ // Broadcast is best-effort; a missing run state is the common case.
+ log.debug("[approval-hint] broadcast skipped/failed for conv={}: {}",
+ conversationId, e.getMessage());
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
index 96338fdc..a92af82e 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
@@ -12,6 +12,7 @@ import vip.mate.workspace.conversation.model.MessageContentPart;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
+import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
@@ -26,6 +27,8 @@ import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -240,16 +243,40 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
*/
private final WeComKeepaliveScheduler keepaliveScheduler;
+ /**
+ * In-memory cache of bytes generated by tools like
+ * {@code DocxRenderTool} / {@code PptxRenderTool}. The agent emits a
+ * {@code /api/v1/files/generated/{id}} URL referencing this cache; the
+ * channel layer resolves that URL back to bytes and uploads them as a
+ * native WeCom file message so the user actually receives a tappable
+ * document instead of an unopenable link. Null-tolerant: if missing
+ * (older constructor / test DI gap), URL stays inline as plain markdown
+ * which renders as a non-interactive link in the bubble.
+ */
+ private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
+
public WeComChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService,
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
WeComKeepaliveScheduler keepaliveScheduler) {
+ this(channelEntity, messageRouter, objectMapper, approvalNotificationService,
+ cardDispatcher, keepaliveScheduler, null);
+ }
+
+ public WeComChannelAdapter(ChannelEntity channelEntity,
+ ChannelMessageRouter messageRouter,
+ ObjectMapper objectMapper,
+ vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService,
+ vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
+ WeComKeepaliveScheduler keepaliveScheduler,
+ vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
super(channelEntity, messageRouter, objectMapper);
this.approvalNotificationService = approvalNotificationService;
this.cardDispatcher = cardDispatcher;
this.keepaliveScheduler = keepaliveScheduler;
+ this.generatedFileCache = generatedFileCache;
// Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential)
// so the UI eventually settles in ERROR instead of getting stuck in
// RECONNECTING forever. User config still overrides (-1 = infinite).
@@ -895,15 +922,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
Map
+ * When download is enabled and succeeds, the part carries
+ * {@code path}, {@code fileUrl} (browser-servable), {@code fileName},
+ * {@code storedName}, {@code fileSize}, and a precise {@code contentType}
+ * — every field the chat bubble and the multimodal sidecar inspect. When
+ * download is disabled or fails, the part falls back to URL-only fields
+ * but still sets {@code fileName} so the bubble doesn't render
+ * "未命名 / unknown".
+ */
+ private MessageContentPart buildInboundImagePart(String url, String aesKey, String msgId,
+ String fileNameHint, String conversationId) {
+ if (getConfigBoolean("media_download_enabled", true)) {
+ InboundMediaResult r = downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
+ if (r != null) {
+ MessageContentPart part = new MessageContentPart();
+ part.setType("image");
+ part.setFileName(r.fileName());
+ part.setStoredName(r.storedName());
+ part.setPath(r.localPath());
+ part.setFileUrl(r.fileUrl());
+ part.setFileSize(r.fileSize());
+ // Prefer the sniffed contentType (could be image/png) over a
+ // hardcoded image/jpeg. Falls back to image/jpeg only when the
+ // sniff was inconclusive.
+ String ct = r.contentType();
+ part.setContentType((ct != null && ct.startsWith("image/")) ? ct : "image/jpeg");
+ // mediaId mirrors path so callers that prefer it still resolve
+ // to the same on-disk file (matches Web upload's behaviour).
+ part.setMediaId(r.localPath());
+ return part;
+ }
+ }
+ // Fallback: download disabled or failed. Browser preview will be broken
+ // because the WeCom CDN URL carries a short-lived signature, but at
+ // least the bubble shows "image.jpg" instead of "未命名 / unknown".
+ MessageContentPart part = new MessageContentPart();
+ part.setType("image");
+ part.setFileName(fileNameHint);
+ part.setFileUrl(url);
+ part.setMediaId(url);
+ part.setContentType("image/jpeg");
+ return part;
+ }
+
+ /**
+ * Build a fully-populated file content part for inbound WeCom media.
+ *
+ * Mirrors {@link #buildInboundImagePart} but for non-image attachments
+ * (PDF, DOCX, ZIP, etc.). The magic-byte sniffer inside
+ * {@link #downloadInboundMedia} fixes generic {@code file.bin} hints to
+ * the real extension so downstream tools (PDF text extractor, magika,
+ * etc.) key off the correct mime.
+ */
+ private MessageContentPart buildInboundFilePart(String url, String aesKey, String msgId,
+ String fileNameHint, String conversationId) {
+ if (getConfigBoolean("media_download_enabled", true)) {
+ InboundMediaResult r = downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
+ if (r != null) {
+ MessageContentPart part = new MessageContentPart();
+ part.setType("file");
+ part.setFileName(r.fileName());
+ part.setStoredName(r.storedName());
+ part.setPath(r.localPath());
+ part.setFileUrl(r.fileUrl());
+ part.setFileSize(r.fileSize());
+ part.setContentType(r.contentType());
+ part.setMediaId(r.localPath());
+ return part;
+ }
+ }
+ // Fallback when download is disabled or fails — at least keep the
+ // original hint so the bubble doesn't say "file.bin" for a PDF.
+ MessageContentPart part = new MessageContentPart();
+ part.setType("file");
+ part.setFileName(fileNameHint);
+ part.setFileUrl(url);
+ part.setMediaId(url);
+ return part;
+ }
+
+ /**
+ * Inbound-media download result. Carries every field the bubble renderer
+ * and the multimodal sidecar need so callers don't have to re-derive
+ * storedName / fileUrl from scratch.
+ *
+ * @param localPath absolute filesystem path of the saved file
+ * @param storedName the on-disk filename (matches the last segment of localPath)
+ * @param fileUrl browser-servable URL: {@code /api/v1/chat/files/{convId}/{storedName}}
+ * @param fileSize byte length after decryption
+ * @param fileName human-readable display name (extension corrected by magic-byte sniff)
+ * @param contentType MIME type derived from magic bytes (or {@code application/octet-stream})
+ */
+ record InboundMediaResult(String localPath, String storedName,
+ String fileUrl, long fileSize, String fileName,
+ String contentType) {}
+
+ /** Magic-byte sniff result. */
+ private record MagicSniff(String extension, String contentType) {
+ static final MagicSniff UNKNOWN = new MagicSniff(".bin", "application/octet-stream");
+ }
+
+ /**
+ * Best-effort MIME sniff from the first 12 bytes of a file. Covers the
+ * formats users routinely forward to bots (PDF, Office, archives, common
+ * image / audio / video). When nothing matches, returns
+ * {@link MagicSniff#UNKNOWN} so the caller falls back to {@code .bin}.
+ *
+ * This exists because WeCom's {@code aibot_msg_callback} {@code file}
+ * body sometimes omits {@code filename} entirely (forwarded files in
+ * particular), and shipping the agent a part labelled {@code file.bin}
+ * makes downstream tools mis-route the content. Sniffing recovers a
+ * useful extension so PDF tools fire on PDFs.
+ */
+ private static MagicSniff sniffMagic(byte[] head) {
+ if (head == null || head.length < 4) return MagicSniff.UNKNOWN;
+ // PDF: %PDF
+ if (head[0] == 0x25 && head[1] == 0x50 && head[2] == 0x44 && head[3] == 0x46) {
+ return new MagicSniff(".pdf", "application/pdf");
+ }
+ // PNG: 89 50 4E 47
+ if (head[0] == (byte) 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47) {
+ return new MagicSniff(".png", "image/png");
+ }
+ // JPEG: FF D8 FF
+ if (head[0] == (byte) 0xFF && head[1] == (byte) 0xD8 && head[2] == (byte) 0xFF) {
+ return new MagicSniff(".jpg", "image/jpeg");
+ }
+ // GIF: "GIF8"
+ if (head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x38) {
+ return new MagicSniff(".gif", "image/gif");
+ }
+ // ZIP-based container: PK\x03\x04. Could be a plain ZIP, a JAR,
+ // an OOXML document (DOCX/XLSX/PPTX), an ODF document (ODT/ODS/ODP),
+ // or an EPUB. Magic-byte alone can't tell them apart — caller is
+ // expected to follow up with refineZipKind(fullBytes) to pick a
+ // specific type.
+ if (head[0] == 0x50 && head[1] == 0x4B && head[2] == 0x03 && head[3] == 0x04) {
+ return new MagicSniff(".zip", "application/zip");
+ }
+ // Legacy Office (DOC/XLS/PPT): D0 CF 11 E0 A1 B1 1A E1
+ if (head.length >= 8
+ && head[0] == (byte) 0xD0 && head[1] == (byte) 0xCF
+ && head[2] == 0x11 && head[3] == (byte) 0xE0
+ && head[4] == (byte) 0xA1 && head[5] == (byte) 0xB1
+ && head[6] == 0x1A && head[7] == (byte) 0xE1) {
+ return new MagicSniff(".doc", "application/msword");
+ }
+ // RTF: "{\rtf"
+ if (head.length >= 5
+ && head[0] == 0x7B && head[1] == 0x5C
+ && head[2] == 0x72 && head[3] == 0x74 && head[4] == 0x66) {
+ return new MagicSniff(".rtf", "application/rtf");
+ }
+ // 7z: 37 7A BC AF 27 1C
+ if (head.length >= 6
+ && head[0] == 0x37 && head[1] == 0x7A && head[2] == (byte) 0xBC
+ && head[3] == (byte) 0xAF && head[4] == 0x27 && head[5] == 0x1C) {
+ return new MagicSniff(".7z", "application/x-7z-compressed");
+ }
+ // RAR: "Rar!\x1A\x07"
+ if (head.length >= 6
+ && head[0] == 0x52 && head[1] == 0x61 && head[2] == 0x72
+ && head[3] == 0x21 && head[4] == 0x1A && head[5] == 0x07) {
+ return new MagicSniff(".rar", "application/x-rar-compressed");
+ }
+ // MP3: ID3v2 ("ID3") or MPEG sync 0xFFFB / 0xFFF3 / 0xFFF2
+ if (head[0] == 0x49 && head[1] == 0x44 && head[2] == 0x33) {
+ return new MagicSniff(".mp3", "audio/mpeg");
+ }
+ // MP4: "....ftyp" — bytes 4..7 == "ftyp"
+ if (head.length >= 8
+ && head[4] == 0x66 && head[5] == 0x74 && head[6] == 0x79 && head[7] == 0x70) {
+ return new MagicSniff(".mp4", "video/mp4");
+ }
+ // OGG: "OggS"
+ if (head[0] == 0x4F && head[1] == 0x67 && head[2] == 0x67 && head[3] == 0x53) {
+ return new MagicSniff(".ogg", "audio/ogg");
+ }
+ return MagicSniff.UNKNOWN;
+ }
+
+ /**
+ * Peek inside a ZIP container to distinguish OOXML (DOCX/XLSX/PPTX),
+ * ODF (ODT/ODS/ODP), JAR, and EPUB from a plain ZIP. Reads the local
+ * file headers in order via {@link ZipInputStream}; the discriminator
+ * entry is almost always within the first few entries (OOXML places
+ * {@code [Content_Types].xml} first, ODF places {@code mimetype} first),
+ * so we cap iteration at 16 entries to bound CPU.
+ *
+ * Returns the original {@code zipDefault} sniff (plain
+ * {@code application/zip}) when no specific kind is detected — that's
+ * the right answer for actual ZIPs and unknown archive formats.
+ */
+ private static MagicSniff refineZipKind(byte[] fileData, MagicSniff zipDefault) {
+ if (fileData == null || fileData.length < 30) return zipDefault;
+ String mimetypeContent = null;
+ try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(fileData))) {
+ ZipEntry entry;
+ int seen = 0;
+ while ((entry = zis.getNextEntry()) != null && seen < 16) {
+ String name = entry.getName();
+ // OOXML — Office Open XML (Word/Excel/PowerPoint). Each format
+ // has a distinct top-level directory; we match on prefix
+ // because the entry order isn't guaranteed.
+ if (name.startsWith("word/")) {
+ return new MagicSniff(".docx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+ }
+ if (name.startsWith("xl/")) {
+ return new MagicSniff(".xlsx",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ }
+ if (name.startsWith("ppt/")) {
+ return new MagicSniff(".pptx",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation");
+ }
+ // Visio (rare but worth catching)
+ if (name.startsWith("visio/")) {
+ return new MagicSniff(".vsdx",
+ "application/vnd.ms-visio.drawing");
+ }
+ // ODF marker: a {@code mimetype} entry that contains the full
+ // application/vnd.oasis.opendocument.* string — read its body
+ // and decide once we have it.
+ if ("mimetype".equals(name)) {
+ byte[] buf = zis.readAllBytes();
+ mimetypeContent = new String(buf, java.nio.charset.StandardCharsets.UTF_8).trim();
+ }
+ // JAR
+ if ("META-INF/MANIFEST.MF".equals(name)) {
+ return new MagicSniff(".jar", "application/java-archive");
+ }
+ // EPUB always has META-INF/container.xml
+ if ("META-INF/container.xml".equals(name)) {
+ return new MagicSniff(".epub", "application/epub+zip");
+ }
+ seen++;
+ }
+ } catch (Exception e) {
+ log.debug("[wecom] refineZipKind failed (treating as plain zip): {}", e.getMessage());
+ return zipDefault;
+ }
+ if (mimetypeContent != null) {
+ if (mimetypeContent.contains("opendocument.text")) {
+ return new MagicSniff(".odt", "application/vnd.oasis.opendocument.text");
+ }
+ if (mimetypeContent.contains("opendocument.spreadsheet")) {
+ return new MagicSniff(".ods", "application/vnd.oasis.opendocument.spreadsheet");
+ }
+ if (mimetypeContent.contains("opendocument.presentation")) {
+ return new MagicSniff(".odp", "application/vnd.oasis.opendocument.presentation");
+ }
+ if (mimetypeContent.contains("epub")) {
+ return new MagicSniff(".epub", "application/epub+zip");
+ }
+ }
+ return zipDefault;
+ }
+
+ /**
+ * Strip a trailing extension from a filename. {@code "image.jpg" → "image"};
+ * {@code "no_ext" → "no_ext"}; {@code "" → ""}.
+ */
+ private static String stripExtension(String name) {
+ if (name == null || name.isBlank()) return "";
+ int dot = name.lastIndexOf('.');
+ if (dot <= 0) return name;
+ return name.substring(0, dot);
+ }
+
+ /**
+ * Download + decrypt an inbound media attachment and stash it under
+ * {@code data/chat-uploads/{conversationId}/} so the existing
+ * {@code /api/v1/chat/files/...} endpoint can serve it back to the chat
+ * bubble. Returns a fully-populated {@link InboundMediaResult} on success
+ * or null on download/decrypt failure (callers fall back to URL-only).
+ *
+ * Storing under chat-uploads rather than {@code data/media} means
+ * {@link MessageContentPart#getPath()} resolves to a real file for the
+ * vision sidecar AND {@code fileUrl} renders as a thumbnail in the Web
+ * mirror — instead of the WeCom-signed CDN URL whose 5-minute query-string
+ * signature expires before the browser can fetch it.
+ */
+ private InboundMediaResult downloadInboundMedia(String url, String aesKey, String msgId,
+ String fileNameHint, String conversationId) {
+ try {
+ // Mirror ChatController.uploadRoot ("data/chat-uploads") so the
+ // serve endpoint at /api/v1/chat/files/{convId}/{storedName} works
+ // without any extra wiring. The conversationId may contain ':'
+ // (e.g. "wecom:XuZhanFu" or "wecom:group:abc"); Path resolution
+ // tolerates this on macOS/Linux but Windows would reject the
+ // colon — for now we keep parity with the existing chat-uploads
+ // layout and revisit if Windows support comes up.
+ Path uploadDir = Path.of("data", "chat-uploads", conversationId);
+ Files.createDirectories(uploadDir);
+
+ // 1. HTTP GET 下载文件
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(30))
+ .GET()
+ .build();
+
+ HttpResponse
* AES-256-CBC 解密:base64 decode aesKey → IV = 前 16 字节 → PKCS#7 去填充
*