fix: stabilize attachment upload and storage flows

This commit is contained in:
kris
2026-03-29 16:59:10 +08:00
parent 1e476a2097
commit 18dc7c6120
13 changed files with 733 additions and 183 deletions

View File

@@ -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() {

View File

@@ -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<String, List<String>> headers) {
if (headers == null) return;
List<String> 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);
}
}
}

View File

@@ -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;
}

View File

@@ -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 "图片";

View File

@@ -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(

View File

@@ -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());
}
}

View File

@@ -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() {

View File

@@ -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 });
}

View File

@@ -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;

View File

@@ -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<SanitizedUserAttachmentStorageConfig> {
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 (
<AppShell bottomNav={false}>

View File

@@ -64,6 +64,7 @@ export function AttachmentStorageClient({
config: SanitizedUserAttachmentStorageConfig;
}) {
const router = useRouter();
const [currentConfig, setCurrentConfig] = useState<SanitizedUserAttachmentStorageConfig>(config);
const [draft, setDraft] = useState<StorageDraft>(() => 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({
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
<span className="font-semibold text-[#111111]">{modeLabel}</span>
<br />
{config.account}
{currentConfig.account}
<br />
{draft.mode === "oss"
? `OSS 提供方:阿里 OSS · 密钥${config.aliyunOss?.accessKeySecretConfigured ? "已保存" : "未保存"}`
{currentConfig.mode === "oss"
? `OSS 提供方:阿里 OSS · 密钥${currentConfig.aliyunOss?.accessKeySecretConfigured ? "已保存" : "未保存"}`
: "附件将继续写入服务器文件存储。"}
</div>
<div className="mt-3 flex gap-2">

View File

@@ -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;
}

View File

@@ -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);