Jelajahi Sumber

fix(exam-sprint): 优化报告报文日志与学习效率计算

金逸霄 1 Minggu lalu
induk
melakukan
0c4c5b8db1

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

@@ -17,6 +17,7 @@ final class AchievementReportContentMapper {
     static AchievementReportContent toDomainContent(AchievementExamSprintReportPayload payload) {
         Objects.requireNonNull(payload, "payload");
         String masteryHitRateText = masteryHitRateText(payload.testPaperImprovedWordCount(), payload.studentImproveWordCount());
+        String learningEfficiencyText = learningEfficiencyText(payload.testPaperImprovedWordCount(), payload.studentImproveWordCount());
         return new AchievementReportContent(
                 payload.studentName(),
                 reportTitle(payload.stageName()),
@@ -27,7 +28,7 @@ final class AchievementReportContentMapper {
                         signed(payload.studentImproveWordCount()),
                         signed(payload.testPaperImprovedWordCount()),
                         masteryHitRateText,
-                        format(payload.improveStudyEfficiency())),
+                        learningEfficiencyText),
                 new AchievementReportContent.Comparison(
                         payload.studentVocabularyBefore().doubleValue(),
                         payload.studentVocabulary().doubleValue(),
@@ -56,7 +57,7 @@ final class AchievementReportContentMapper {
                         signed(payload.testPaperImproveRate())),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                         masteryHitRateText,
-                        format(payload.improveStudyEfficiency()),
+                        learningEfficiencyText,
                         format(payload.testPaperBeforUnMastery()),
                         format(payload.testPaperAfterUnMastery()),
                         format(payload.testPaperImprovedWordCount()),
@@ -90,6 +91,15 @@ final class AchievementReportContentMapper {
         return derivedRate.toPlainString() + "%";
     }
 
+    private static String learningEfficiencyText(BigDecimal improvedWordCount, BigDecimal studentImproveWordCount) {
+        if (studentImproveWordCount.signum() == 0) {
+            return "0";
+        }
+        BigDecimal derivedEfficiency = improvedWordCount
+                .divide(studentImproveWordCount.multiply(new BigDecimal("0.04")), 6, RoundingMode.HALF_UP);
+        return format(derivedEfficiency);
+    }
+
     private static List<String> hitWords(List<JsonNode> improvedWords) {
         return improvedWords.stream()
                 .map(AchievementReportContentMapper::hitWord)

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

@@ -18,6 +18,7 @@ import cn.yunzhixue.ability.center.kernel.ErrorCode;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import jakarta.validation.ConstraintViolation;
 import jakarta.validation.Validator;
@@ -35,6 +36,7 @@ import java.math.BigDecimal;
 import java.time.Clock;
 import java.time.Instant;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
@@ -83,6 +85,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
 
     @Override
     public CreateExamSprintReportResponse createOutlookReport(JsonNode payload) {
+        logReceivedPayload(ReportType.OUTLOOK, "async", payload);
         validateOutlookPayload(payload);
         JsonNode normalizedPayload = normalizeOutlookPayload(payload);
         return submitReportGeneration(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, normalizedPayload));
@@ -90,12 +93,14 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
 
     @Override
     public CreateExamSprintReportResponse createAchievementReport(JsonNode payload) {
+        logReceivedPayload(ReportType.ACHIEVEMENT, "async", payload);
         AchievementReportContent content = validateAchievementPayload(payload);
         return submitReportGeneration(ReportType.ACHIEVEMENT, content);
     }
 
     @Override
     public CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload) {
+        logReceivedPayload(ReportType.OUTLOOK, "sync", payload);
         validateOutlookPayload(payload);
         JsonNode normalizedPayload = normalizeOutlookPayload(payload);
         return submitReportGenerationSync(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, normalizedPayload));
@@ -103,10 +108,82 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
 
     @Override
     public CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload) {
+        logReceivedPayload(ReportType.ACHIEVEMENT, "sync", payload);
         AchievementReportContent content = validateAchievementPayload(payload);
         return submitReportGenerationSync(ReportType.ACHIEVEMENT, content);
     }
 
+    private void logReceivedPayload(ReportType reportType, String mode, JsonNode payload) {
+        log.info(
+                "exam_sprint_report_payload_received reportType={} mode={} payload={}",
+                reportType,
+                mode,
+                summarizePayloadForLog(payload));
+    }
+
+    private JsonNode summarizePayloadForLog(JsonNode payload) {
+        if (payload == null || payload.isNull() || payload.isMissingNode()) {
+            return payload;
+        }
+        if (payload.isObject()) {
+            ObjectNode summary = objectMapper.createObjectNode();
+            Set<String> arraySummaryFieldNames = arraySummaryFieldNames(payload);
+            Set<String> reservedFieldNames = reservedSummaryFieldNames(payload, arraySummaryFieldNames);
+            payload.fields().forEachRemaining(entry -> appendSummarizedField(summary, arraySummaryFieldNames, reservedFieldNames, entry.getKey(), entry.getValue()));
+            return summary;
+        }
+        if (payload.isArray()) {
+            return JsonNodeFactory.instance.numberNode(payload.size());
+        }
+        return payload.deepCopy();
+    }
+
+    private Set<String> arraySummaryFieldNames(JsonNode payload) {
+        Set<String> arraySummaryFieldNames = new HashSet<>();
+        payload.fields().forEachRemaining(entry -> {
+            JsonNode value = entry.getValue();
+            if (value != null && value.isArray()) {
+                arraySummaryFieldNames.add(entry.getKey() + "Size");
+            }
+        });
+        return arraySummaryFieldNames;
+    }
+
+    private Set<String> reservedSummaryFieldNames(JsonNode payload, Set<String> arraySummaryFieldNames) {
+        Set<String> reservedFieldNames = new HashSet<>();
+        reservedFieldNames.addAll(arraySummaryFieldNames);
+        payload.fields().forEachRemaining(entry -> {
+            reservedFieldNames.add(entry.getKey());
+        });
+        return reservedFieldNames;
+    }
+
+    private void appendSummarizedField(
+            ObjectNode summary,
+            Set<String> arraySummaryFieldNames,
+            Set<String> reservedFieldNames,
+            String fieldName,
+            JsonNode value) {
+        if (value != null && value.isArray()) {
+            summary.put(fieldName + "Size", value.size());
+            return;
+        }
+        String summarizedFieldName = arraySummaryFieldNames.contains(fieldName)
+                ? nonConflictingSummaryFieldName(reservedFieldNames, summary, fieldName + "Value")
+                : fieldName;
+        summary.set(summarizedFieldName, summarizePayloadForLog(value));
+    }
+
+    private String nonConflictingSummaryFieldName(Set<String> reservedFieldNames, ObjectNode summary, String preferredFieldName) {
+        String candidate = preferredFieldName;
+        int suffix = 2;
+        while (reservedFieldNames.contains(candidate) || summary.has(candidate)) {
+            candidate = preferredFieldName + suffix;
+            suffix++;
+        }
+        return candidate;
+    }
+
     private CreateExamSprintReportResponse submitReportGeneration(ReportType reportType, ReportContent content) {
         Instant now = clock.instant();
         ExamSprintReport report = ExamSprintReport.pending(
@@ -501,14 +578,12 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
 
     private JsonNode outlookPayloadForValidation(JsonNode payload) {
         requireObjectPayload(payload, OUTLOOK_REPORT_NAME);
-        if (!payload.has("StudentName") || !payload.has("studentName")) {
-            return payload;
-        }
-
         ObjectNode validationPayload = ((ObjectNode) payload).deepCopy();
         resolveOutlookStudentName(validationPayload)
                 .ifPresent(studentName -> validationPayload.put("StudentName", studentName));
-        validationPayload.remove("studentName");
+        if (validationPayload.has("StudentName") && validationPayload.has("studentName")) {
+            validationPayload.remove("studentName");
+        }
         return validationPayload;
     }
 

+ 9 - 2
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java

@@ -13,6 +13,7 @@ class AchievementReportContentMapperTest {
 
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
+    /** 覆盖调用方 PascalCase 成果报告完整映射场景,当命中真题词数为 4 且学生提分词数为 19 时,应按直接公式展示学习效率。 */
     @Test
     void mapsCallerPascalCasePayloadToExistingAchievementContent() {
         AchievementReportContent content = AchievementReportContentMapper.toDomainContent(payload());
@@ -25,7 +26,7 @@ class AchievementReportContentMapperTest {
         assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
         assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
         assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("21.1%");
-        assertThat(content.summaryMetrics().learningEfficiencyText()).isEqualTo("0.48");
+        assertThat(content.summaryMetrics().learningEfficiencyText()).isEqualTo("5.263158");
         assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0);
         assertThat(content.vocabularyComparison().afterText()).isEqualTo("2347");
         assertThat(content.vocabularyComparison().growthText()).isEqualTo("+19");
@@ -45,7 +46,7 @@ class AchievementReportContentMapperTest {
         assertThat(content.testPaperVocabularySummary().masteryRateAfterText()).isEqualTo("75.96");
         assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
         assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("21.1%");
-        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("5.263158");
         assertThat(content.examUnknownWordsHitStatus().unknownWordsBeforeText()).isEqualTo("207");
         assertThat(content.examUnknownWordsHitStatus().unknownWordsAfterText()).isEqualTo("203");
         assertThat(content.examUnknownWordsHitStatus().reducedUnknownWordsText()).isEqualTo("4");
@@ -53,6 +54,7 @@ class AchievementReportContentMapperTest {
                 .containsExactly("number", "bear", "popular", "importance");
     }
 
+    /** 覆盖学生提分词数为零的边界场景,当分母为 0 时,应展示 0% 命中率。 */
     @Test
     void mapsZeroMasteryHitRateWhenStudentImproveWordCountIsZero() {
         ObjectNode payload = pascalPayload();
@@ -65,6 +67,7 @@ class AchievementReportContentMapperTest {
         assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("0%");
     }
 
+    /** 覆盖命中率整百分比展示场景,当计算结果为整数百分比时,应保留一位小数。 */
     @Test
     void mapsWholeNumberMasteryHitRateWithOneDecimalPlace() {
         ObjectNode payload = pascalPayload();
@@ -77,6 +80,7 @@ class AchievementReportContentMapperTest {
         assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("20.0%");
     }
 
+    /** 覆盖负向增量展示场景,当学生提分词数为负且阶段名已含英语时,应保留负号且不重复英语。 */
     @Test
     void mapsNegativeSignedGrowthAndDoesNotDuplicateEnglishInReportTitle() {
         ObjectNode payload = pascalPayload();
@@ -94,6 +98,7 @@ class AchievementReportContentMapperTest {
         assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("-0.04");
     }
 
+    /** 覆盖命中词清洗场景,当列表包含空白字符串或空白 WordSpell 时,应只保留非空命中词。 */
     @Test
     void filtersBlankImprovedWordsFromStringAndObjectElements() {
         ObjectNode payload = pascalPayload();
@@ -108,6 +113,7 @@ class AchievementReportContentMapperTest {
         assertThat(content.examUnknownWordsHitStatus().hitWords()).containsExactly("number", "bear");
     }
 
+    /** 覆盖空 payload 防御场景,当 mapper 收到 null 时,应抛出包含 payload 的 NullPointerException。 */
     @Test
     void rejectsNullPayload() {
         assertThatThrownBy(() -> AchievementReportContentMapper.toDomainContent(null))
@@ -115,6 +121,7 @@ class AchievementReportContentMapperTest {
                 .hasMessageContaining("payload");
     }
 
+    /** 覆盖阶段名必填防御场景,当 StageName 为 null 时,应抛出包含 stageName 的 NullPointerException。 */
     @Test
     void rejectsNullStageNameInsteadOfSilentlyFallingBack() {
         AchievementExamSprintReportPayload payload = payload();

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

@@ -108,6 +108,52 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(dispatchedReportIds).containsExactly(response.reportId());
     }
 
+    /** 覆盖展望报告原始报文日志场景,当 payload 包含数组时,应只记录数组大小而不记录数组内容。 */
+    @Test
+    void createOutlookReportLogsPayloadWithArraySizesOnly(CapturedOutput output) throws Exception {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+
+        service.createOutlookReport(callerVocabularyPayload());
+
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_payload_received")
+                .contains("reportType=OUTLOOK")
+                .contains("mode=async")
+                .contains("StudentName")
+                .contains("20260318测试")
+                .contains("TestPaperWordIdArraySize")
+                .contains("StudentWordsLatestSize")
+                .contains("TestPaperUnMasterWordsSize")
+                .contains("TestPaperMastedWordsSize")
+                .doesNotContain("TestPaperWordIdArray\":")
+                .doesNotContain("WordSpell\":\"w1")
+                .doesNotContain("lot")
+                .doesNotContain("father")
+                .doesNotContain("catch");
+    }
+
+    /** 覆盖展望报告数组摘要字段冲突场景,当 payload 同层包含同名非数组字段被校验拒绝时,预校验日志仍应记录真实数组长度。 */
+    @Test
+    void createOutlookReportLogsRealArraySizeWhenPayloadContainsConflictingSizeField(CapturedOutput output) throws Exception {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        payload.put("StudentWordsLatestSize", "forged-size");
+
+        assertThatThrownBy(() -> service.createOutlookReport(payload))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_payload_received")
+                .contains("reportType=OUTLOOK")
+                .contains("\"StudentWordsLatestSize\":10")
+                .doesNotContain("\"StudentWordsLatestSize\":\"forged-size\"");
+        assertThat(repository.storage).isEmpty();
+    }
+
     /** 覆盖 mixed-field 展望报文场景,当 StudentName 为空白或 null 且 studentName 有效时,应允许通过并归一化到 lowercase 值。 */
     @ParameterizedTest(name = "{0}")
     @MethodSource("mixedOutlookPayloadsWithInvalidCanonicalStudentName")
@@ -301,6 +347,7 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
         assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
         assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("21.1%");
+        assertThat(content.summaryMetrics().learningEfficiencyText()).isEqualTo("5.263158");
         assertThat(content.stageVocabularySummary().stageName()).isEqualTo("高考");
         assertThat(content.stageVocabularySummary().stageVocabularyText()).isEqualTo("3500");
         assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("+0.55");
@@ -308,11 +355,34 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(content.testPaperVocabularySummary().testPaperWordCountText()).isEqualTo("861");
         assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
         assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("21.1%");
-        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("5.263158");
         assertThat(content.examUnknownWordsHitStatus().hitWords())
                 .containsExactly("number", "bear", "popular", "importance");
     }
 
+    /** 覆盖成果报告原始报文日志场景,当 payload 包含数组时,应只记录数组大小而不记录数组内容。 */
+    @Test
+    void createAchievementReportLogsPayloadWithArraySizesOnly(CapturedOutput output) {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+
+        service.createAchievementReport(validAchievementPayload());
+
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_payload_received")
+                .contains("reportType=ACHIEVEMENT")
+                .contains("mode=async")
+                .contains("StudentName")
+                .contains("吴泓妤")
+                .contains("TestPaperImprovedWordsSize")
+                .contains("StudentWordsLatestSize")
+                .contains("StudentWordsFirstPreExamAssaultAfterSize")
+                .doesNotContain("TestPaperImprovedWords\":")
+                .doesNotContain("number")
+                .doesNotContain("ignored-large-array");
+    }
+
+    /** 覆盖成果报告学生提分词数为零的边界场景,当分母为 0 时,应保存 0% 命中率与 0 学习效率。 */
     @Test
     void createAchievementReportStoresZeroPercentWhenStudentImproveWordCountIsZero() {
         TestRepository repository = new TestRepository();
@@ -325,7 +395,25 @@ class ExamSprintReportApplicationServiceTest {
 
         AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
         assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("0%");
+        assertThat(content.summaryMetrics().learningEfficiencyText()).isEqualTo("0");
         assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("0%");
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0");
+    }
+
+    /** 覆盖成果报告学习效率舍入边界场景,当直接公式与中间舍入结果不同,应保存最终直接公式的 6 位小数。 */
+    @Test
+    void createAchievementReportCalculatesLearningEfficiencyWithoutIntermediateRounding() {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+        ObjectNode payload = validAchievementPayload().deepCopy();
+        payload.put("TestPaperImprovedWordCount", 6);
+        payload.put("StudentImproveWordCount", 7);
+
+        var response = service.createAchievementReport(payload);
+
+        AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
+        assertThat(content.summaryMetrics().learningEfficiencyText()).isEqualTo("21.428571");
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("21.428571");
     }
 
     /** 覆盖成果报告调用方 PascalCase 类型边界场景,当核心数字字段为 string 时,应在保存前校验失败。 */

+ 331 - 0
docs/superpowers/plans/2026-05-06-exam-sprint-report-payload-summary-and-efficiency.md

@@ -0,0 +1,331 @@
+# Exam Sprint Report Payload Summary And Efficiency 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:** Log incoming `OUTLOOK` and `ACHIEVEMENT` report payloads while replacing every array field with `<fieldName>Size`, and derive achievement learning efficiency from mastery hit rate divided by `0.04`.
+
+**Architecture:** Keep changes inside the existing report application boundary. `DefaultExamSprintReportApplicationService` owns payload logging because it receives raw `JsonNode` request payloads; `AchievementReportContentMapper` owns presentation-field derivation for achievement reports.
+
+**Tech Stack:** Java 17, Spring Boot, Jackson `JsonNode`/`ObjectNode`, SLF4J, JUnit 5, AssertJ, Spring `OutputCaptureExtension`, Maven.
+
+---
+
+## File Structure
+
+- Modify `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
+  - Add payload-received logs in the four report creation entry points.
+  - Add recursive Jackson helper methods that preserve scalar/object fields and replace arrays with `<fieldName>Size` numeric fields.
+- Modify `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java`
+  - Compute learning efficiency as `(testPaperImprovedWordCount / studentImproveWordCount) / 0.04`.
+  - Return `0` when `studentImproveWordCount` is zero.
+- Modify `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+  - Add log-capture tests for `OUTLOOK` and `ACHIEVEMENT` payload summaries.
+  - Update achievement learning-efficiency assertions.
+
+## Task 1: Payload Summary Logging Tests
+
+**Files:**
+- Test: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+
+- [ ] **Step 1: Add an OUTLOOK payload logging test**
+
+Insert this test near existing OUTLOOK creation tests after `createOutlookReportAcceptsCallerVocabularyPayloadAndDispatches`:
+
+```java
+    /** 覆盖展望报告原始报文日志场景,当 payload 包含数组时,应只记录数组大小而不记录数组内容。 */
+    @Test
+    void createOutlookReportLogsPayloadWithArraySizesOnly(CapturedOutput output) throws Exception {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+
+        service.createOutlookReport(callerVocabularyPayload());
+
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_payload_received")
+                .contains("reportType=OUTLOOK")
+                .contains("mode=async")
+                .contains("StudentName")
+                .contains("20260318测试")
+                .contains("TestPaperWordIdArraySize")
+                .contains("StudentWordsLatestSize")
+                .contains("TestPaperUnMasterWordsSize")
+                .contains("TestPaperMastedWordsSize")
+                .doesNotContain("TestPaperWordIdArray\":")
+                .doesNotContain("WordSpell\":\"w1")
+                .doesNotContain("lot")
+                .doesNotContain("father")
+                .doesNotContain("catch");
+    }
+```
+
+- [ ] **Step 2: Add an ACHIEVEMENT payload logging test**
+
+Insert this test near existing ACHIEVEMENT creation tests after `createAchievementReportAcceptsCallerPascalCasePayloadAndStoresAchievementContent`:
+
+```java
+    /** 覆盖成果报告原始报文日志场景,当 payload 包含数组时,应只记录数组大小而不记录数组内容。 */
+    @Test
+    void createAchievementReportLogsPayloadWithArraySizesOnly(CapturedOutput output) {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+
+        service.createAchievementReport(validAchievementPayload());
+
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_payload_received")
+                .contains("reportType=ACHIEVEMENT")
+                .contains("mode=async")
+                .contains("StudentName")
+                .contains("吴泓妤")
+                .contains("TestPaperImprovedWordsSize")
+                .contains("StudentWordsLatestSize")
+                .contains("StudentWordsFirstPreExamAssaultAfterSize")
+                .doesNotContain("TestPaperImprovedWords\":")
+                .doesNotContain("number")
+                .doesNotContain("ignored-large-array");
+    }
+```
+
+- [ ] **Step 3: Run tests and verify the new tests fail before implementation**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportApplicationServiceTest#createOutlookReportLogsPayloadWithArraySizesOnly,ExamSprintReportApplicationServiceTest#createAchievementReportLogsPayloadWithArraySizesOnly test
+```
+
+Expected: FAIL because `exam_sprint_report_payload_received`, `TestPaperWordIdArraySize`, and `TestPaperImprovedWordsSize` are not logged yet.
+
+## Task 2: Payload Summary Logging Implementation
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
+- Test: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+
+- [ ] **Step 1: Add Jackson node imports**
+
+Update imports in `DefaultExamSprintReportApplicationService.java`:
+
+```java
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+```
+
+Keep the existing `ObjectNode` import and add `ArrayNode` plus `JsonNodeFactory` beside it.
+
+- [ ] **Step 2: Log payload summaries in all report creation entry points**
+
+Replace the four public creation methods with:
+
+```java
+    @Override
+    public CreateExamSprintReportResponse createOutlookReport(JsonNode payload) {
+        logReceivedPayload(ReportType.OUTLOOK, "async", payload);
+        validateOutlookPayload(payload);
+        JsonNode normalizedPayload = normalizeOutlookPayload(payload);
+        return submitReportGeneration(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, normalizedPayload));
+    }
+
+    @Override
+    public CreateExamSprintReportResponse createAchievementReport(JsonNode payload) {
+        logReceivedPayload(ReportType.ACHIEVEMENT, "async", payload);
+        AchievementReportContent content = validateAchievementPayload(payload);
+        return submitReportGeneration(ReportType.ACHIEVEMENT, content);
+    }
+
+    @Override
+    public CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload) {
+        logReceivedPayload(ReportType.OUTLOOK, "sync", payload);
+        validateOutlookPayload(payload);
+        JsonNode normalizedPayload = normalizeOutlookPayload(payload);
+        return submitReportGenerationSync(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, normalizedPayload));
+    }
+
+    @Override
+    public CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload) {
+        logReceivedPayload(ReportType.ACHIEVEMENT, "sync", payload);
+        AchievementReportContent content = validateAchievementPayload(payload);
+        return submitReportGenerationSync(ReportType.ACHIEVEMENT, content);
+    }
+```
+
+- [ ] **Step 3: Add recursive payload summary helpers**
+
+Add these private methods before `submitReportGeneration`:
+
+```java
+    private void logReceivedPayload(ReportType reportType, String mode, JsonNode payload) {
+        log.info(
+                "exam_sprint_report_payload_received reportType={} mode={} payload={}",
+                reportType,
+                mode,
+                summarizePayloadForLog(payload));
+    }
+
+    private JsonNode summarizePayloadForLog(JsonNode payload) {
+        if (payload == null || payload.isNull() || payload.isMissingNode()) {
+            return payload;
+        }
+        if (payload.isObject()) {
+            ObjectNode summary = objectMapper.createObjectNode();
+            payload.fields().forEachRemaining(entry -> appendSummarizedField(summary, entry.getKey(), entry.getValue()));
+            return summary;
+        }
+        if (payload.isArray()) {
+            return JsonNodeFactory.instance.numberNode(payload.size());
+        }
+        return payload.deepCopy();
+    }
+
+    private void appendSummarizedField(ObjectNode summary, String fieldName, JsonNode value) {
+        if (value != null && value.isArray()) {
+            summary.put(fieldName + "Size", value.size());
+            return;
+        }
+        summary.set(fieldName, summarizePayloadForLog(value));
+    }
+```
+
+- [ ] **Step 4: Run payload logging tests and verify pass**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportApplicationServiceTest#createOutlookReportLogsPayloadWithArraySizesOnly,ExamSprintReportApplicationServiceTest#createAchievementReportLogsPayloadWithArraySizesOnly test
+```
+
+Expected: PASS.
+
+## Task 3: Achievement Learning Efficiency Tests
+
+**Files:**
+- Test: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+
+- [ ] **Step 1: Update existing learning-efficiency assertion**
+
+In `createAchievementReportAcceptsCallerPascalCasePayloadAndStoresAchievementContent`, replace:
+
+```java
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
+```
+
+with:
+
+```java
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("5.263158");
+```
+
+This expectation comes from `(4 / 19) / 0.04`, rounded to 6 decimal places.
+
+- [ ] **Step 2: Add zero-denominator assertion**
+
+In `createAchievementReportStoresZeroPercentWhenStudentImproveWordCountIsZero`, add:
+
+```java
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0");
+```
+
+- [ ] **Step 3: Run tests and verify they fail before implementation**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportApplicationServiceTest#createAchievementReportAcceptsCallerPascalCasePayloadAndStoresAchievementContent,ExamSprintReportApplicationServiceTest#createAchievementReportStoresZeroPercentWhenStudentImproveWordCountIsZero test
+```
+
+Expected: FAIL because implementation still uses upstream `ImproveStudyEfficiency`.
+
+## Task 4: Achievement Learning Efficiency Implementation
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java`
+- Test: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+
+- [ ] **Step 1: Introduce derived learning efficiency text**
+
+In `toDomainContent`, after `masteryHitRateText` add:
+
+```java
+        String learningEfficiencyText = learningEfficiencyText(payload.testPaperImprovedWordCount(), payload.studentImproveWordCount());
+```
+
+Then replace both occurrences of:
+
+```java
+format(payload.improveStudyEfficiency())
+```
+
+with:
+
+```java
+learningEfficiencyText
+```
+
+- [ ] **Step 2: Add the calculation helper**
+
+Add this method after `masteryHitRateText`:
+
+```java
+    private static String learningEfficiencyText(BigDecimal improvedWordCount, BigDecimal studentImproveWordCount) {
+        if (studentImproveWordCount.signum() == 0) {
+            return "0";
+        }
+        BigDecimal efficiency = improvedWordCount
+                .divide(studentImproveWordCount, 8, RoundingMode.HALF_UP)
+                .divide(new BigDecimal("0.04"), 6, RoundingMode.HALF_UP);
+        return format(efficiency);
+    }
+```
+
+- [ ] **Step 3: Run learning-efficiency tests and verify pass**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportApplicationServiceTest#createAchievementReportAcceptsCallerPascalCasePayloadAndStoresAchievementContent,ExamSprintReportApplicationServiceTest#createAchievementReportStoresZeroPercentWhenStudentImproveWordCountIsZero test
+```
+
+Expected: PASS.
+
+## Task 5: Regression Verification
+
+**Files:**
+- Test: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+- Verify: `pom.xml`, `abilities/exam-sprint/application/pom.xml`
+
+- [ ] **Step 1: Run full application-module test class**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportApplicationServiceTest test
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run application module tests**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application test
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Inspect working tree**
+
+Run:
+
+```bash
+git status --short
+```
+
+Expected: modified Java source/test files and this plan file only, unless local build outputs already existed.
+
+## Self-Review
+
+- Spec coverage: payload logging for `OUTLOOK` and `ACHIEVEMENT`, array-size-only behavior, and derived achievement learning efficiency are each covered by tests and implementation tasks.
+- Placeholder scan: no `TBD`, `TODO`, or unspecified implementation steps remain.
+- Type consistency: helper methods use existing `JsonNode`, `ObjectNode`, `ObjectMapper`, `ReportType`, `BigDecimal`, and `RoundingMode` types already present in the target classes.