Преглед изворни кода

fix(exam-sprint): 优化报告 Azure 上传链路

金逸霄 пре 1 недеља
родитељ
комит
600bf88aa2

+ 3 - 2
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java

@@ -126,12 +126,13 @@ public class ExamSprintReportGenerationPipeline {
                     pdfBytes,
                     processingReport.expiresAt());
             log.info(
-                    "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage=storage_upload durationMs={} storageObjectKey={} fileName={}",
+                    "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage=storage_upload durationMs={} storageObjectKey={} fileName={} pdfByteLength={}",
                     processingReport.reportId(),
                     processingReport.reportType(),
                     elapsedMillis(uploadStartedNanos),
                     storedFile.storageObjectKey(),
-                    storedFile.fileName());
+                    storedFile.fileName(),
+                    pdfBytes.length);
 
             stage = "final_success_save";
             Optional<ExamSprintReport> currentReport = repository.findById(reportId);

+ 1 - 0
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

@@ -82,6 +82,7 @@ class ExamSprintReportGenerationWorkerTest {
                 .contains("stage=pdf_generation")
                 .contains("pdfByteLength=")
                 .contains("stage=storage_upload")
+                .containsPattern("stage=storage_upload.*pdfByteLength=\\d+")
                 .contains("exam_sprint_report_generation_succeeded")
                 .contains("storageObjectKey=report-log-success-临考词汇突击潜力展望报告-20260101080000.pdf")
                 .doesNotContain("SENSITIVE_HTML_DO_NOT_LOG")

+ 69 - 6
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java

@@ -2,11 +2,15 @@ package cn.yunzhixue.ability.center.examsprint.infrastructure.report.storage;
 
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import com.azure.core.util.Context;
 import com.azure.storage.blob.BlobClient;
 import com.azure.storage.blob.BlobContainerClient;
 import com.azure.storage.blob.BlobContainerClientBuilder;
 import com.azure.storage.blob.models.BlobHttpHeaders;
+import com.azure.storage.blob.options.BlobParallelUploadOptions;
 import com.azure.storage.common.StorageSharedKeyCredential;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -15,16 +19,20 @@ import org.springframework.stereotype.Component;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.net.URI;
+import java.net.URISyntaxException;
 import java.time.Clock;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 
 @Component
 @ConditionalOnProperty(prefix = "ability.exam-sprint.report.storage", name = "type", havingValue = "azure")
 public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage {
 
+    private static final Logger log = LoggerFactory.getLogger(AzureBlobExamSprintReportStorage.class);
+
     private final BlobContainerClient containerClient;
     private final String containerName;
     private final String downloadUrlPrefix;
@@ -60,15 +68,49 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
             String fileName,
             byte[] pdfBytes,
             Instant expiresAt) {
-        String blobName = fileName;
+        long uploadStartedNanos = System.nanoTime();
+        String blobName = blobName(reportId, reportType, fileName);
         BlobClient blobClient = containerClient.getBlobClient(blobName);
-        blobClient.upload(new ByteArrayInputStream(pdfBytes), pdfBytes.length, true);
-        blobClient.setHttpHeaders(new BlobHttpHeaders().setContentType("application/pdf"));
-        blobClient.setMetadata(Map.of(
+        log.info(
+                "exam_sprint_report_azure_storage_upload_stage_completed reportId={} reportType={} stage=client_resolved storageObjectKey={} blobName={} fileName={} pdfByteLength={} durationMs={}",
+                reportId,
+                reportType,
+                blobName,
+                blobName,
+                fileName,
+                pdfBytes.length,
+                elapsedMillis(uploadStartedNanos));
+
+        Map<String, String> metadata = Map.of(
                 "reportId", reportId,
                 "reportType", reportType.name(),
                 "expiresAt", expiresAt.toString(),
-                "uploadedAt", clock.instant().toString()));
+                "uploadedAt", clock.instant().toString());
+        BlobParallelUploadOptions options = new BlobParallelUploadOptions(
+                new ByteArrayInputStream(pdfBytes),
+                pdfBytes.length)
+                .setHeaders(new BlobHttpHeaders().setContentType("application/pdf"))
+                .setMetadata(metadata);
+        long uploadRequestStartedNanos = System.nanoTime();
+        blobClient.uploadWithResponse(options, Context.NONE);
+        log.info(
+                "exam_sprint_report_azure_storage_upload_stage_completed reportId={} reportType={} stage=upload_request_completed storageObjectKey={} blobName={} fileName={} pdfByteLength={} durationMs={}",
+                reportId,
+                reportType,
+                blobName,
+                blobName,
+                fileName,
+                pdfBytes.length,
+                elapsedMillis(uploadRequestStartedNanos));
+        log.info(
+                "exam_sprint_report_azure_storage_upload_completed reportId={} reportType={} storageObjectKey={} blobName={} fileName={} pdfByteLength={} durationMs={}",
+                reportId,
+                reportType,
+                blobName,
+                blobName,
+                fileName,
+                pdfBytes.length,
+                elapsedMillis(uploadStartedNanos));
         return new StoredExamSprintReportFile(blobName, fileName);
     }
 
@@ -76,7 +118,7 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
     public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
         // This implementation returns a public Blob URL, so ttl is not used.
         String normalizedStorageObjectKey = normalizeSegment(storageObjectKey, "storageObjectKey");
-        return URI.create(downloadUrlPrefix + "/" + containerName + "/" + normalizedStorageObjectKey);
+        return URI.create(downloadUrlPrefix + "/" + containerName + "/" + encodePathSegments(normalizedStorageObjectKey));
     }
 
     @Override
@@ -104,6 +146,15 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
         return storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
     }
 
+    private String blobName(String reportId, ReportType reportType, String fileName) {
+        return "reports/"
+                + reportType.name().toLowerCase()
+                + "/"
+                + normalizeSegment(reportId, "reportId")
+                + "/"
+                + normalizeSegment(fileName, "fileName");
+    }
+
     private static String normalizeDownloadUrlPrefix(String value) {
         if (!hasText(value)) {
             throw new IllegalStateException("Azure storage download-url-prefix is incomplete");
@@ -126,6 +177,14 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
         return normalized;
     }
 
+    private static String encodePathSegments(String value) {
+        try {
+            return new URI(null, null, value, null).getRawPath();
+        } catch (URISyntaxException e) {
+            throw new IllegalArgumentException("Azure storage storageObjectKey is not a valid path", e);
+        }
+    }
+
     private static String trimSlashes(String value) {
         if (value == null) {
             return "";
@@ -164,4 +223,8 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
     private static boolean hasText(String value) {
         return value != null && !value.isBlank();
     }
+
+    private long elapsedMillis(long startedNanos) {
+        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
+    }
 }

+ 110 - 7
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java

@@ -1,10 +1,16 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.storage;
 
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import com.azure.core.util.Context;
 import com.azure.storage.blob.BlobClient;
 import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.options.BlobParallelUploadOptions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
 import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
 
 import java.lang.reflect.Constructor;
 import java.net.URI;
@@ -15,12 +21,15 @@ import java.time.ZoneOffset;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
+@ExtendWith(OutputCaptureExtension.class)
 class AzureBlobExamSprintReportStorageTest {
 
     private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-03T08:00:00Z"), ZoneOffset.UTC);
@@ -81,6 +90,27 @@ class AzureBlobExamSprintReportStorageTest {
                 "https://dcjxbtest.blob.core.chinacloudapi.cn/report/file.pdf"));
     }
 
+    @Test
+    void generateDownloadUrlEncodesStorageObjectKeyPathSegmentsAndPreservesPathSeparators() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "report",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn",
+                FIXED_CLOCK);
+
+        URI downloadUrl = storage.generateDownloadUrl(
+                "reports/outlook/report-123/John Doe-报告 #1?.pdf",
+                Duration.ofMinutes(15));
+
+        assertThat(downloadUrl.toString()).isEqualTo(
+                "https://dcjxbtest.blob.core.chinacloudapi.cn/report/reports/outlook/report-123/John%20Doe-报告%20%231%3F.pdf");
+        assertThat(downloadUrl.getRawPath()).isEqualTo(
+                "/report/reports/outlook/report-123/John%20Doe-报告%20%231%3F.pdf");
+        assertThat(downloadUrl.getRawQuery()).isNull();
+        assertThat(downloadUrl.getRawFragment()).isNull();
+    }
+
     @Test
     void generateDownloadUrlRejectsBlankStorageObjectKey() {
         BlobContainerClient containerClient = mock(BlobContainerClient.class);
@@ -136,10 +166,42 @@ class AzureBlobExamSprintReportStorageTest {
     }
 
     @Test
-    void uploadWritesUploadedAtMetadataFromInjectedClock() {
+    void uploadUsesReportScopedBlobNameAndSendsHeadersAndMetadataInUploadRequest() {
         BlobContainerClient containerClient = mock(BlobContainerClient.class);
         BlobClient blobClient = mock(BlobClient.class);
-        when(containerClient.getBlobClient("file.pdf")).thenReturn(blobClient);
+        when(containerClient.getBlobClient(anyString())).thenReturn(blobClient);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "report",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn",
+                FIXED_CLOCK);
+
+        ExamSprintReportStorage.StoredExamSprintReportFile storedFile = storage.upload(
+                "report-123",
+                ReportType.OUTLOOK,
+                "file.pdf",
+                new byte[]{1, 2, 3},
+                Instant.parse("2026-01-10T00:00:00Z"));
+
+        assertThat(storedFile.storageObjectKey()).isEqualTo("reports/outlook/report-123/file.pdf");
+        assertThat(storedFile.fileName()).isEqualTo("file.pdf");
+        verify(containerClient).getBlobClient("reports/outlook/report-123/file.pdf");
+        verify(blobClient).uploadWithResponse(argThat(options ->
+                        options.getLength() == 3
+                                && options.getHeaders() != null
+                                && "application/pdf".equals(options.getHeaders().getContentType())
+                                && "report-123".equals(options.getMetadata().get("reportId"))
+                                && "OUTLOOK".equals(options.getMetadata().get("reportType"))
+                                && "2026-01-10T00:00:00Z".equals(options.getMetadata().get("expiresAt"))
+                                && FIXED_CLOCK.instant().toString().equals(options.getMetadata().get("uploadedAt"))),
+                eq(Context.NONE));
+    }
+
+    @Test
+    void uploadLogsClientResolutionUploadRequestCompletionAndTotalDuration(CapturedOutput output) {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        BlobClient blobClient = mock(BlobClient.class);
+        when(containerClient.getBlobClient(anyString())).thenReturn(blobClient);
         AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
                 containerClient,
                 "report",
@@ -153,10 +215,51 @@ class AzureBlobExamSprintReportStorageTest {
                 new byte[]{1, 2, 3},
                 Instant.parse("2026-01-10T00:00:00Z"));
 
-        verify(blobClient).setMetadata(argThat(metadata ->
-                "report-123".equals(metadata.get("reportId"))
-                        && "OUTLOOK".equals(metadata.get("reportType"))
-                        && "2026-01-10T00:00:00Z".equals(metadata.get("expiresAt"))
-                        && FIXED_CLOCK.instant().toString().equals(metadata.get("uploadedAt"))));
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_azure_storage_upload_stage_completed")
+                .contains("stage=client_resolved")
+                .contains("stage=upload_request_completed")
+                .contains("exam_sprint_report_azure_storage_upload_completed")
+                .contains("reportId=report-123")
+                .contains("reportType=OUTLOOK")
+                .contains("storageObjectKey=reports/outlook/report-123/file.pdf")
+                .contains("blobName=reports/outlook/report-123/file.pdf")
+                .contains("fileName=file.pdf")
+                .contains("pdfByteLength=3")
+                .contains("durationMs=")
+                .doesNotContain("connection-string")
+                .doesNotContain("accountKey")
+                .doesNotContain("account-key");
+    }
+
+    @Test
+    void uploadDoesNotUseDisplayFileNameAsTheUniqueStorageObjectKey() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        BlobClient blobClient = mock(BlobClient.class);
+        when(containerClient.getBlobClient(anyString())).thenReturn(blobClient);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "report",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn",
+                FIXED_CLOCK);
+
+        ExamSprintReportStorage.StoredExamSprintReportFile firstStoredFile = storage.upload(
+                "report-123",
+                ReportType.OUTLOOK,
+                "same-display-name.pdf",
+                new byte[]{1},
+                Instant.parse("2026-01-10T00:00:00Z"));
+        ExamSprintReportStorage.StoredExamSprintReportFile secondStoredFile = storage.upload(
+                "report-456",
+                ReportType.OUTLOOK,
+                "same-display-name.pdf",
+                new byte[]{2},
+                Instant.parse("2026-01-10T00:00:00Z"));
+
+        assertThat(firstStoredFile.storageObjectKey()).isEqualTo("reports/outlook/report-123/same-display-name.pdf");
+        assertThat(secondStoredFile.storageObjectKey()).isEqualTo("reports/outlook/report-456/same-display-name.pdf");
+        assertThat(firstStoredFile.storageObjectKey()).isNotEqualTo(secondStoredFile.storageObjectKey());
+        assertThat(firstStoredFile.fileName()).isEqualTo("same-display-name.pdf");
+        assertThat(secondStoredFile.fileName()).isEqualTo("same-display-name.pdf");
     }
 }