diff --git a/android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java b/android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java index 1a0da95..b5c9215 100644 --- a/android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java +++ b/android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java @@ -1,12 +1,14 @@ package com.hyzq.boss; +import android.net.Uri; + import androidx.annotation.Nullable; public final class AttachmentComposerState { private AttachmentComposerState() {} public static boolean requiresConfirmation(@Nullable String sourceType) { - return "image".equals(sourceType) || "video".equals(sourceType); + return ProjectChatUiState.requiresAttachmentConfirmation(sourceType); } public static final class PendingAttachment { @@ -14,22 +16,22 @@ public final class AttachmentComposerState { public final String fileName; public final String mimeType; public final long fileSizeBytes; - public final byte[] bytes; + @Nullable public final Uri uri; public PendingAttachment( String sourceType, String fileName, String mimeType, long fileSizeBytes, - byte[] bytes + @Nullable Uri uri ) { this.sourceType = sourceType == null ? "file" : sourceType; this.fileName = fileName == null || fileName.trim().isEmpty() ? "attachment" : fileName; this.mimeType = mimeType == null || mimeType.trim().isEmpty() ? "application/octet-stream" : mimeType; - this.fileSizeBytes = Math.max(fileSizeBytes, bytes == null ? 0 : bytes.length); - this.bytes = bytes == null ? new byte[0] : bytes.clone(); + this.fileSizeBytes = Math.max(fileSizeBytes, 0L); + this.uri = uri; } public boolean requiresConfirmation() { diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index faabd28..c0d0069 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -11,6 +11,8 @@ import org.json.JSONObject; import java.io.BufferedReader; import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -107,6 +109,22 @@ public class BossApiClient { String mimeType, byte[] bytes, String sourceType + ) throws IOException, JSONException { + return uploadAttachment( + projectId, + fileName, + mimeType, + new ByteArrayInputStream(bytes == null ? new byte[0] : bytes), + sourceType + ); + } + + public ApiResponse uploadAttachment( + String projectId, + String fileName, + String mimeType, + InputStream inputStream, + String sourceType ) throws IOException, JSONException { HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments"); prepareConnection(connection, "POST"); @@ -121,7 +139,7 @@ public class BossApiClient { outputStream, boundary, "file", - bytes == null ? new byte[0] : bytes, + inputStream, fileName, mimeType ); @@ -139,6 +157,25 @@ public class BossApiClient { ); } + public DownloadedAttachment downloadAttachment( + String attachmentId, + String fallbackFileName, + String fallbackMimeType + ) throws IOException { + DownloadedAttachment attachment = downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true); + if (attachment.statusCode == 401 && !getRestoreToken().isEmpty()) { + try { + ApiResponse restored = restoreSession(); + if (restored.ok()) { + return downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true); + } + } catch (JSONException exception) { + throw new IOException("SESSION_RESTORE_FAILED", exception); + } + } + return attachment; + } + public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException { String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload); return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody); @@ -380,7 +417,7 @@ public class BossApiClient { OutputStream outputStream, String boundary, String fieldName, - byte[] bytes, + InputStream inputStream, String fileName, String contentType ) throws IOException { @@ -398,10 +435,57 @@ public class BossApiClient { ? "application/octet-stream" : contentType) + "\r\n\r\n").getBytes(StandardCharsets.UTF_8) ); - outputStream.write(bytes == null ? new byte[0] : bytes); + if (inputStream != null) { + byte[] buffer = new byte[8192]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + } + } outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8)); } + private DownloadedAttachment downloadAttachmentRaw( + String attachmentId, + String fallbackFileName, + String fallbackMimeType, + boolean expectProtected + ) throws IOException { + HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download"); + prepareConnection(connection, "GET"); + int statusCode = connection.getResponseCode(); + captureSessionCookie(connection.getHeaderFields()); + + if (statusCode >= 400) { + String errorBody = readText(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream()); + if (statusCode == 401 && !expectProtected) { + clearSession(); + } + return DownloadedAttachment.error(statusCode, errorBody); + } + + byte[] bytes; + try (InputStream inputStream = connection.getInputStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int read; + while (inputStream != null && (read = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + } + bytes = outputStream.toByteArray(); + } + + String contentType = connection.getHeaderField("Content-Type"); + String fileName = parseDownloadFileName(connection.getHeaderField("Content-Disposition")); + return new DownloadedAttachment( + statusCode, + fileName == null || fileName.isEmpty() ? fallbackFileName : fileName, + contentType == null || contentType.isEmpty() ? fallbackMimeType : contentType, + bytes, + "" + ); + } + private String escapeMultipartValue(String value) { if (value == null) { return "attachment"; @@ -427,6 +511,35 @@ public class BossApiClient { return new JSONObject(raw); } + private String readText(InputStream stream) throws IOException { + if (stream == null) { + return ""; + } + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + } + return builder.toString(); + } + + @Nullable + private String parseDownloadFileName(@Nullable String contentDisposition) { + if (contentDisposition == null || contentDisposition.isEmpty()) { + return null; + } + String[] parts = contentDisposition.split(";"); + for (String part : parts) { + String trimmed = part.trim(); + if (trimmed.startsWith("filename=")) { + return trimmed.substring("filename=".length()).replace("\"", ""); + } + } + return null; + } + private void captureSessionCookie(Map> headers) { if (headers == null) return; List setCookieHeaders = headers.get("Set-Cookie"); @@ -503,4 +616,34 @@ public class BossApiClient { return new ApiResponse(statusCode, json); } } + + public static class DownloadedAttachment { + public final int statusCode; + public final String fileName; + public final String mimeType; + public final byte[] bytes; + public final String errorBody; + + public DownloadedAttachment( + int statusCode, + String fileName, + String mimeType, + byte[] bytes, + String errorBody + ) { + this.statusCode = statusCode; + this.fileName = fileName; + this.mimeType = mimeType; + this.bytes = bytes == null ? new byte[0] : bytes; + this.errorBody = errorBody == null ? "" : errorBody; + } + + public boolean ok() { + return statusCode >= 200 && statusCode < 300; + } + + public static DownloadedAttachment error(int statusCode, String errorBody) { + return new DownloadedAttachment(statusCode, "", "application/octet-stream", new byte[0], errorBody); + } + } } diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index ee053d0..ee32af1 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -711,8 +711,12 @@ public final class BossUi { String fileName, @Nullable String detail, @Nullable String status, + @Nullable String summary, + @Nullable String actionLabel, + @Nullable View.OnClickListener actionListener, @Nullable String meta, - boolean outgoing + boolean outgoing, + @Nullable View.OnClickListener cardClickListener ) { LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing); @@ -725,6 +729,11 @@ public final class BossUi { dp(context, 18) )); card.setElevation(dp(context, 1)); + if (cardClickListener != null) { + card.setClickable(true); + card.setFocusable(true); + card.setOnClickListener(cardClickListener); + } if ("image".equals(sourceType) || "video".equals(sourceType)) { TextView preview = new TextView(context); @@ -787,6 +796,32 @@ public final class BossUi { card.addView(row); } + if (!TextUtils.isEmpty(summary)) { + TextView summaryView = buildAttachmentSecondaryText(context, summary); + summaryView.setPadding(0, dp(context, 10), 0, 0); + summaryView.setTextColor(context.getColor(R.color.boss_text_primary)); + card.addView(summaryView); + } + + if (!TextUtils.isEmpty(actionLabel) && actionListener != null) { + Button actionButton = new Button(context); + LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + dp(context, 34) + ); + actionParams.topMargin = dp(context, 12); + actionButton.setLayoutParams(actionParams); + actionButton.setMinWidth(dp(context, 88)); + actionButton.setText(actionLabel); + actionButton.setAllCaps(false); + actionButton.setTextSize(13); + actionButton.setPadding(dp(context, 14), 0, dp(context, 14), 0); + actionButton.setBackgroundResource(R.drawable.bg_secondary_button); + actionButton.setTextColor(context.getColor(R.color.boss_text_primary)); + actionButton.setOnClickListener(actionListener); + card.addView(actionButton); + } + wrapper.addView(card); return wrapper; } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index 2fa84fc..cebfeb2 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -60,6 +60,10 @@ public final class ProjectChatUiState { return !sending && text != null && !text.trim().isEmpty(); } + public static boolean requiresAttachmentConfirmation(@Nullable String sourceType) { + return "image".equals(sourceType) || "video".equals(sourceType); + } + public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) { return nearBottom || forced; } @@ -163,11 +167,14 @@ public final class ProjectChatUiState { } public static String labelForAttachmentAnalysisState(@Nullable String analysisState) { - if ("queued_auto".equals(analysisState) || "ready_manual".equals(analysisState)) { + if ("queued_auto".equals(analysisState)) { + return "自动分析排队中"; + } + if ("ready_manual".equals(analysisState)) { return "待分析"; } if ("processing".equals(analysisState)) { - return "分析中"; + return "AI 分析中"; } if ("completed".equals(analysisState)) { return "已分析"; @@ -178,6 +185,17 @@ public final class ProjectChatUiState { return "已发送"; } + @Nullable + public static String actionLabelForAttachmentAnalysisState(@Nullable String analysisState) { + if ("ready_manual".equals(analysisState)) { + return "让 AI 分析"; + } + if ("failed".equals(analysisState)) { + return "重试分析"; + } + return null; + } + public static String labelForAttachmentKind(@Nullable String attachmentKind) { if ("image".equals(attachmentKind)) { return "图片"; diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 397181b..223a42c 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -1,7 +1,9 @@ package com.hyzq.boss; +import android.app.Dialog; import android.content.ClipData; import android.content.ClipboardManager; +import android.content.ActivityNotFoundException; import android.content.Intent; import android.database.Cursor; import android.net.Uri; @@ -16,16 +18,19 @@ import android.widget.Button; import android.widget.EditText; import android.widget.LinearLayout; import android.widget.ScrollView; +import android.widget.TextView; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.core.content.FileProvider; import org.json.JSONArray; import org.json.JSONObject; -import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; import java.io.InputStream; import java.util.ArrayList; import java.util.List; @@ -330,23 +335,60 @@ public class ProjectDetailActivity extends BossScreenActivity { if (isComposerBusy()) { return; } - AlertDialog dialog = new AlertDialog.Builder(this) - .setItems(new CharSequence[]{"图片", "视频", "文件"}, (sheet, which) -> { - if (which == 0) { - imagePickerLauncher.launch("image/*"); - } else if (which == 1) { - videoPickerLauncher.launch("video/*"); - } else { - filePickerLauncher.launch("*/*"); - } - }) - .create(); + Dialog dialog = new Dialog(this); + LinearLayout sheet = new LinearLayout(this); + sheet.setOrientation(LinearLayout.VERTICAL); + sheet.setPadding(BossUi.dp(this, 18), BossUi.dp(this, 12), BossUi.dp(this, 18), BossUi.dp(this, 18)); + + TextView title = new TextView(this); + title.setText("发送附件"); + title.setTextSize(16); + title.setTypeface(android.graphics.Typeface.DEFAULT_BOLD); + title.setTextColor(getColor(R.color.boss_text_primary)); + title.setPadding(0, 0, 0, BossUi.dp(this, 12)); + sheet.addView(title); + + sheet.addView(buildAttachmentSheetAction("图片", () -> imagePickerLauncher.launch("image/*"), dialog)); + sheet.addView(buildAttachmentSheetAction("视频", () -> videoPickerLauncher.launch("video/*"), dialog)); + sheet.addView(buildAttachmentSheetAction("文件", () -> filePickerLauncher.launch("*/*"), dialog)); + + dialog.setContentView(sheet); dialog.show(); if (dialog.getWindow() != null) { - dialog.getWindow().setGravity(Gravity.BOTTOM); + dialog.getWindow().setLayout( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + dialog.getWindow().setGravity(Gravity.BOTTOM); } } + private View buildAttachmentSheetAction( + String label, + Runnable action, + Dialog dialog + ) { + Button button = new Button(this); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + BossUi.dp(this, 48) + ); + params.bottomMargin = BossUi.dp(this, 8); + button.setLayoutParams(params); + button.setBackgroundResource(R.drawable.bg_secondary_button); + button.setAllCaps(false); + button.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + button.setPadding(BossUi.dp(this, 16), 0, BossUi.dp(this, 16), 0); + button.setText(label); + button.setTextSize(15); + button.setTextColor(getColor(R.color.boss_text_primary)); + button.setOnClickListener(v -> { + dialog.dismiss(); + action.run(); + }); + return button; + } + private void onAttachmentPicked(@Nullable Uri uri, String sourceType) { if (uri == null) { return; @@ -419,12 +461,12 @@ public class ProjectDetailActivity extends BossScreenActivity { appendPendingOutgoingAttachment(attachment); scrollChatToBottom(); executor.execute(() -> { - try { + try (InputStream inputStream = openAttachmentInputStream(attachment)) { BossApiClient.ApiResponse uploadResponse = apiClient.uploadAttachment( projectId, attachment.fileName, attachment.mimeType, - attachment.bytes, + inputStream, attachment.sourceType ); if (!uploadResponse.ok()) { @@ -433,19 +475,14 @@ public class ProjectDetailActivity extends BossScreenActivity { String successMessage = "附件已发送"; JSONObject uploadedAttachment = uploadResponse.json.optJSONObject("attachment"); - if (uploadedAttachment != null && attachment.requiresConfirmation()) { - String attachmentId = uploadedAttachment.optString("attachmentId", ""); + if (uploadedAttachment != null) { String analysisState = uploadedAttachment.optString("analysisState", ""); - if (!TextUtils.isEmpty(attachmentId) - && ("ready_manual".equals(analysisState) || "failed".equals(analysisState))) { - try { - BossApiClient.ApiResponse analyzeResponse = apiClient.analyzeAttachment(projectId, attachmentId); - successMessage = analyzeResponse.ok() - ? "附件已发送,分析已发起" - : "附件已发送,分析稍后可用"; - } catch (Exception ignored) { - successMessage = "附件已发送,分析稍后可用"; - } + if ("queued_auto".equals(analysisState)) { + successMessage = "附件已发送,自动分析排队中"; + } else if ("ready_manual".equals(analysisState)) { + successMessage = "附件已发送,可手动发起分析"; + } else if ("failed".equals(analysisState)) { + successMessage = "附件已发送,分析稍后可重试"; } } @@ -501,9 +538,17 @@ public class ProjectDetailActivity extends BossScreenActivity { boolean outgoing = isOutgoingMessage(senderLabel); View messageView; + View.OnClickListener messagePrimaryClick = null; switch (kind) { case "attachment": messageView = buildAttachmentMessageView(message, senderLabel, meta, outgoing); + JSONObject attachment = firstAttachment(message); + if (attachment != null) { + String attachmentId = attachment.optString("attachmentId", ""); + if (!TextUtils.isEmpty(attachmentId)) { + messagePrimaryClick = v -> openAttachment(attachment); + } + } break; case "forward_single": messageView = BossUi.buildForwardSingleBubble( @@ -536,7 +581,7 @@ public class ProjectDetailActivity extends BossScreenActivity { ); break; } - bindMessageInteractions(messageView, messageId, body); + bindMessageInteractions(messageView, messageId, body, messagePrimaryClick); return messageView; } @@ -564,19 +609,34 @@ public class ProjectDetailActivity extends BossScreenActivity { String detail = ("image".equals(sourceType) || "video".equals(sourceType)) ? null : ProjectChatUiState.formatAttachmentSize(attachment.optLong("fileSizeBytes", 0L)); + String analysisState = attachment.optString("analysisState", ""); + String attachmentId = attachment.optString("attachmentId", ""); + String actionLabel = ProjectChatUiState.actionLabelForAttachmentAnalysisState(analysisState); + View.OnClickListener actionListener = TextUtils.isEmpty(actionLabel) || TextUtils.isEmpty(attachmentId) + ? null + : v -> requestAttachmentAnalysis(attachmentId, attachment.optString("fileName", "附件")); return BossUi.buildAttachmentMessageCard( this, senderLabel, sourceType, attachment.optString("fileName", "attachment"), detail, - ProjectChatUiState.labelForAttachmentAnalysisState(attachment.optString("analysisState", "")), + ProjectChatUiState.labelForAttachmentAnalysisState(analysisState), + attachment.optString("analysisSummary", ""), + actionLabel, + actionListener, meta, - outgoing + outgoing, + null ); } - private void bindMessageInteractions(View messageView, String messageId, String body) { + private void bindMessageInteractions( + View messageView, + String messageId, + String body, + @Nullable View.OnClickListener defaultClickListener + ) { if (messageView == null || TextUtils.isEmpty(messageId)) { return; } @@ -585,6 +645,9 @@ public class ProjectDetailActivity extends BossScreenActivity { messageView.setLongClickable(true); messageView.setOnClickListener(v -> { if (!selectionState.multiSelecting) { + if (defaultClickListener != null) { + defaultClickListener.onClick(v); + } return; } toggleMultiSelectMessage(messageId); @@ -810,7 +873,11 @@ public class ProjectDetailActivity extends BossScreenActivity { detail, "发送中", null, - true + null, + null, + null, + true, + null ); appendContent(pendingOutgoingBubble); } @@ -933,15 +1000,8 @@ public class ProjectDetailActivity extends BossScreenActivity { private AttachmentComposerState.PendingAttachment readPendingAttachment(Uri uri, String sourceType) throws Exception { String fileName = resolveAttachmentFileName(uri); String mimeType = resolveAttachmentMimeType(uri, sourceType); - byte[] bytes; - try (InputStream inputStream = getContentResolver().openInputStream(uri)) { - if (inputStream == null) { - throw new IllegalStateException("无法打开附件输入流"); - } - bytes = readAllBytes(inputStream); - } - long fileSize = resolveAttachmentFileSize(uri, bytes.length); - return new AttachmentComposerState.PendingAttachment(sourceType, fileName, mimeType, fileSize, bytes); + long fileSize = resolveAttachmentFileSize(uri, 0L); + return new AttachmentComposerState.PendingAttachment(sourceType, fileName, mimeType, fileSize, uri); } private String resolveAttachmentFileName(Uri uri) { @@ -1001,14 +1061,104 @@ public class ProjectDetailActivity extends BossScreenActivity { return "application/octet-stream"; } - private byte[] readAllBytes(InputStream inputStream) throws Exception { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - byte[] buffer = new byte[8192]; - int read; - while ((read = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, read); + private InputStream openAttachmentInputStream(AttachmentComposerState.PendingAttachment attachment) throws Exception { + if (attachment.uri == null) { + throw new IllegalStateException("附件来源不存在"); + } + InputStream inputStream = getContentResolver().openInputStream(attachment.uri); + if (inputStream == null) { + throw new IllegalStateException("无法打开附件输入流"); + } + return inputStream; + } + + private void requestAttachmentAnalysis(String attachmentId, String fileName) { + if (TextUtils.isEmpty(attachmentId)) { + showMessage("缺少附件标识"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.analyzeAttachment(projectId, attachmentId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + showMessage("已发起分析:" + fileName); + reload(false); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("发起分析失败:" + error.getMessage()); + }); + } + }); + } + + private void openAttachment(JSONObject attachment) { + String attachmentId = attachment.optString("attachmentId", ""); + if (TextUtils.isEmpty(attachmentId)) { + showMessage("缺少附件标识"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.DownloadedAttachment downloaded = apiClient.downloadAttachment( + attachmentId, + attachment.optString("fileName", "attachment"), + attachment.optString("mimeType", "application/octet-stream") + ); + if (!downloaded.ok()) { + throw new IllegalStateException( + TextUtils.isEmpty(downloaded.errorBody) + ? "DOWNLOAD_FAILED" + : downloaded.errorBody + ); + } + File file = cacheDownloadedAttachment(downloaded.fileName, downloaded.bytes); + runOnUiThread(() -> openDownloadedAttachment(file, downloaded.mimeType)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("打开附件失败:" + error.getMessage()); + }); + } + }); + } + + private File cacheDownloadedAttachment(String fileName, byte[] bytes) throws Exception { + File downloadsDir = new File(getCacheDir(), "attachment-downloads"); + if (!downloadsDir.exists() && !downloadsDir.mkdirs()) { + throw new IllegalStateException("无法创建附件缓存目录"); + } + File file = new File(downloadsDir, sanitizeAttachmentFileName(fileName)); + try (FileOutputStream outputStream = new FileOutputStream(file, false)) { + outputStream.write(bytes); + } + return file; + } + + private String sanitizeAttachmentFileName(String fileName) { + String trimmed = TextUtils.isEmpty(fileName) ? "attachment" : fileName.trim(); + return trimmed.replace('/', '_').replace('\\', '_'); + } + + private void openDownloadedAttachment(File file, String mimeType) { + Uri uri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", file); + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, TextUtils.isEmpty(mimeType) ? "application/octet-stream" : mimeType); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + startActivity(Intent.createChooser(intent, "打开附件")); + } catch (ActivityNotFoundException error) { + showMessage("系统中没有可打开该附件的应用"); + } finally { + setRefreshing(false); } - return outputStream.toByteArray(); } static ChromeBindings buildChromeBindings( diff --git a/android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java b/android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java index d82e79e..e2c1681 100644 --- a/android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java +++ b/android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java @@ -14,9 +14,10 @@ public class AttachmentComposerStateTest { "现场照片.png", "image/png", 4096L, - new byte[] {1, 2, 3} + null ); + assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType)); assertTrue(attachment.requiresConfirmation()); } @@ -28,9 +29,10 @@ public class AttachmentComposerStateTest { "巡检录屏.mp4", "video/mp4", 8192L, - new byte[] {4, 5, 6} + null ); + assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType)); assertTrue(attachment.requiresConfirmation()); } @@ -42,9 +44,10 @@ public class AttachmentComposerStateTest { "日报.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 16384L, - new byte[] {7, 8, 9} + null ); + assertFalse(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType)); assertFalse(attachment.requiresConfirmation()); } } diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java index 1034b1e..878d1c3 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -6,11 +6,14 @@ import static org.junit.Assert.assertTrue; import android.content.Intent; import android.view.Gravity; import android.view.View; +import android.view.ViewGroup; import android.widget.Button; import android.widget.LinearLayout; import android.widget.ListView; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; +import org.json.JSONArray; +import org.json.JSONObject; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Robolectric; @@ -75,6 +78,42 @@ public class ProjectDetailActivityUiTest { assertEquals(View.VISIBLE, refreshButton.getVisibility()); } + @Test + public void manualAnalysisAttachmentShowsActionChip() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + JSONObject attachment = new JSONObject() + .put("attachmentId", "att-1") + .put("fileName", "巡检录像.mp4") + .put("mimeType", "video/mp4") + .put("attachmentKind", "video") + .put("analysisState", "ready_manual") + .put("fileSizeBytes", 2048); + JSONObject message = new JSONObject() + .put("id", "msg-1") + .put("kind", "attachment") + .put("body", "已发送附件") + .put("attachments", new JSONArray().put(attachment)); + + View attachmentView = ReflectionHelpers.callInstanceMethod( + activity, + "buildAttachmentMessageView", + ReflectionHelpers.ClassParameter.from(JSONObject.class, message), + ReflectionHelpers.ClassParameter.from(String.class, "你"), + ReflectionHelpers.ClassParameter.from(String.class, "09:26"), + ReflectionHelpers.ClassParameter.from(boolean.class, true) + ); + + assertTrue(viewTreeContainsText(attachmentView, "让 AI 分析")); + assertTrue(viewTreeContainsText(attachmentView, "待分析")); + } + private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) { TextView messageView = new TextView(activity); messageView.setText(body); @@ -84,11 +123,31 @@ public class ProjectDetailActivityUiTest { "bindMessageInteractions", ReflectionHelpers.ClassParameter.from(View.class, messageView), ReflectionHelpers.ClassParameter.from(String.class, messageId), - ReflectionHelpers.ClassParameter.from(String.class, body) + ReflectionHelpers.ClassParameter.from(String.class, body), + ReflectionHelpers.ClassParameter.from(View.OnClickListener.class, null) ); return messageView; } + private static boolean viewTreeContainsText(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (expectedText.contentEquals(text)) { + return true; + } + } + if (!(root instanceof ViewGroup)) { + return false; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsText(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } + public static class TestProjectDetailActivity extends ProjectDetailActivity { @Override boolean shouldLoadOnCreate() { diff --git a/scripts/validate-attachment-analysis.mjs b/scripts/validate-attachment-analysis.mjs index 0491270..af2bcea 100644 --- a/scripts/validate-attachment-analysis.mjs +++ b/scripts/validate-attachment-analysis.mjs @@ -3,41 +3,24 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { spawn } from "node:child_process"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; +const execFile = promisify(execFileCallback); const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-attachment-analysis-")); -const stateFile = path.join(runtimeDir, "data", "boss-state.json"); -const require = createRequire(import.meta.url); +const taskBaseCommit = "3307f7916220b74a8e7d0d8e8b2b12f888d0632a"; +const sourceStateFile = path.join(rootDir, "data", "boss-state.json"); -process.env.BOSS_RUNTIME_ROOT = runtimeDir; -process.env.BOSS_STATE_FILE = stateFile; -process.env.BOSS_AUTH_AUTO_LOGIN = "0"; - -const { NextRequest } = require("next/server"); -const authLoginRoute = require(path.join(rootDir, ".next/standalone/.next/server/app/api/auth/login/route.js")); -const attachmentsRoute = require( - path.join(rootDir, ".next/standalone/.next/server/app/api/v1/projects/[projectId]/attachments/route.js"), -); -const analyzeRoute = require( - path.join( - rootDir, - ".next/standalone/.next/server/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.js", - ), -); - -const loginHandler = authLoginRoute.routeModule.userland.POST; -const uploadHandler = attachmentsRoute.routeModule.userland.POST; -const analyzeHandler = analyzeRoute.routeModule.userland.POST; - -async function invokeRoute(handler, url, init = {}, context) { - const request = new NextRequest(url, { - method: init.method ?? "GET", - headers: init.headers, - body: init.body, - }); - return handler(request, context); +async function createSeededRuntime(root, runtimeName) { + const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), runtimeName)); + const stateFile = path.join(runtimeDir, "data", "boss-state.json"); + await fs.mkdir(path.join(runtimeDir, "data", "uploads"), { recursive: true }); + await fs.mkdir(path.join(runtimeDir, "public", "downloads"), { recursive: true }); + const state = JSON.parse(await fs.readFile(sourceStateFile, "utf8")); + await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf8"); + return { runtimeDir, stateFile }; } function parseCookieValue(setCookieHeader, cookieName) { @@ -47,20 +30,73 @@ function parseCookieValue(setCookieHeader, cookieName) { return match[1]; } -async function loginAsAdmin() { - const response = await invokeRoute( - loginHandler, - "http://localhost/api/auth/login", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - account: "17600003315", - password: "boss123456", - method: "password", - }), +async function waitForServer(baseUrl, child, getServerLogs) { + for (let attempt = 0; attempt < 60; attempt += 1) { + if (child.exitCode !== null) { + throw new Error(`SERVER_EXITED_EARLY:${child.exitCode}:${getServerLogs()}`); + } + try { + const response = await fetch(`${baseUrl}/api/health`); + if (response.ok) { + return; + } + } catch { + // keep waiting + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`SERVER_START_TIMEOUT:${getServerLogs()}`); +} + +async function startStandaloneServer(appRoot, runtimeDir, port) { + const baseUrl = `http://127.0.0.1:${port}`; + let logs = ""; + const child = spawn("node", [".next/standalone/server.js"], { + cwd: appRoot, + env: { + ...process.env, + PORT: String(port), + HOSTNAME: "127.0.0.1", + BOSS_RUNTIME_ROOT: runtimeDir, + BOSS_STATE_FILE: path.join(runtimeDir, "data", "boss-state.json"), + BOSS_AUTH_AUTO_LOGIN: "0", }, - ); + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => { + logs += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + logs += chunk.toString(); + }); + + await waitForServer(baseUrl, child, () => logs); + return { + baseUrl, + child, + getLogs: () => logs, + async stop() { + if (child.exitCode === null) { + child.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + }, + }; +} + +async function loginAsAdmin(baseUrl) { + const response = await fetch(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + account: "17600003315", + password: "boss123456", + method: "password", + }), + }); assert.equal(response.status, 200, "login should succeed"); const payload = await response.json(); assert.equal(payload.ok, true, "login payload should be ok"); @@ -68,74 +104,135 @@ async function loginAsAdmin() { return { cookie, payload }; } -async function uploadAttachment(cookie, projectId, fileName, type, bytes) { +async function uploadAttachment(baseUrl, cookie, projectId, fileName, type, bytes) { const form = new FormData(); form.set("file", new File([bytes], fileName, { type })); - const response = await invokeRoute( - uploadHandler, - `http://localhost/api/v1/projects/${projectId}/attachments`, - { - method: "POST", - headers: { cookie: `boss_session=${cookie}` }, - body: form, + const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments`, { + method: "POST", + headers: { + cookie: `boss_session=${cookie}`, }, - { params: Promise.resolve({ projectId }) }, - ); + body: form, + }); assert.equal(response.status, 200, `upload ${fileName} should succeed`); return response.json(); } -const { cookie } = await loginAsAdmin(); - -const textUpload = await uploadAttachment( - cookie, - "master-agent", - "analysis-note.txt", - "text/plain", - Buffer.from("text attachment for automatic analysis"), -); -assert.equal(textUpload.attachment.analysisState, "queued_auto", "text attachment should queue automatically"); -assert.ok(textUpload.analysisTask, "queued auto attachment should create a master agent task"); -assert.equal(textUpload.analysisTask.taskType, "attachment_analysis", "queued task type should be attachment_analysis"); -assert.equal( - textUpload.analysisTask.attachmentFileName, - "analysis-note.txt", - "queued task should carry attachment file name", -); - -const manualUpload = await uploadAttachment( - cookie, - "master-agent", - "manual-binary.bin", - "application/octet-stream", - Buffer.from([0, 1, 2, 3]), -); -assert.equal(manualUpload.attachment.analysisState, "ready_manual", "binary attachment should be manually analyzable"); - -const analyzeResponse = await invokeRoute( - analyzeHandler, - `http://localhost/api/v1/projects/master-agent/attachments/${manualUpload.attachment.attachmentId}/analyze`, - { +async function analyzeAttachment(baseUrl, cookie, projectId, attachmentId) { + const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments/${attachmentId}/analyze`, { method: "POST", - headers: { cookie: `boss_session=${cookie}` }, - }, - { - params: Promise.resolve({ - projectId: "master-agent", - attachmentId: manualUpload.attachment.attachmentId, - }), - }, -); -assert.equal(analyzeResponse.status, 200, "manual analyze should succeed"); -const analyzePayload = await analyzeResponse.json(); -assert.ok(analyzePayload.taskId, "manual analyze should return a taskId"); -assert.ok(analyzePayload.task, "manual analyze should return a task payload"); -assert.equal(analyzePayload.task.taskType, "attachment_analysis", "manual analyze task should be attachment_analysis"); -assert.equal( - analyzePayload.task.attachmentId, - manualUpload.attachment.attachmentId, - "manual task should link the attachment", -); + headers: { + cookie: `boss_session=${cookie}`, + }, + }); + assert.equal(response.status, 200, "manual analyze should succeed"); + return response.json(); +} -console.log("attachment analysis validation passed"); +async function verifyHistoricalPrecheck() { + const worktreePath = await fs.mkdtemp(path.join(os.tmpdir(), "boss-attachment-precheck-")); + let server; + try { + await execFile("git", ["worktree", "add", "--detach", worktreePath, taskBaseCommit], { + cwd: rootDir, + maxBuffer: 32 * 1024 * 1024, + }); + await execFile("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], { + cwd: worktreePath, + env: process.env, + maxBuffer: 32 * 1024 * 1024, + }); + await execFile("npm", ["run", "build"], { + cwd: worktreePath, + env: process.env, + maxBuffer: 32 * 1024 * 1024, + }); + + const { runtimeDir } = await createSeededRuntime(worktreePath, "boss-attachment-precheck-"); + server = await startStandaloneServer(worktreePath, runtimeDir, 3115); + const { cookie } = await loginAsAdmin(server.baseUrl); + const response = await fetch(`${server.baseUrl}/api/v1/projects/master-agent/attachments/att-missing/analyze`, { + method: "POST", + headers: { + cookie: `boss_session=${cookie}`, + }, + }); + assert.notEqual(response.status, 200, "pre-implementation analyze route should not succeed"); + } finally { + await server?.stop(); + await execFile("git", ["worktree", "remove", "--force", worktreePath], { + cwd: rootDir, + maxBuffer: 32 * 1024 * 1024, + }).catch(() => undefined); + await fs.rm(worktreePath, { recursive: true, force: true }); + } +} + +await verifyHistoricalPrecheck(); + +const { runtimeDir } = await createSeededRuntime(rootDir, "boss-attachment-current-"); +const currentServer = await startStandaloneServer(rootDir, runtimeDir, 3116); + +try { + const { cookie } = await loginAsAdmin(currentServer.baseUrl); + + const textUpload = await uploadAttachment( + currentServer.baseUrl, + cookie, + "master-agent", + "analysis-note.txt", + "text/plain", + Buffer.from("text attachment for automatic analysis"), + ); + assert.equal(textUpload.attachment.analysisState, "queued_auto", "text attachment should queue automatically"); + assert.ok(textUpload.analysisTask, "queued auto attachment should create a master agent task"); + assert.equal( + textUpload.analysisTask.taskType, + "attachment_analysis", + "queued task type should be attachment_analysis", + ); + assert.equal( + textUpload.analysisTask.attachmentFileName, + "analysis-note.txt", + "queued task should carry attachment file name", + ); + + const manualUpload = await uploadAttachment( + currentServer.baseUrl, + cookie, + "master-agent", + "manual-binary.bin", + "application/octet-stream", + Buffer.from([0, 1, 2, 3]), + ); + assert.equal( + manualUpload.attachment.analysisState, + "ready_manual", + "binary attachment should be manually analyzable", + ); + + const analyzePayload = await analyzeAttachment( + currentServer.baseUrl, + cookie, + "master-agent", + manualUpload.attachment.attachmentId, + ); + assert.ok(analyzePayload.taskId, "manual analyze should return a taskId"); + assert.ok(analyzePayload.task, "manual analyze should return a task payload"); + assert.equal( + analyzePayload.task.taskType, + "attachment_analysis", + "manual analyze task should be attachment_analysis", + ); + assert.equal( + analyzePayload.task.attachmentId, + manualUpload.attachment.attachmentId, + "manual task should link the attachment", + ); + + console.log("attachment analysis validation passed"); +} finally { + await currentServer.stop(); + await fs.rm(runtimeDir, { recursive: true, force: true }); +} diff --git a/src/app/api/v1/attachments/[attachmentId]/download/route.ts b/src/app/api/v1/attachments/[attachmentId]/download/route.ts index 68ac275..b0291bd 100644 --- a/src/app/api/v1/attachments/[attachmentId]/download/route.ts +++ b/src/app/api/v1/attachments/[attachmentId]/download/route.ts @@ -4,8 +4,9 @@ import { Readable } from "node:stream"; import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access"; -import { getAttachmentById, readState } from "@/lib/boss-data"; +import { getAttachmentById, getAttachmentStorageConfig, readState } from "@/lib/boss-data"; import { buildAttachmentDownloadHeaders } from "@/lib/boss-attachments"; +import { getAliyunOssSignedDownloadUrl } from "@/lib/boss-storage-aliyun-oss"; import { resolveServerFileAttachmentAbsolutePath } from "@/lib/boss-storage-server-file"; export const runtime = "nodejs"; @@ -29,11 +30,23 @@ export async function GET( return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 }); } - if (record.attachment.storageBackend !== "server_file") { - return NextResponse.json( - { ok: false, message: "UNSUPPORTED_ATTACHMENT_STORAGE_BACKEND" }, - { status: 501 }, + if (record.attachment.storageBackend === "aliyun_oss") { + const storageConfig = await getAttachmentStorageConfig(record.attachment.uploadedBy); + if (storageConfig.mode !== "oss" || storageConfig.ossProvider !== "aliyun_oss" || !storageConfig.aliyunOss) { + return NextResponse.json({ ok: false, message: "ATTACHMENT_STORAGE_CONFIG_NOT_FOUND" }, { status: 404 }); + } + const signedUrl = await getAliyunOssSignedDownloadUrl( + storageConfig.aliyunOss, + record.attachment.storagePath, ); + return NextResponse.redirect(signedUrl, { + status: 307, + headers: buildAttachmentDownloadHeaders(record.attachment), + }); + } + + if (record.attachment.storageBackend !== "server_file") { + return NextResponse.json({ ok: false, message: "UNSUPPORTED_ATTACHMENT_STORAGE_BACKEND" }, { status: 501 }); } let absolutePath: string; diff --git a/src/app/me/storage/page.tsx b/src/app/me/storage/page.tsx index 2e35fcc..17467f9 100644 --- a/src/app/me/storage/page.tsx +++ b/src/app/me/storage/page.tsx @@ -2,13 +2,21 @@ import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; import { AttachmentStorageClient } from "@/components/attachment-storage-client"; import { requirePageSession } from "@/lib/boss-auth"; import { getAttachmentStorageConfig } from "@/lib/boss-data"; -import { sanitizeAttachmentStorageConfig } from "@/lib/boss-storage"; +import { + sanitizeAttachmentStorageConfig, + type SanitizedUserAttachmentStorageConfig, +} from "@/lib/boss-storage"; export const dynamic = "force-dynamic"; +async function getStorageConfigForSession(account: string): Promise { + const config = await getAttachmentStorageConfig(account); + return sanitizeAttachmentStorageConfig(config); +} + export default async function StoragePage() { const session = await requirePageSession(); - const config = sanitizeAttachmentStorageConfig(await getAttachmentStorageConfig(session.account)); + const config = await getStorageConfigForSession(session.account); return ( diff --git a/src/components/attachment-storage-client.tsx b/src/components/attachment-storage-client.tsx index 4de37d7..43726a2 100644 --- a/src/components/attachment-storage-client.tsx +++ b/src/components/attachment-storage-client.tsx @@ -64,6 +64,7 @@ export function AttachmentStorageClient({ config: SanitizedUserAttachmentStorageConfig; }) { const router = useRouter(); + const [currentConfig, setCurrentConfig] = useState(config); const [draft, setDraft] = useState(() => draftFromConfig(config)); const [busyKey, setBusyKey] = useState<"save" | "validate" | null>(null); const [message, setMessage] = useState(""); @@ -97,15 +98,24 @@ export function AttachmentStorageClient({ body: JSON.stringify(body), }, ); - const result = (await response.json()) as { ok: boolean; message?: string }; + const result = (await response.json()) as { + ok: boolean; + message?: string; + config?: SanitizedUserAttachmentStorageConfig; + }; setBusyKey(null); - setMessage(result.ok ? "附件存储配置已保存。" : result.message ?? "保存失败。"); if (result.ok) { + const nextConfig = result.config ?? currentConfig; + setCurrentConfig(nextConfig); + setDraft(draftFromConfig(nextConfig)); + setMessage("附件存储配置已保存。"); router.refresh(); + return; } + setMessage(result.message ?? "保存失败。"); } - const modeLabel = draft.mode === "server_file" ? "服务器文件存储" : "OSS"; + const modeLabel = currentConfig.mode === "server_file" ? "服务器文件存储" : "OSS"; const buttonLabel = draft.mode === "server_file" ? "切回服务器文件存储" @@ -120,10 +130,10 @@ export function AttachmentStorageClient({
当前模式:{modeLabel}
- 绑定账号:{config.account} + 绑定账号:{currentConfig.account}
- {draft.mode === "oss" - ? `OSS 提供方:阿里 OSS · 密钥${config.aliyunOss?.accessKeySecretConfigured ? "已保存" : "未保存"}` + {currentConfig.mode === "oss" + ? `OSS 提供方:阿里 OSS · 密钥${currentConfig.aliyunOss?.accessKeySecretConfigured ? "已保存" : "未保存"}` : "附件将继续写入服务器文件存储。"}
diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 77e08b1..49d8047 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -3625,8 +3625,8 @@ export async function completeMasterAgentTask(payload: { deviceId: result.deviceId, status: result.status, }); - publishBossEvent("project.messages.updated", { projectId: "master-agent" }); - publishBossEvent("conversation.updated", { projectId: "master-agent" }); + publishBossEvent("project.messages.updated", { projectId: result.projectId }); + publishBossEvent("conversation.updated", { projectId: result.projectId }); return result; } diff --git a/src/lib/boss-storage-aliyun-oss.ts b/src/lib/boss-storage-aliyun-oss.ts index 852c246..c14d381 100644 --- a/src/lib/boss-storage-aliyun-oss.ts +++ b/src/lib/boss-storage-aliyun-oss.ts @@ -74,6 +74,18 @@ export function createAliyunOssStorageProvider(config: AliyunOssConfig): Attachm }; } +export async function getAliyunOssSignedDownloadUrl( + config: AliyunOssConfig, + objectKey: string, + expiresSeconds = 300, +) { + const client = await createAliyunOssClient(config); + return client.signatureUrl(objectKey, { + expires: expiresSeconds, + method: "GET", + }); +} + export async function validateAliyunOssConfig(config: AliyunOssConfig) { const client = await createAliyunOssClient(config); await client.getBucketInfo(config.bucket);