Przeglądaj źródła

fix(exam-sprint): 修正报告下载地址前缀配置

金逸霄 2 tygodni temu
rodzic
commit
a2786e7886

+ 5 - 5
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java

@@ -75,7 +75,7 @@ public class ExamSprintReportProperties {
     public static class Storage {
         private String type = "memory";
         private String containerName = "exam-sprint-reports";
-        private String urlPrefix;
+        private String downloadUrlPrefix;
         private String connectionString;
         private String endpoint;
         private String accountName;
@@ -97,12 +97,12 @@ public class ExamSprintReportProperties {
             this.containerName = containerName;
         }
 
-        public String getUrlPrefix() {
-            return urlPrefix;
+        public String getDownloadUrlPrefix() {
+            return downloadUrlPrefix;
         }
 
-        public void setUrlPrefix(String urlPrefix) {
-            this.urlPrefix = urlPrefix;
+        public void setDownloadUrlPrefix(String downloadUrlPrefix) {
+            this.downloadUrlPrefix = downloadUrlPrefix;
         }
 
         public String getConnectionString() {

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

@@ -27,7 +27,7 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
 
     private final BlobContainerClient containerClient;
     private final String containerName;
-    private final String urlPrefix;
+    private final String downloadUrlPrefix;
     private final Clock clock;
 
     @Autowired
@@ -37,19 +37,19 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
             @Value("${ability.exam-sprint.report.storage.endpoint:}") String endpoint,
             @Value("${ability.exam-sprint.report.storage.account-name:}") String accountName,
             @Value("${ability.exam-sprint.report.storage.account-key:}") String accountKey,
-            @Value("${ability.exam-sprint.report.storage.url-prefix:}") String urlPrefix,
+            @Value("${ability.exam-sprint.report.storage.download-url-prefix:}") String downloadUrlPrefix,
             Clock clock) {
         this(buildContainerClient(containerName, connectionString, endpoint, accountName, accountKey),
                 containerName,
-                urlPrefix,
+                downloadUrlPrefix,
                 clock);
         this.containerClient.createIfNotExists();
     }
 
-    AzureBlobExamSprintReportStorage(BlobContainerClient containerClient, String containerName, String urlPrefix, Clock clock) {
+    AzureBlobExamSprintReportStorage(BlobContainerClient containerClient, String containerName, String downloadUrlPrefix, Clock clock) {
         this.containerClient = containerClient;
         this.containerName = normalizeSegment(containerName, "containerName");
-        this.urlPrefix = normalizeUrlPrefix(urlPrefix);
+        this.downloadUrlPrefix = normalizeDownloadUrlPrefix(downloadUrlPrefix);
         this.clock = clock;
     }
 
@@ -74,9 +74,9 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
 
     @Override
     public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
-        // This implementation returns a public OSS/CDN URL, so ttl is not used.
+        // This implementation returns a public Blob URL, so ttl is not used.
         String normalizedStorageObjectKey = normalizeSegment(storageObjectKey, "storageObjectKey");
-        return URI.create(urlPrefix + "/" + containerName + "/" + normalizedStorageObjectKey);
+        return URI.create(downloadUrlPrefix + "/" + containerName + "/" + normalizedStorageObjectKey);
     }
 
     @Override
@@ -104,16 +104,16 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
         return storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
     }
 
-    private static String normalizeUrlPrefix(String value) {
+    private static String normalizeDownloadUrlPrefix(String value) {
         if (!hasText(value)) {
-            throw new IllegalStateException("Azure storage url-prefix is incomplete");
+            throw new IllegalStateException("Azure storage download-url-prefix is incomplete");
         }
         String normalized = value.trim();
         while (normalized.endsWith("/")) {
             normalized = normalized.substring(0, normalized.length() - 1);
         }
         if (!hasText(normalized)) {
-            throw new IllegalStateException("Azure storage url-prefix is incomplete");
+            throw new IllegalStateException("Azure storage download-url-prefix is incomplete");
         }
         return normalized;
     }

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

@@ -47,12 +47,12 @@ class AzureBlobExamSprintReportStorageTest {
     }
 
     @Test
-    void generateDownloadUrlReturnsConfiguredOssFileLink() {
+    void generateDownloadUrlReturnsConfiguredBlobFileUrl() {
         BlobContainerClient containerClient = mock(BlobContainerClient.class);
         AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
                 containerClient,
                 "exam-assault-report",
-                "https://dcjxb-cdntest.yunzhixue.cn",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn",
                 FIXED_CLOCK);
 
         URI downloadUrl = storage.generateDownloadUrl(
@@ -60,7 +60,7 @@ class AzureBlobExamSprintReportStorageTest {
                 Duration.ofMinutes(15));
 
         assertThat(downloadUrl).isEqualTo(URI.create(
-                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-123.pdf"));
+                "https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-outlook-report-report-123.pdf"));
         verifyNoInteractions(containerClient);
     }
 
@@ -70,7 +70,7 @@ class AzureBlobExamSprintReportStorageTest {
         AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
                 containerClient,
                 "/exam-assault-report/",
-                "https://dcjxb-cdntest.yunzhixue.cn/",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn/",
                 FIXED_CLOCK);
 
         URI downloadUrl = storage.generateDownloadUrl(
@@ -78,7 +78,7 @@ class AzureBlobExamSprintReportStorageTest {
                 Duration.ofMinutes(15));
 
         assertThat(downloadUrl).isEqualTo(URI.create(
-                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/file.pdf"));
+                "https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/file.pdf"));
     }
 
     @Test
@@ -87,7 +87,7 @@ class AzureBlobExamSprintReportStorageTest {
         AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
                 containerClient,
                 "exam-assault-report",
-                "https://dcjxb-cdntest.yunzhixue.cn",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn",
                 FIXED_CLOCK);
 
         assertThatThrownBy(() -> storage.generateDownloadUrl("   ", Duration.ofMinutes(15)))
@@ -101,7 +101,7 @@ class AzureBlobExamSprintReportStorageTest {
         AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
                 containerClient,
                 "exam-assault-report",
-                "https://dcjxb-cdntest.yunzhixue.cn",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn",
                 FIXED_CLOCK);
 
         assertThatThrownBy(() -> storage.generateDownloadUrl("///", Duration.ofMinutes(15)))
@@ -110,7 +110,7 @@ class AzureBlobExamSprintReportStorageTest {
     }
 
     @Test
-    void constructorRejectsAllSlashUrlPrefix() {
+    void constructorRejectsAllSlashDownloadUrlPrefix() {
         BlobContainerClient containerClient = mock(BlobContainerClient.class);
 
         assertThatThrownBy(() -> new AzureBlobExamSprintReportStorage(
@@ -119,11 +119,11 @@ class AzureBlobExamSprintReportStorageTest {
                 "///",
                 FIXED_CLOCK))
                 .isInstanceOf(IllegalStateException.class)
-                .hasMessage("Azure storage url-prefix is incomplete");
+                .hasMessage("Azure storage download-url-prefix is incomplete");
     }
 
     @Test
-    void constructorRejectsBlankAfterTrimUrlPrefix() {
+    void constructorRejectsBlankAfterTrimDownloadUrlPrefix() {
         BlobContainerClient containerClient = mock(BlobContainerClient.class);
 
         assertThatThrownBy(() -> new AzureBlobExamSprintReportStorage(
@@ -132,7 +132,7 @@ class AzureBlobExamSprintReportStorageTest {
                 " / ",
                 FIXED_CLOCK))
                 .isInstanceOf(IllegalStateException.class)
-                .hasMessage("Azure storage url-prefix is incomplete");
+                .hasMessage("Azure storage download-url-prefix is incomplete");
     }
 
     @Test
@@ -143,7 +143,7 @@ class AzureBlobExamSprintReportStorageTest {
         AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
                 containerClient,
                 "exam-assault-report",
-                "https://dcjxb-cdntest.yunzhixue.cn",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn",
                 FIXED_CLOCK);
 
         storage.upload(

+ 1 - 1
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java

@@ -36,7 +36,7 @@ public class ExamSprintReportRuntimeConfiguration {
         properties.getStorage().setEndpoint(bound.getStorage().getEndpoint());
         properties.getStorage().setAccountName(bound.getStorage().getAccountName());
         properties.getStorage().setAccountKey(bound.getStorage().getAccountKey());
-        properties.getStorage().setUrlPrefix(bound.getStorage().getUrlPrefix());
+        properties.getStorage().setDownloadUrlPrefix(bound.getStorage().getDownloadUrlPrefix());
         return properties;
     }
 

+ 1 - 1
ability-center-runtime/src/main/resources/application-prod.yml

@@ -5,4 +5,4 @@ ability:
         type: azure
         connection-string: "${AZURE_BLOB_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=dcjxb;AccountKey=+Bg9srieVxwemxcE2b+icL3t3hp8m04PuYanTl6fwB/Cx1SF49qimBpYvXQjcvatgDDDgxjqYDP/0DCFTSQcgg==;EndpointSuffix=core.chinacloudapi.cn;}"
         container-name: "${AZURE_BLOB_CONTAINER:exam-assault-report}"
-        url-prefix: "${AZURE_BLOB_URL_PREFIX:https://dcjxb.blob.core.chinacloudapi.cn}"
+        download-url-prefix: "${AZURE_BLOB_DOWNLOAD_URL_PREFIX:https://dcjxb.blob.core.chinacloudapi.cn}"

+ 1 - 1
ability-center-runtime/src/main/resources/application-test.yml

@@ -8,4 +8,4 @@ ability:
         type: azure
         connection-string: "DefaultEndpointsProtocol=https;AccountName=dcjxbtest;AccountKey=CoOzFKq3/aecqY8JehnW+oV3XYe8dN8772NQbhT5VzYO5fdrx+Ps/LhmPqv9U/M28BtqSrgN13pjJqPvIRdI2w==;EndpointSuffix=core.chinacloudapi.cn"
         container-name: "${AZURE_BLOB_CONTAINER:exam-assault-report}"
-        url-prefix: "${AZURE_BLOB_URL_PREFIX:https://dcjxb-cdntest.yunzhixue.cn}"
+        download-url-prefix: "${AZURE_BLOB_DOWNLOAD_URL_PREFIX:https://dcjxbtest.blob.core.chinacloudapi.cn}"

+ 6 - 6
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java

@@ -116,7 +116,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-01T00:00:00Z"),
                 Instant.parse("2026-01-01T00:01:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z"),
-                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf"));
+                "https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf"));
 
         String requestJson = requestPayloadJson("requests/exam-sprint-outlook-report-request.json");
 
@@ -126,7 +126,7 @@ class ExamSprintReportControllerWebMvcTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.reportType").value("OUTLOOK"))
                 .andExpect(jsonPath("$.data.generationStatus").value("SUCCESS"))
-                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf"));
+                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf"));
 
         verify(applicationService).createOutlookReportSync(any());
     }
@@ -140,7 +140,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-01T00:00:00Z"),
                 Instant.parse("2026-01-01T00:01:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z"),
-                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf"));
+                "https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf"));
 
         String requestJson = requestPayloadJson("requests/exam-sprint-achievement-report-request.json");
 
@@ -150,7 +150,7 @@ class ExamSprintReportControllerWebMvcTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.reportType").value("ACHIEVEMENT"))
                 .andExpect(jsonPath("$.data.generationStatus").value("SUCCESS"))
-                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf"));
+                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf"));
 
         verify(applicationService).createAchievementReportSync(any());
     }
@@ -188,7 +188,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-01T00:00:00Z"),
                 Instant.parse("2026-01-01T00:05:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z"),
-                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-001.pdf",
+                "https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-outlook-report-report-001.pdf",
                 null));
         given(applicationService.downloadReport("report-001")).willReturn(new ReportDownloadContent(
                 "exam-sprint-outlook-report-report-001.pdf",
@@ -198,7 +198,7 @@ class ExamSprintReportControllerWebMvcTest {
         mockMvc.perform(get("/api/exam-sprint/reports/{reportId}", "report-001"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.reportId").value("report-001"))
-                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-001.pdf"));
+                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-outlook-report-report-001.pdf"));
 
         mockMvc.perform(get("/api/exam-sprint/reports/{reportId}/download", "report-001"))
                 .andExpect(status().isOk())

+ 4 - 3
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfigurationTest.java

@@ -8,14 +8,15 @@ import static org.assertj.core.api.Assertions.assertThat;
 class ExamSprintReportRuntimeConfigurationTest {
 
     @Test
-    void examSprintReportPropertiesCopiesStorageUrlPrefixFromBoundProperties() {
+    void examSprintReportPropertiesCopiesStorageDownloadUrlPrefixFromBoundProperties() {
         ExamSprintReportRuntimeConfiguration configuration = new ExamSprintReportRuntimeConfiguration();
         ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties bound =
                 new ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties();
-        bound.getStorage().setUrlPrefix("https://cdn.example.test/reports");
+        bound.getStorage().setDownloadUrlPrefix("https://dcjxbtest.blob.core.chinacloudapi.cn");
 
         ExamSprintReportProperties properties = configuration.examSprintReportProperties(bound);
 
-        assertThat(properties.getStorage().getUrlPrefix()).isEqualTo("https://cdn.example.test/reports");
+        assertThat(properties.getStorage().getDownloadUrlPrefix())
+                .isEqualTo("https://dcjxbtest.blob.core.chinacloudapi.cn");
     }
 }

+ 225 - 0
docs/superpowers/plans/2026-04-28-exam-sprint-download-url-prefix.md

@@ -0,0 +1,225 @@
+# Exam Sprint Download URL Prefix Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rename the Azure report download URL configuration from `url-prefix` to `download-url-prefix` and make test downloads return direct Azure Blob URLs.
+
+**Architecture:** Keep the existing storage boundary unchanged: `DefaultExamSprintReportApplicationService` asks `ExamSprintReportStorage.generateDownloadUrl(...)`, and `AzureBlobExamSprintReportStorage` builds a public URL from a configured download prefix, container name, and storage object key. This change only clarifies the configuration name and default test URL; upload/download/delete continue to use the Azure SDK client and connection string.
+
+**Tech Stack:** Java 17, Spring Boot `@Value` configuration binding, Maven, JUnit 5, AssertJ, Mockito, Spring MockMvc tests.
+
+---
+
+## File Structure
+
+- Modify `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java`
+  - Replace the `Storage.urlPrefix` property with `Storage.downloadUrlPrefix`.
+- Modify `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java`
+  - Inject `ability.exam-sprint.report.storage.download-url-prefix` instead of `url-prefix`.
+  - Rename internal field and validation method naming from `urlPrefix` to `downloadUrlPrefix`.
+- Modify `ability-center-runtime/src/main/resources/application-test.yml`
+  - Replace `url-prefix` with `download-url-prefix` and default to `https://dcjxbtest.blob.core.chinacloudapi.cn`.
+- Modify `ability-center-runtime/src/main/resources/application-prod.yml`
+  - Replace `url-prefix` with `download-url-prefix` and keep the existing production Blob account host default.
+- Modify `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java`
+  - Update test names, expected URLs, and validation messages.
+- Modify `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java`
+  - Update expected `downloadUrl` values from CDN host to Blob host where tests assert exact values.
+
+### Task 1: Rename the Azure storage configuration property
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java`
+- Modify: `ability-center-runtime/src/main/resources/application-test.yml`
+- Modify: `ability-center-runtime/src/main/resources/application-prod.yml`
+- Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java`
+
+- [ ] **Step 1: Write the failing infrastructure expectations**
+
+In `AzureBlobExamSprintReportStorageTest`, update the constructor reflection test so it still expects seven parameters but treats the sixth `String` as `download-url-prefix` by name in the surrounding test naming. Rename `generateDownloadUrlReturnsConfiguredOssFileLink` to `generateDownloadUrlReturnsConfiguredBlobFileLink` and change the expected URL to:
+
+```java
+assertThat(downloadUrl).isEqualTo(URI.create(
+        "https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-outlook-report-report-123.pdf"));
+```
+
+Rename `constructorRejectsAllSlashUrlPrefix` to `constructorRejectsAllSlashDownloadUrlPrefix` and `constructorRejectsBlankAfterTrimUrlPrefix` to `constructorRejectsBlankAfterTrimDownloadUrlPrefix`. Change both expected messages to:
+
+```java
+.hasMessage("Azure storage download-url-prefix is incomplete");
+```
+
+- [ ] **Step 2: Run the focused infrastructure test and verify it fails**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure -Dtest=AzureBlobExamSprintReportStorageTest test
+```
+
+Expected: FAIL because production code still emits the old CDN URL and old `url-prefix` error message.
+
+- [ ] **Step 3: Implement the Java property rename**
+
+In `ExamSprintReportProperties.Storage`, replace:
+
+```java
+private String urlPrefix;
+
+public String getUrlPrefix() {
+    return urlPrefix;
+}
+
+public void setUrlPrefix(String urlPrefix) {
+    this.urlPrefix = urlPrefix;
+}
+```
+
+with:
+
+```java
+private String downloadUrlPrefix;
+
+public String getDownloadUrlPrefix() {
+    return downloadUrlPrefix;
+}
+
+public void setDownloadUrlPrefix(String downloadUrlPrefix) {
+    this.downloadUrlPrefix = downloadUrlPrefix;
+}
+```
+
+In `AzureBlobExamSprintReportStorage`, replace the `urlPrefix` field and constructor parameter with `downloadUrlPrefix`, inject:
+
+```java
+@Value("${ability.exam-sprint.report.storage.download-url-prefix:}") String downloadUrlPrefix,
+```
+
+and change URL creation to:
+
+```java
+return URI.create(downloadUrlPrefix + "/" + containerName + "/" + normalizedStorageObjectKey);
+```
+
+Rename `normalizeUrlPrefix` to `normalizeDownloadUrlPrefix` and make both thrown messages say:
+
+```java
+throw new IllegalStateException("Azure storage download-url-prefix is incomplete");
+```
+
+- [ ] **Step 4: Update runtime YAML configuration**
+
+In `application-test.yml`, replace:
+
+```yaml
+url-prefix: "${AZURE_BLOB_URL_PREFIX:https://dcjxb-cdntest.yunzhixue.cn}"
+```
+
+with:
+
+```yaml
+download-url-prefix: "${AZURE_BLOB_DOWNLOAD_URL_PREFIX:https://dcjxbtest.blob.core.chinacloudapi.cn}"
+```
+
+In `application-prod.yml`, replace:
+
+```yaml
+url-prefix: "${AZURE_BLOB_URL_PREFIX:https://dcjxb.blob.core.chinacloudapi.cn}"
+```
+
+with:
+
+```yaml
+download-url-prefix: "${AZURE_BLOB_DOWNLOAD_URL_PREFIX:https://dcjxb.blob.core.chinacloudapi.cn}"
+```
+
+- [ ] **Step 5: Run the focused infrastructure test and verify it passes**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure -Dtest=AzureBlobExamSprintReportStorageTest test
+```
+
+Expected: PASS with `Tests run: 8, Failures: 0, Errors: 0, Skipped: 0`.
+
+### Task 2: Update HTTP-facing tests for the new download host
+
+**Files:**
+- Modify: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java`
+
+- [ ] **Step 1: Write the failing WebMvc expectations**
+
+Replace exact expected URLs using `https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/` with `https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/`. For example:
+
+```java
+"https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf"
+```
+
+and:
+
+```java
+"https://dcjxbtest.blob.core.chinacloudapi.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf"
+```
+
+- [ ] **Step 2: Run the focused runtime WebMvc test and verify it fails if code/config is incomplete**
+
+Run:
+
+```bash
+mvn -pl ability-center-runtime -Dtest=ExamSprintReportControllerWebMvcTest test
+```
+
+Expected before Task 1 implementation: FAIL because the old configuration emits CDN URLs. Expected after Task 1 implementation: PASS.
+
+- [ ] **Step 3: Re-run the focused runtime WebMvc test after Task 1 changes**
+
+Run:
+
+```bash
+mvn -pl ability-center-runtime -Dtest=ExamSprintReportControllerWebMvcTest test
+```
+
+Expected: PASS with all `ExamSprintReportControllerWebMvcTest` cases passing.
+
+### Task 3: Final verification
+
+**Files:**
+- Verify only; no additional files should change in this task.
+
+- [ ] **Step 1: Run all directly affected tests**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure -Dtest=AzureBlobExamSprintReportStorageTest test && mvn -pl ability-center-runtime -Dtest=ExamSprintReportControllerWebMvcTest test
+```
+
+Expected: both Maven commands finish with `BUILD SUCCESS`.
+
+- [ ] **Step 2: Inspect the diff for accidental compatibility code or secret changes**
+
+Run:
+
+```bash
+git diff -- abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java ability-center-runtime/src/main/resources/application-test.yml ability-center-runtime/src/main/resources/application-prod.yml abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java
+```
+
+Expected: diff contains only the `download-url-prefix` rename, Blob host default for test, unchanged production Blob host default, and test expectation updates.
+
+- [ ] **Step 3: Report deployment configuration note**
+
+Tell the operator that test deployments should use:
+
+```bash
+AZURE_BLOB_DOWNLOAD_URL_PREFIX=https://dcjxbtest.blob.core.chinacloudapi.cn
+```
+
+and that `AZURE_BLOB_URL_PREFIX` is intentionally no longer read.
+
+## Self-Review
+
+- Spec coverage: The plan covers the approved rename to `download-url-prefix`, direct test Blob URL default, no backward compatibility for `url-prefix`, production config rename, and affected tests.
+- Placeholder scan: No placeholder tasks remain; each step lists exact files, code snippets, commands, and expected outcomes.
+- Type consistency: The property name is consistently `downloadUrlPrefix` in Java and `download-url-prefix` in YAML/Spring configuration.