Просмотр исходного кода

feat(词汇摸底): 新增后端测评能力

金逸霄 2 дней назад
Родитель
Сommit
8b735f2f94
65 измененных файлов с 3358 добавлено и 2773 удалено
  1. 1 0
      .gitignore
  2. 59 0
      abilities/vocabulary-survey/application/pom.xml
  3. 98 0
      abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/DefaultVocabularySurveyApplicationService.java
  4. 4 0
      abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/StartVocabularySurveyCommand.java
  5. 10 0
      abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/SubmitVocabularySurveyLevelCommand.java
  6. 13 0
      abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/VocabularySurveyApplicationService.java
  7. 26 0
      abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/VocabularySurveyProperties.java
  8. 81 0
      abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/VocabularySurveyResultMapper.java
  9. 137 0
      abilities/vocabulary-survey/application/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/application/DefaultVocabularySurveyApplicationServiceTest.java
  10. 35 0
      abilities/vocabulary-survey/contracts/pom.xml
  11. 6 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/StartVocabularySurveyRequest.java
  12. 7 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/SubmitVocabularySurveyLevelRequest.java
  13. 9 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/SubmitVocabularySurveyLevelResponse.java
  14. 4 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyLearningRangeResponse.java
  15. 11 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyLevelResponse.java
  16. 13 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyReportResponse.java
  17. 9 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveySessionResponse.java
  18. 7 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyStatus.java
  19. 4 0
      abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyWordResponse.java
  20. 37 0
      abilities/vocabulary-survey/domain/pom.xml
  21. 4 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyAnswerAnalysis.java
  22. 66 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyAnswerAnalyzer.java
  23. 38 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyGradePolicy.java
  24. 69 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyKEstimator.java
  25. 4 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLearningRange.java
  26. 65 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLevel.java
  27. 95 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLevelPlanner.java
  28. 16 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLevelWord.java
  29. 13 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyReport.java
  30. 73 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyReportGenerator.java
  31. 246 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveySession.java
  32. 13 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveySessionRepository.java
  33. 7 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyStatus.java
  34. 4 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularyWord.java
  35. 18 0
      abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularyWordProvider.java
  36. 61 0
      abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyAnswerAnalyzerTest.java
  37. 33 0
      abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyGradePolicyTest.java
  38. 62 0
      abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyKEstimatorTest.java
  39. 220 0
      abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLevelPlannerTest.java
  40. 57 0
      abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyReportGeneratorTest.java
  41. 212 0
      abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveySessionTest.java
  42. 59 0
      abilities/vocabulary-survey/infrastructure/pom.xml
  43. 8 0
      abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryExchange.java
  44. 8 0
      abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryMeaning.java
  45. 11 0
      abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryObject.java
  46. 10 0
      abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryPhrase.java
  47. 10 0
      abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryWord.java
  48. 22 0
      abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/MessagePackDictionaryLoader.java
  49. 173 0
      abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/MessagePackVocabularyWordProvider.java
  50. 29 0
      abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/repository/InMemoryVocabularySurveySessionRepository.java
  51. BIN
      abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data
  52. 59 0
      abilities/vocabulary-survey/infrastructure/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/MessagePackVocabularyWordProviderTest.java
  53. 85 0
      abilities/vocabulary-survey/infrastructure/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/repository/InMemoryVocabularySurveySessionRepositoryTest.java
  54. 9 0
      ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java
  55. 20 0
      ability-center-runtime/pom.xml
  56. 48 0
      ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/adapter/http/VocabularySurveyController.java
  57. 71 0
      ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/configuration/VocabularySurveyRuntimeConfiguration.java
  58. 3 0
      ability-center-runtime/src/main/resources/application.yml
  59. 34 0
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/VocabularySurveyArchitectureTest.java
  60. 132 0
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/adapter/http/VocabularySurveyControllerWebMvcTest.java
  61. 36 0
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/configuration/VocabularySurveyRuntimeConfigurationTest.java
  62. 376 2665
      docs/superpowers/plans/2026-05-24-vocabulary-survey.md
  63. 115 0
      docs/superpowers/plans/2026-05-25-vocabulary-survey-miniprogram.md
  64. 89 108
      docs/superpowers/specs/2026-05-24-vocabulary-survey-design.md
  65. 4 0
      pom.xml

+ 1 - 0
.gitignore

@@ -11,3 +11,4 @@ target/
 
 # OS files
 .DS_Store
+.superpowers

+ 59 - 0
abilities/vocabulary-survey/application/pom.xml

@@ -0,0 +1,59 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>dcjxb.microservice</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>vocabulary-survey-application</artifactId>
+    <name>vocabulary-survey-application</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-contracts</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-domain</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-context</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-autoconfigure</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 98 - 0
abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/DefaultVocabularySurveyApplicationService.java

@@ -0,0 +1,98 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevelPlanner;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReportGenerator;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+
+public class DefaultVocabularySurveyApplicationService implements VocabularySurveyApplicationService {
+
+    private final VocabularyWordProvider wordProvider;
+    private final VocabularySurveySessionRepository repository;
+    private final VocabularySurveyLevelPlanner planner;
+    private final VocabularySurveyReportGenerator reportGenerator;
+    private final Clock clock;
+    private final Duration sessionTtl;
+    private final VocabularySurveyResultMapper mapper;
+
+    public DefaultVocabularySurveyApplicationService(
+            VocabularyWordProvider wordProvider,
+            VocabularySurveySessionRepository repository,
+            VocabularySurveyLevelPlanner planner,
+            VocabularySurveyReportGenerator reportGenerator,
+            Clock clock,
+            Duration sessionTtl) {
+        this.wordProvider = Objects.requireNonNull(wordProvider, "wordProvider");
+        this.repository = Objects.requireNonNull(repository, "repository");
+        this.planner = Objects.requireNonNull(planner, "planner");
+        this.reportGenerator = Objects.requireNonNull(reportGenerator, "reportGenerator");
+        this.clock = Objects.requireNonNull(clock, "clock");
+        this.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl");
+        this.mapper = new VocabularySurveyResultMapper();
+    }
+
+    @Override
+    public VocabularySurveySessionResponse start(StartVocabularySurveyCommand command) {
+        Instant now = clock.instant();
+        VocabularySurveySession session = VocabularySurveySession.start(
+                "surv_" + UUID.randomUUID(),
+                command.grade(),
+                wordProvider,
+                planner,
+                now,
+                sessionTtl);
+        repository.save(session);
+        return mapper.toSessionResponse(session);
+    }
+
+    @Override
+    public SubmitVocabularySurveyLevelResponse submit(SubmitVocabularySurveyLevelCommand command) {
+        Instant now = clock.instant();
+        VocabularySurveySession session = findSession(command.sessionId());
+        ensureNotExpired(session, now);
+        session.submitLevel(
+                command.levelId(),
+                Set.copyOf(command.unknownWordIds()),
+                planner,
+                reportGenerator,
+                now);
+        repository.save(session);
+        int submittedLevelNo = session.levelById(command.levelId())
+                .orElseThrow(() -> new BusinessException(ErrorCode.SURVEY_LEVEL_NOT_FOUND))
+                .levelNo();
+        return mapper.toSubmitResponse(session, submittedLevelNo);
+    }
+
+    @Override
+    public VocabularySurveySessionResponse getSession(String sessionId) {
+        Instant now = clock.instant();
+        VocabularySurveySession session = findSession(sessionId);
+        ensureNotExpired(session, now);
+        return mapper.toSessionResponse(session);
+    }
+
+    private VocabularySurveySession findSession(String sessionId) {
+        return repository.findById(sessionId)
+                .orElseThrow(() -> new BusinessException(ErrorCode.SURVEY_SESSION_NOT_FOUND));
+    }
+
+    private void ensureNotExpired(VocabularySurveySession session, Instant now) {
+        if (session.isExpired(now)) {
+            session.markExpired(now);
+            repository.save(session);
+            throw new BusinessException(ErrorCode.SURVEY_SESSION_EXPIRED);
+        }
+    }
+}

+ 4 - 0
abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/StartVocabularySurveyCommand.java

@@ -0,0 +1,4 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+public record StartVocabularySurveyCommand(int grade) {
+}

+ 10 - 0
abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/SubmitVocabularySurveyLevelCommand.java

@@ -0,0 +1,10 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import java.util.List;
+
+public record SubmitVocabularySurveyLevelCommand(String sessionId, String levelId, List<Integer> unknownWordIds) {
+
+    public SubmitVocabularySurveyLevelCommand {
+        unknownWordIds = List.copyOf(unknownWordIds);
+    }
+}

+ 13 - 0
abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/VocabularySurveyApplicationService.java

@@ -0,0 +1,13 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+
+public interface VocabularySurveyApplicationService {
+
+    VocabularySurveySessionResponse start(StartVocabularySurveyCommand command);
+
+    SubmitVocabularySurveyLevelResponse submit(SubmitVocabularySurveyLevelCommand command);
+
+    VocabularySurveySessionResponse getSession(String sessionId);
+}

+ 26 - 0
abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/VocabularySurveyProperties.java

@@ -0,0 +1,26 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "ability.vocabulary-survey")
+public class VocabularySurveyProperties {
+
+    private final Dictionary dictionary = new Dictionary();
+
+    public Dictionary getDictionary() {
+        return dictionary;
+    }
+
+    public static class Dictionary {
+
+        private String location = "classpath:data/dict.data";
+
+        public String getLocation() {
+            return location;
+        }
+
+        public void setLocation(String location) {
+            this.location = location;
+        }
+    }
+}

+ 81 - 0
abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/VocabularySurveyResultMapper.java

@@ -0,0 +1,81 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLearningRangeResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyReportResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyStatus;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyWordResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevel;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevelWord;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReport;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+
+import java.util.List;
+
+public class VocabularySurveyResultMapper {
+
+    public VocabularySurveySessionResponse toSessionResponse(VocabularySurveySession session) {
+        return new VocabularySurveySessionResponse(
+                session.sessionId(),
+                toContractStatus(session.status()),
+                session.grade(),
+                session.status() == cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus.IN_PROGRESS
+                        ? toLevelResponse(session.currentLevel())
+                        : null,
+                toReportResponse(session.report()));
+    }
+
+    public SubmitVocabularySurveyLevelResponse toSubmitResponse(VocabularySurveySession session, int submittedLevelNo) {
+        return new SubmitVocabularySurveyLevelResponse(
+                session.sessionId(),
+                toContractStatus(session.status()),
+                submittedLevelNo,
+                session.status() == cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus.IN_PROGRESS
+                        ? toLevelResponse(session.currentLevel())
+                        : null,
+                toReportResponse(session.report()));
+    }
+
+    private VocabularySurveyStatus toContractStatus(
+            cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus status) {
+        return switch (status) {
+            case IN_PROGRESS -> VocabularySurveyStatus.inProgress;
+            case FINISHED -> VocabularySurveyStatus.finished;
+            case EXPIRED -> VocabularySurveyStatus.expired;
+        };
+    }
+
+    private VocabularySurveyLevelResponse toLevelResponse(VocabularySurveyLevel level) {
+        List<VocabularySurveyWordResponse> words = level.words().stream()
+                .map(this::toWordResponse)
+                .toList();
+        return new VocabularySurveyLevelResponse(
+                level.levelId(),
+                level.levelNo(),
+                words.size(),
+                level.feedback(),
+                words);
+    }
+
+    private VocabularySurveyWordResponse toWordResponse(VocabularySurveyLevelWord word) {
+        return new VocabularySurveyWordResponse(word.wordId(), word.spell(), word.wordFrequency());
+    }
+
+    private VocabularySurveyReportResponse toReportResponse(VocabularySurveyReport report) {
+        if (report == null) {
+            return null;
+        }
+        return new VocabularySurveyReportResponse(
+                report.vocabularySize(),
+                report.k(),
+                report.grade(),
+                report.gradeMaxFrequency(),
+                report.abilityLevel(),
+                new VocabularySurveyLearningRangeResponse(report.learningRange().min(), report.learningRange().max()),
+                report.testedWordCount(),
+                report.unknownWordCount(),
+                report.summary());
+    }
+}

+ 137 - 0
abilities/vocabulary-survey/application/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/application/DefaultVocabularySurveyApplicationServiceTest.java

@@ -0,0 +1,137 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyStatus;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevelPlanner;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReportGenerator;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyKEstimator;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DefaultVocabularySurveyApplicationServiceTest {
+
+    private final FakeVocabularyWordProvider provider = new FakeVocabularyWordProvider(3000);
+    private final FakeVocabularySurveySessionRepository repository = new FakeVocabularySurveySessionRepository();
+    private final VocabularySurveyApplicationService service = new DefaultVocabularySurveyApplicationService(
+            provider,
+            repository,
+            new VocabularySurveyLevelPlanner(),
+            new VocabularySurveyReportGenerator(new VocabularySurveyKEstimator()),
+            Clock.fixed(Instant.parse("2026-05-25T00:00:00Z"), ZoneOffset.UTC),
+            Duration.ofDays(7));
+
+    @Test
+    void startsSubmitsAndRestoresAnonymousSession() {
+        VocabularySurveySessionResponse started = service.start(new StartVocabularySurveyCommand(7));
+
+        assertThat(started.sessionId()).startsWith("surv_");
+        assertThat(started.status()).isEqualTo(VocabularySurveyStatus.inProgress);
+        assertThat(started.level().levelNo()).isEqualTo(1);
+        assertThat(started.level().words()).hasSize(12);
+
+        SubmitVocabularySurveyLevelResponse next = service.submit(new SubmitVocabularySurveyLevelCommand(
+                started.sessionId(),
+                started.level().levelId(),
+                List.of()));
+
+        assertThat(next.status()).isEqualTo(VocabularySurveyStatus.inProgress);
+        assertThat(next.submittedLevelNo()).isEqualTo(1);
+        assertThat(next.level().levelNo()).isEqualTo(2);
+
+        VocabularySurveySessionResponse restored = service.getSession(started.sessionId());
+
+        assertThat(restored.sessionId()).isEqualTo(started.sessionId());
+        assertThat(restored.level().levelNo()).isEqualTo(2);
+    }
+
+    @Test
+    void submitReturnsUnstableFeedbackWhenEasyWordsAreUnknownButHarderWordsAreKnown() {
+        VocabularySurveySessionResponse started = service.start(new StartVocabularySurveyCommand(7));
+        List<Integer> easyUnknownWordIds = started.level().words().stream()
+                .sorted(Comparator.comparingInt(word -> word.wordFrequency()))
+                .limit(3)
+                .map(word -> word.wordId())
+                .toList();
+
+        SubmitVocabularySurveyLevelResponse next = service.submit(new SubmitVocabularySurveyLevelCommand(
+                started.sessionId(),
+                started.level().levelId(),
+                easyUnknownWordIds));
+
+        assertThat(next.status()).isEqualTo(VocabularySurveyStatus.inProgress);
+        assertThat(next.level().levelNo()).isEqualTo(2);
+        assertThat(next.level().feedback()).contains("不稳定");
+    }
+
+    private static final class FakeVocabularySurveySessionRepository implements VocabularySurveySessionRepository {
+
+        private final Map<String, VocabularySurveySession> sessions = new ConcurrentHashMap<>();
+
+        @Override
+        public Optional<VocabularySurveySession> findById(String sessionId) {
+            return Optional.ofNullable(sessions.get(sessionId));
+        }
+
+        @Override
+        public void save(VocabularySurveySession session) {
+            sessions.put(session.sessionId(), session);
+        }
+
+        @Override
+        public void deleteExpired(Instant now) {
+            sessions.values().removeIf(session -> session.isExpired(now));
+        }
+    }
+
+    private static final class FakeVocabularyWordProvider implements VocabularyWordProvider {
+
+        private final List<VocabularyWord> words;
+
+        private FakeVocabularyWordProvider(int maxFrequency) {
+            this.words = IntStream.rangeClosed(1, maxFrequency)
+                    .mapToObj(frequency -> new VocabularyWord(100000 + frequency, "word" + frequency, frequency))
+                    .toList();
+        }
+
+        @Override
+        public List<VocabularyWord> findWordsAroundFrequency(
+                int centerFrequency,
+                int width,
+                int count,
+                Set<Integer> excludedWordIds) {
+            return words.stream()
+                    .filter(word -> !excludedWordIds.contains(word.wordId()))
+                    .sorted(Comparator.comparingInt(word -> Math.abs(word.wordFrequency() - centerFrequency)))
+                    .limit(count)
+                    .toList();
+        }
+
+        @Override
+        public Optional<VocabularyWord> findByWordId(int wordId) {
+            return words.stream().filter(word -> word.wordId() == wordId).findFirst();
+        }
+
+        @Override
+        public int maxWordFrequency() {
+            return words.get(words.size() - 1).wordFrequency();
+        }
+    }
+}

+ 35 - 0
abilities/vocabulary-survey/contracts/pom.xml

@@ -0,0 +1,35 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>dcjxb.microservice</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>vocabulary-survey-contracts</artifactId>
+    <name>vocabulary-survey-contracts</name>
+    <dependencies>
+        <dependency>
+            <groupId>jakarta.validation</groupId>
+            <artifactId>jakarta.validation-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 6 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/StartVocabularySurveyRequest.java

@@ -0,0 +1,6 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+import jakarta.validation.constraints.NotNull;
+
+public record StartVocabularySurveyRequest(@NotNull Integer grade) {
+}

+ 7 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/SubmitVocabularySurveyLevelRequest.java

@@ -0,0 +1,7 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+
+public record SubmitVocabularySurveyLevelRequest(@NotNull List<@NotNull Integer> unknownWordIds) {
+}

+ 9 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/SubmitVocabularySurveyLevelResponse.java

@@ -0,0 +1,9 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record SubmitVocabularySurveyLevelResponse(
+        String sessionId,
+        VocabularySurveyStatus status,
+        int submittedLevelNo,
+        VocabularySurveyLevelResponse level,
+        VocabularySurveyReportResponse report) {
+}

+ 4 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyLearningRangeResponse.java

@@ -0,0 +1,4 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record VocabularySurveyLearningRangeResponse(int min, int max) {
+}

+ 11 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyLevelResponse.java

@@ -0,0 +1,11 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+import java.util.List;
+
+public record VocabularySurveyLevelResponse(
+        String levelId,
+        int levelNo,
+        int wordCount,
+        String feedback,
+        List<VocabularySurveyWordResponse> words) {
+}

+ 13 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyReportResponse.java

@@ -0,0 +1,13 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record VocabularySurveyReportResponse(
+        int vocabularySize,
+        int k,
+        int grade,
+        int gradeMaxFrequency,
+        String abilityLevel,
+        VocabularySurveyLearningRangeResponse learningRange,
+        int testedWordCount,
+        int unknownWordCount,
+        String summary) {
+}

+ 9 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveySessionResponse.java

@@ -0,0 +1,9 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record VocabularySurveySessionResponse(
+        String sessionId,
+        VocabularySurveyStatus status,
+        int grade,
+        VocabularySurveyLevelResponse level,
+        VocabularySurveyReportResponse report) {
+}

+ 7 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyStatus.java

@@ -0,0 +1,7 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public enum VocabularySurveyStatus {
+    inProgress,
+    finished,
+    expired
+}

+ 4 - 0
abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyWordResponse.java

@@ -0,0 +1,4 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record VocabularySurveyWordResponse(int wordId, String spell, int wordFrequency) {
+}

+ 37 - 0
abilities/vocabulary-survey/domain/pom.xml

@@ -0,0 +1,37 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>dcjxb.microservice</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>vocabulary-survey-domain</artifactId>
+    <name>vocabulary-survey-domain</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 4 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyAnswerAnalysis.java

@@ -0,0 +1,4 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public record VocabularySurveyAnswerAnalysis(int estimatedK, boolean inversionDetected) {
+}

+ 66 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyAnswerAnalyzer.java

@@ -0,0 +1,66 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+
+import java.util.Comparator;
+import java.util.List;
+
+public class VocabularySurveyAnswerAnalyzer {
+
+    private final VocabularySurveyKEstimator estimator;
+
+    public VocabularySurveyAnswerAnalyzer() {
+        this(new VocabularySurveyKEstimator());
+    }
+
+    public VocabularySurveyAnswerAnalyzer(VocabularySurveyKEstimator estimator) {
+        this.estimator = estimator;
+    }
+
+    public VocabularySurveyAnswerAnalysis analyze(List<VocabularySurveyLevel> levels) {
+        List<TestedWord> testedWords = levels.stream()
+                .filter(VocabularySurveyLevel::submitted)
+                .flatMap(level -> level.words().stream())
+                .map(word -> new TestedWord(word.wordFrequency(), word.unknown()))
+                .sorted(Comparator.comparingInt(TestedWord::wordFrequency))
+                .toList();
+        if (testedWords.isEmpty()) {
+            throw new BusinessException(ErrorCode.SURVEY_REPORT_GENERATION_FAILED);
+        }
+        return new VocabularySurveyAnswerAnalysis(estimator.estimate(levels), detectsCurrentLevelInversion(levels));
+    }
+
+    private boolean detectsCurrentLevelInversion(List<VocabularySurveyLevel> levels) {
+        return levels.stream()
+                .filter(VocabularySurveyLevel::submitted)
+                .reduce((previous, current) -> current)
+                .map(level -> detectsInversion(level.words().stream()
+                        .map(word -> new TestedWord(word.wordFrequency(), word.unknown()))
+                        .sorted(Comparator.comparingInt(TestedWord::wordFrequency))
+                        .toList()))
+                .orElse(false);
+    }
+
+    private boolean detectsInversion(List<TestedWord> testedWords) {
+        boolean hasKnown = testedWords.stream().anyMatch(word -> !word.unknown());
+        boolean hasUnknown = testedWords.stream().anyMatch(TestedWord::unknown);
+        if (!hasKnown || !hasUnknown) {
+            return false;
+        }
+        int easiestUnknownFrequency = testedWords.stream()
+                .filter(TestedWord::unknown)
+                .mapToInt(TestedWord::wordFrequency)
+                .min()
+                .orElse(Integer.MAX_VALUE);
+        int hardestKnownFrequency = testedWords.stream()
+                .filter(word -> !word.unknown())
+                .mapToInt(TestedWord::wordFrequency)
+                .max()
+                .orElse(Integer.MIN_VALUE);
+        return hardestKnownFrequency > easiestUnknownFrequency;
+    }
+
+    private record TestedWord(int wordFrequency, boolean unknown) {
+    }
+}

+ 38 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyGradePolicy.java

@@ -0,0 +1,38 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+
+import java.util.Map;
+
+public final class VocabularySurveyGradePolicy {
+
+    private static final Map<Integer, Integer> GRADE_MAX = Map.of(
+            3, 400,
+            4, 700,
+            5, 1000,
+            6, 1200,
+            7, 1600,
+            8, 2000,
+            9, 2400,
+            10, 3200,
+            11, 4000,
+            12, 4800);
+
+    private VocabularySurveyGradePolicy() {
+    }
+
+    public static int gradeMaxFrequency(int grade) {
+        Integer maxFrequency = GRADE_MAX.get(grade);
+        if (maxFrequency == null) {
+            throw new BusinessException(ErrorCode.INVALID_GRADE);
+        }
+        return maxFrequency;
+    }
+
+    public static int challengeMaxFrequency(int grade, int providerMax) {
+        gradeMaxFrequency(grade);
+        int challengeMax = grade == 12 ? providerMax : gradeMaxFrequency(grade + 1);
+        return Math.min(challengeMax, providerMax);
+    }
+}

+ 69 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyKEstimator.java

@@ -0,0 +1,69 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+
+import java.util.Comparator;
+import java.util.List;
+
+public class VocabularySurveyKEstimator {
+
+    public int estimate(List<VocabularySurveyLevel> levels) {
+        List<TestedWord> testedWords = levels.stream()
+                .filter(VocabularySurveyLevel::submitted)
+                .flatMap(level -> level.words().stream())
+                .map(word -> new TestedWord(word.wordFrequency(), word.unknown()))
+                .sorted(Comparator.comparingInt(TestedWord::wordFrequency))
+                .toList();
+        if (testedWords.isEmpty()) {
+            throw new BusinessException(ErrorCode.SURVEY_REPORT_GENERATION_FAILED);
+        }
+
+        int tieBreakCenter = tieBreakCenter(levels);
+        int bestK = testedWords.get(0).wordFrequency();
+        int bestError = Integer.MAX_VALUE;
+        for (int candidateK : testedWords.stream().map(TestedWord::wordFrequency).distinct().toList()) {
+            int error = error(candidateK, testedWords);
+            if (error < bestError || error == bestError && closer(candidateK, bestK, tieBreakCenter)) {
+                bestK = candidateK;
+                bestError = error;
+            }
+        }
+        return bestK;
+    }
+
+    private int error(int candidateK, List<TestedWord> testedWords) {
+        int error = 0;
+        for (TestedWord word : testedWords) {
+            if (word.unknown() && word.wordFrequency() <= candidateK) {
+                error++;
+            }
+            if (!word.unknown() && word.wordFrequency() > candidateK) {
+                error++;
+            }
+        }
+        return error;
+    }
+
+    private int tieBreakCenter(List<VocabularySurveyLevel> levels) {
+        return levels.stream()
+                .filter(VocabularySurveyLevel::inConvergenceBand)
+                .reduce((previous, current) -> current)
+                .or(() -> levels.stream().filter(VocabularySurveyLevel::submitted).reduce((previous, current) -> current))
+                .or(() -> levels.stream().reduce((previous, current) -> current))
+                .map(VocabularySurveyLevel::centerFrequency)
+                .orElseThrow(() -> new BusinessException(ErrorCode.SURVEY_REPORT_GENERATION_FAILED));
+    }
+
+    private boolean closer(int candidateK, int bestK, int tieBreakCenter) {
+        int candidateDistance = Math.abs(candidateK - tieBreakCenter);
+        int bestDistance = Math.abs(bestK - tieBreakCenter);
+        if (candidateDistance == bestDistance) {
+            return candidateK < bestK;
+        }
+        return candidateDistance < bestDistance;
+    }
+
+    private record TestedWord(int wordFrequency, boolean unknown) {
+    }
+}

+ 4 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLearningRange.java

@@ -0,0 +1,4 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public record VocabularySurveyLearningRange(int min, int max) {
+}

+ 65 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLevel.java

@@ -0,0 +1,65 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public record VocabularySurveyLevel(
+        String levelId,
+        int levelNo,
+        int centerFrequency,
+        int wordWidth,
+        String feedback,
+        List<VocabularySurveyLevelWord> words,
+        boolean submitted,
+        Set<Integer> unknownWordIds) {
+
+    public VocabularySurveyLevel {
+        words = List.copyOf(words);
+        unknownWordIds = Set.copyOf(unknownWordIds);
+    }
+
+    public static VocabularySurveyLevel unsubmitted(
+            String levelId,
+            int levelNo,
+            int centerFrequency,
+            int wordWidth,
+            String feedback,
+            List<VocabularySurveyLevelWord> words) {
+        return new VocabularySurveyLevel(levelId, levelNo, centerFrequency, wordWidth, feedback, words, false, Set.of());
+    }
+
+    public Set<Integer> wordIds() {
+        return words.stream()
+                .map(VocabularySurveyLevelWord::wordId)
+                .collect(Collectors.toUnmodifiableSet());
+    }
+
+    public boolean containsAllWordIds(Set<Integer> wordIds) {
+        return wordIds().containsAll(wordIds);
+    }
+
+    public int unknownCount() {
+        return unknownWordIds.size();
+    }
+
+    public boolean inConvergenceBand() {
+        return submitted && unknownCount() >= 4 && unknownCount() <= 8;
+    }
+
+    public VocabularySurveyLevel markSubmitted(Set<Integer> submittedUnknownWordIds) {
+        Set<Integer> copiedUnknownWordIds = Set.copyOf(submittedUnknownWordIds);
+        List<VocabularySurveyLevelWord> submittedWords = words.stream()
+                .map(word -> word.markUnknown(copiedUnknownWordIds.contains(word.wordId())))
+                .toList();
+        return new VocabularySurveyLevel(
+                levelId,
+                levelNo,
+                centerFrequency,
+                wordWidth,
+                feedback,
+                submittedWords,
+                true,
+                copiedUnknownWordIds);
+    }
+}

+ 95 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLevelPlanner.java

@@ -0,0 +1,95 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+
+import java.util.List;
+import java.util.Set;
+
+public class VocabularySurveyLevelPlanner {
+
+    public static final int WORD_COUNT = 12;
+    public static final int MIN_WORD_WIDTH = 100;
+    public static final double WIDTH_DECAY = 0.75;
+
+    private final VocabularySurveyAnswerAnalyzer answerAnalyzer = new VocabularySurveyAnswerAnalyzer();
+
+    public VocabularySurveyLevel planFirstLevel(
+            String levelId,
+            int grade,
+            VocabularyWordProvider provider,
+            Set<Integer> excludedWordIds) {
+        int gradeMaxFrequency = VocabularySurveyGradePolicy.gradeMaxFrequency(grade);
+        int centerFrequency = Math.max(MIN_WORD_WIDTH, Math.round(gradeMaxFrequency * 0.25f));
+        return planLevel(
+                levelId,
+                1,
+                centerFrequency,
+                gradeMaxFrequency,
+                "先来热个身,选出你不认识的单词吧",
+                provider,
+                excludedWordIds);
+    }
+
+    public VocabularySurveyLevel planNextLevel(
+            String levelId,
+            VocabularySurveyLevel currentLevel,
+            List<VocabularySurveyLevel> levels,
+            int challengeMaxFrequency,
+            VocabularyWordProvider provider,
+            Set<Integer> excludedWordIds) {
+        int nextWidth = Math.max((int) Math.round(currentLevel.wordWidth() * WIDTH_DECAY), MIN_WORD_WIDTH);
+        int offset = Math.max(1, nextWidth / 6);
+        VocabularySurveyAnswerAnalysis analysis = answerAnalyzer.analyze(levels);
+        int nextCenter;
+        String feedback;
+        if (analysis.inversionDetected()) {
+            nextCenter = currentLevel.centerFrequency() - offset;
+            feedback = "本关结果不稳定,我们再确认一下基础词。";
+        } else {
+            nextCenter = analysis.estimatedK();
+            if (nextCenter > currentLevel.centerFrequency()) {
+                feedback = "本关完成,下一关围绕能力边界挑战稍高一点的词!";
+            } else if (nextCenter < currentLevel.centerFrequency()) {
+                feedback = "本关完成,下一关先巩固更基础的词!";
+            } else {
+                feedback = "本关完成,继续保持当前节奏!";
+            }
+        }
+        nextCenter = Math.max(MIN_WORD_WIDTH, Math.min(challengeMaxFrequency, nextCenter));
+        return planLevel(
+                levelId,
+                currentLevel.levelNo() + 1,
+                nextCenter,
+                nextWidth,
+                feedback,
+                provider,
+                excludedWordIds);
+    }
+
+    private VocabularySurveyLevel planLevel(
+            String levelId,
+            int levelNo,
+            int centerFrequency,
+            int wordWidth,
+            String feedback,
+            VocabularyWordProvider provider,
+            Set<Integer> excludedWordIds) {
+        List<VocabularySurveyLevelWord> words = provider.findWordsAroundFrequency(
+                        centerFrequency,
+                        wordWidth,
+                        WORD_COUNT,
+                        excludedWordIds)
+                .stream()
+                .map(VocabularySurveyLevelWord::from)
+                .toList();
+        if (words.size() != WORD_COUNT) {
+            throw new BusinessException(ErrorCode.SURVEY_WORD_GENERATION_FAILED);
+        }
+        long uniqueWordCount = words.stream().map(VocabularySurveyLevelWord::wordId).distinct().count();
+        if (uniqueWordCount != WORD_COUNT) {
+            throw new BusinessException(ErrorCode.SURVEY_WORD_GENERATION_FAILED);
+        }
+        return VocabularySurveyLevel.unsubmitted(levelId, levelNo, centerFrequency, wordWidth, feedback, words);
+    }
+}

+ 16 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLevelWord.java

@@ -0,0 +1,16 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public record VocabularySurveyLevelWord(
+        int wordId,
+        String spell,
+        int wordFrequency,
+        boolean unknown) {
+
+    public static VocabularySurveyLevelWord from(VocabularyWord word) {
+        return new VocabularySurveyLevelWord(word.wordId(), word.spell(), word.wordFrequency(), false);
+    }
+
+    public VocabularySurveyLevelWord markUnknown(boolean unknown) {
+        return new VocabularySurveyLevelWord(wordId, spell, wordFrequency, unknown);
+    }
+}

+ 13 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyReport.java

@@ -0,0 +1,13 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public record VocabularySurveyReport(
+        int vocabularySize,
+        int k,
+        int grade,
+        int gradeMaxFrequency,
+        String abilityLevel,
+        VocabularySurveyLearningRange learningRange,
+        int testedWordCount,
+        int unknownWordCount,
+        String summary) {
+}

+ 73 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyReportGenerator.java

@@ -0,0 +1,73 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.util.List;
+
+public class VocabularySurveyReportGenerator {
+
+    private final VocabularySurveyKEstimator estimator;
+
+    public VocabularySurveyReportGenerator(VocabularySurveyKEstimator estimator) {
+        this.estimator = estimator;
+    }
+
+    public VocabularySurveyReport generate(
+            int grade,
+            int gradeMaxFrequency,
+            int challengeMaxFrequency,
+            List<VocabularySurveyLevel> levels) {
+        int k = estimator.estimate(levels);
+        int testedWordCount = levels.stream()
+                .filter(VocabularySurveyLevel::submitted)
+                .mapToInt(level -> level.words().size())
+                .sum();
+        int unknownWordCount = levels.stream()
+                .filter(VocabularySurveyLevel::submitted)
+                .mapToInt(VocabularySurveyLevel::unknownCount)
+                .sum();
+        String abilityLevel = abilityLevel(k, gradeMaxFrequency);
+        return new VocabularySurveyReport(
+                k,
+                k,
+                grade,
+                gradeMaxFrequency,
+                abilityLevel,
+                learningRange(k, gradeMaxFrequency, challengeMaxFrequency),
+                testedWordCount,
+                unknownWordCount,
+                summary(abilityLevel));
+    }
+
+    private VocabularySurveyLearningRange learningRange(int k, int gradeMaxFrequency, int challengeMaxFrequency) {
+        int offset = (int) Math.round(gradeMaxFrequency * 0.15d);
+        int min = Math.max(1, k - offset);
+        int max = Math.min(challengeMaxFrequency, k + offset);
+        return new VocabularySurveyLearningRange(min, max);
+    }
+
+    private String abilityLevel(int k, int gradeMaxFrequency) {
+        double ratio = (double) k / gradeMaxFrequency;
+        if (ratio < 0.45d) {
+            return "基础待加强";
+        }
+        if (ratio < 0.75d) {
+            return "年级基础";
+        }
+        if (ratio < 1.05d) {
+            return "年级稳定";
+        }
+        if (ratio <= 1.25d) {
+            return "超前挑战";
+        }
+        return "明显超前";
+    }
+
+    private String summary(String abilityLevel) {
+        return switch (abilityLevel) {
+            case "基础待加强" -> "建议先巩固本年级核心高频词,再逐步挑战更高频段。";
+            case "年级基础" -> "你的词汇基础已具备雏形,建议继续巩固本年级常见词。";
+            case "年级稳定" -> "你的词汇基础比较稳定,适合继续挑战本年级中高阶词汇。";
+            case "超前挑战" -> "你的词汇能力已经超过当前年级基础要求,可以适当挑战更高频段词汇。";
+            default -> "你的词汇掌握明显超前,可以尝试挑战更高年级词汇。";
+        };
+    }
+}

+ 246 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveySession.java

@@ -0,0 +1,246 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class VocabularySurveySession {
+
+    private final String sessionId;
+    private final int grade;
+    private final int gradeMaxFrequency;
+    private final int challengeMaxFrequency;
+    private final VocabularyWordProvider wordProvider;
+    private final Instant createdAt;
+    private final Instant expiresAt;
+    private final List<VocabularySurveyLevel> levels;
+    private VocabularySurveyStatus status;
+    private VocabularySurveyReport report;
+    private Instant updatedAt;
+
+    private VocabularySurveySession(
+            String sessionId,
+            int grade,
+            int gradeMaxFrequency,
+            int challengeMaxFrequency,
+            VocabularyWordProvider wordProvider,
+            VocabularySurveyStatus status,
+            List<VocabularySurveyLevel> levels,
+            VocabularySurveyReport report,
+            Instant createdAt,
+            Instant updatedAt,
+            Instant expiresAt) {
+        this.sessionId = Objects.requireNonNull(sessionId, "sessionId");
+        this.grade = grade;
+        this.gradeMaxFrequency = gradeMaxFrequency;
+        this.challengeMaxFrequency = challengeMaxFrequency;
+        this.wordProvider = Objects.requireNonNull(wordProvider, "wordProvider");
+        this.status = Objects.requireNonNull(status, "status");
+        this.levels = new ArrayList<>(levels);
+        this.report = report;
+        this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
+        this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt");
+        this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt");
+    }
+
+    public static VocabularySurveySession start(
+            String sessionId,
+            int grade,
+            VocabularyWordProvider wordProvider,
+            VocabularySurveyLevelPlanner planner,
+            Instant now,
+            Duration ttl) {
+        int gradeMaxFrequency = VocabularySurveyGradePolicy.gradeMaxFrequency(grade);
+        int challengeMaxFrequency = VocabularySurveyGradePolicy.challengeMaxFrequency(grade, wordProvider.maxWordFrequency());
+        VocabularySurveyLevel firstLevel = planner.planFirstLevel("lvl_1", grade, wordProvider, Set.of());
+        return new VocabularySurveySession(
+                sessionId,
+                grade,
+                gradeMaxFrequency,
+                challengeMaxFrequency,
+                wordProvider,
+                VocabularySurveyStatus.IN_PROGRESS,
+                List.of(firstLevel),
+                null,
+                now,
+                now,
+                now.plus(ttl));
+    }
+
+    public synchronized void submitCurrentLevel(
+            Set<Integer> unknownWordIds,
+            VocabularySurveyLevelPlanner planner,
+            VocabularySurveyReportGenerator reportGenerator) {
+        submitCurrentLevel(unknownWordIds, planner, reportGenerator, Instant.now());
+    }
+
+    public synchronized void submitCurrentLevel(
+            Set<Integer> unknownWordIds,
+            VocabularySurveyLevelPlanner planner,
+            VocabularySurveyReportGenerator reportGenerator,
+            Instant now) {
+        submitLevel(currentLevel().levelId(), unknownWordIds, planner, reportGenerator, now);
+    }
+
+    public synchronized void submitLevel(
+            String levelId,
+            Set<Integer> unknownWordIds,
+            VocabularySurveyLevelPlanner planner,
+            VocabularySurveyReportGenerator reportGenerator) {
+        submitLevel(levelId, unknownWordIds, planner, reportGenerator, Instant.now());
+    }
+
+    public synchronized void submitLevel(
+            String levelId,
+            Set<Integer> unknownWordIds,
+            VocabularySurveyLevelPlanner planner,
+            VocabularySurveyReportGenerator reportGenerator,
+            Instant now) {
+        if (isExpired(now)) {
+            markExpired(now);
+            throw new BusinessException(ErrorCode.SURVEY_SESSION_EXPIRED);
+        }
+        int levelIndex = levelIndex(levelId)
+                .orElseThrow(() -> new BusinessException(ErrorCode.SURVEY_LEVEL_NOT_FOUND));
+        VocabularySurveyLevel targetLevel = levels.get(levelIndex);
+        if (status == VocabularySurveyStatus.FINISHED) {
+            throw new BusinessException(ErrorCode.SURVEY_SESSION_FINISHED);
+        }
+        if (status != VocabularySurveyStatus.IN_PROGRESS) {
+            throw new BusinessException(ErrorCode.SURVEY_SESSION_EXPIRED);
+        }
+        if (targetLevel.submitted()) {
+            return;
+        }
+        if (!targetLevel.levelId().equals(currentLevel().levelId())) {
+            throw new BusinessException(ErrorCode.SURVEY_LEVEL_NOT_CURRENT);
+        }
+        Set<Integer> copiedUnknownWordIds = Set.copyOf(unknownWordIds);
+        if (!targetLevel.containsAllWordIds(copiedUnknownWordIds)) {
+            throw new BusinessException(ErrorCode.INVALID_SURVEY_WORD);
+        }
+
+        VocabularySurveyLevel submittedLevel = targetLevel.markSubmitted(copiedUnknownWordIds);
+        levels.set(levelIndex, submittedLevel);
+        updatedAt = now;
+        if (shouldFinish()) {
+            status = VocabularySurveyStatus.FINISHED;
+            report = reportGenerator.generate(grade, gradeMaxFrequency, challengeMaxFrequency, levels());
+            return;
+        }
+        levels.add(planner.planNextLevel(
+                "lvl_" + (levels.size() + 1),
+                submittedLevel,
+                levels(),
+                challengeMaxFrequency,
+                wordProvider,
+                usedWordIds()));
+    }
+
+    public synchronized boolean shouldFinish() {
+        int completedLevelCount = completedLevelCount();
+        if (completedLevelCount >= 6) {
+            return true;
+        }
+        if (completedLevelCount >= 4 && recentTwoLevelsInConvergenceBand()) {
+            return true;
+        }
+        return completedLevelCount >= 5;
+    }
+
+    public synchronized int completedLevelCount() {
+        return (int) levels.stream().filter(VocabularySurveyLevel::submitted).count();
+    }
+
+    public synchronized VocabularySurveyLevel currentLevel() {
+        return levels.get(levels.size() - 1);
+    }
+
+    public synchronized List<VocabularySurveyLevel> levels() {
+        return List.copyOf(levels);
+    }
+
+    public synchronized Optional<VocabularySurveyLevel> levelById(String levelId) {
+        return levels.stream().filter(level -> level.levelId().equals(levelId)).findFirst();
+    }
+
+    public synchronized boolean isExpired(Instant now) {
+        return !now.isBefore(expiresAt);
+    }
+
+    public synchronized void markExpired(Instant now) {
+        status = VocabularySurveyStatus.EXPIRED;
+        updatedAt = now;
+    }
+
+    public String sessionId() {
+        return sessionId;
+    }
+
+    public int grade() {
+        return grade;
+    }
+
+    public int gradeMaxFrequency() {
+        return gradeMaxFrequency;
+    }
+
+    public int challengeMaxFrequency() {
+        return challengeMaxFrequency;
+    }
+
+    public VocabularySurveyStatus status() {
+        return status;
+    }
+
+    public VocabularySurveyReport report() {
+        return report;
+    }
+
+    public Instant createdAt() {
+        return createdAt;
+    }
+
+    public Instant updatedAt() {
+        return updatedAt;
+    }
+
+    public Instant expiresAt() {
+        return expiresAt;
+    }
+
+    private Optional<Integer> levelIndex(String levelId) {
+        for (int i = 0; i < levels.size(); i++) {
+            if (levels.get(i).levelId().equals(levelId)) {
+                return Optional.of(i);
+            }
+        }
+        return Optional.empty();
+    }
+
+    private boolean recentTwoLevelsInConvergenceBand() {
+        List<VocabularySurveyLevel> submittedLevels = levels.stream()
+                .filter(VocabularySurveyLevel::submitted)
+                .toList();
+        if (submittedLevels.size() < 2) {
+            return false;
+        }
+        return submittedLevels.get(submittedLevels.size() - 1).inConvergenceBand()
+                && submittedLevels.get(submittedLevels.size() - 2).inConvergenceBand();
+    }
+
+    private Set<Integer> usedWordIds() {
+        return levels.stream()
+                .flatMap(level -> level.words().stream())
+                .map(VocabularySurveyLevelWord::wordId)
+                .collect(Collectors.toUnmodifiableSet());
+    }
+}

+ 13 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveySessionRepository.java

@@ -0,0 +1,13 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.time.Instant;
+import java.util.Optional;
+
+public interface VocabularySurveySessionRepository {
+
+    Optional<VocabularySurveySession> findById(String sessionId);
+
+    void save(VocabularySurveySession session);
+
+    void deleteExpired(Instant now);
+}

+ 7 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyStatus.java

@@ -0,0 +1,7 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public enum VocabularySurveyStatus {
+    IN_PROGRESS,
+    FINISHED,
+    EXPIRED
+}

+ 4 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularyWord.java

@@ -0,0 +1,4 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public record VocabularyWord(int wordId, String spell, int wordFrequency) {
+}

+ 18 - 0
abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularyWordProvider.java

@@ -0,0 +1,18 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public interface VocabularyWordProvider {
+
+    List<VocabularyWord> findWordsAroundFrequency(
+            int centerFrequency,
+            int width,
+            int count,
+            Set<Integer> excludedWordIds);
+
+    Optional<VocabularyWord> findByWordId(int wordId);
+
+    int maxWordFrequency();
+}

+ 61 - 0
abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyAnswerAnalyzerTest.java

@@ -0,0 +1,61 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VocabularySurveyAnswerAnalyzerTest {
+
+    private final VocabularySurveyAnswerAnalyzer analyzer = new VocabularySurveyAnswerAnalyzer();
+
+    @Test
+    void detectsInversionWhenEasyWordsAreUnknownButHarderWordsAreKnown() {
+        VocabularySurveyLevel level = submittedLevel(
+                List.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200),
+                Set.of(1, 110, 219));
+
+        VocabularySurveyAnswerAnalysis analysis = analyzer.analyze(List.of(level));
+
+        assertThat(analysis.estimatedK()).isEqualTo(1200);
+        assertThat(analysis.inversionDetected()).isTrue();
+    }
+
+    @Test
+    void keepsStableBoundaryWhenOnlyHardestWordsAreUnknown() {
+        VocabularySurveyLevel level = submittedLevel(
+                List.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200),
+                Set.of(982, 1091, 1200));
+
+        VocabularySurveyAnswerAnalysis analysis = analyzer.analyze(List.of(level));
+
+        assertThat(analysis.estimatedK()).isEqualTo(873);
+        assertThat(analysis.inversionDetected()).isFalse();
+    }
+
+    @Test
+    void doesNotTreatAllUnknownAnswersAsInversion() {
+        VocabularySurveyLevel level = submittedLevel(
+                List.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200),
+                Set.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200));
+
+        VocabularySurveyAnswerAnalysis analysis = analyzer.analyze(List.of(level));
+
+        assertThat(analysis.estimatedK()).isEqualTo(1);
+        assertThat(analysis.inversionDetected()).isFalse();
+    }
+
+    private VocabularySurveyLevel submittedLevel(List<Integer> frequencies, Set<Integer> unknownFrequencies) {
+        List<VocabularySurveyLevelWord> words = frequencies.stream()
+                .map(frequency -> new VocabularySurveyLevelWord(100000 + frequency, "word" + frequency, frequency, false))
+                .toList();
+        Set<Integer> unknownWordIds = unknownFrequencies.stream()
+                .map(frequency -> 100000 + frequency)
+                .collect(Collectors.toSet());
+        return VocabularySurveyLevel.unsubmitted("lvl_1", 1, 400, 1600, "feedback", words)
+                .markSubmitted(unknownWordIds);
+    }
+}

+ 33 - 0
abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyGradePolicyTest.java

@@ -0,0 +1,33 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class VocabularySurveyGradePolicyTest {
+
+    @Test
+    void mapsSupportedGradesToMaxFrequency() {
+        assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(3)).isEqualTo(400);
+        assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(7)).isEqualTo(1600);
+        assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(12)).isEqualTo(4800);
+    }
+
+    @Test
+    void rejectsUnsupportedGrade() {
+        assertThatThrownBy(() -> VocabularySurveyGradePolicy.gradeMaxFrequency(2))
+                .isInstanceOf(BusinessException.class)
+                .extracting("errorCode")
+                .isEqualTo(ErrorCode.INVALID_GRADE);
+    }
+
+    @Test
+    void challengeMaxUsesNextGradeAndCapsByProviderMax() {
+        assertThat(VocabularySurveyGradePolicy.challengeMaxFrequency(7, 11993)).isEqualTo(2000);
+        assertThat(VocabularySurveyGradePolicy.challengeMaxFrequency(7, 1800)).isEqualTo(1800);
+        assertThat(VocabularySurveyGradePolicy.challengeMaxFrequency(12, 11993)).isEqualTo(11993);
+    }
+}

+ 62 - 0
abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyKEstimatorTest.java

@@ -0,0 +1,62 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VocabularySurveyKEstimatorTest {
+
+    private final VocabularySurveyKEstimator estimator = new VocabularySurveyKEstimator();
+
+    @Test
+    void choosesBoundaryThatMinimizesKnownUnknownClassificationError() {
+        VocabularySurveyLevel testedLevel = submittedLevel(
+                1000,
+                List.of(
+                        word(1, 800),
+                        word(2, 900),
+                        word(3, 1000),
+                        word(4, 1100),
+                        word(5, 1200)),
+                Set.of(4, 5));
+
+        assertThat(estimator.estimate(List.of(testedLevel))).isBetween(900, 1100);
+    }
+
+    @Test
+    void breaksTiesByNearestRecentConvergenceBandCenter() {
+        VocabularySurveyLevel convergence = submittedLevel(
+                1600,
+                List.of(
+                        word(1, 1000),
+                        word(2, 1200),
+                        word(3, 1600),
+                        word(4, 1800),
+                        word(5, 2200),
+                        word(6, 2300)),
+                Set.of(2, 4, 5, 6));
+
+        assertThat(estimator.estimate(List.of(convergence))).isEqualTo(1600);
+    }
+
+    private VocabularySurveyLevel submittedLevel(
+            int centerFrequency,
+            List<VocabularySurveyLevelWord> words,
+            Set<Integer> unknownWordIds) {
+        return VocabularySurveyLevel.unsubmitted(
+                        "lvl_" + centerFrequency,
+                        1,
+                        centerFrequency,
+                        100,
+                        "feedback",
+                        words)
+                .markSubmitted(unknownWordIds);
+    }
+
+    private VocabularySurveyLevelWord word(int id, int frequency) {
+        return new VocabularySurveyLevelWord(id, "word" + id, frequency, false);
+    }
+}

+ 220 - 0
abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyLevelPlannerTest.java

@@ -0,0 +1,220 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VocabularySurveyLevelPlannerTest {
+
+    private final FakeVocabularyWordProvider provider = new FakeVocabularyWordProvider(3000);
+    private final VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner();
+
+    @Test
+    void firstLevelForGradeSevenStartsAtQuarterOfGradeMaxAndReturnsTwelveUniqueWords() {
+        VocabularySurveyLevel level = planner.planFirstLevel("lvl_1", 7, provider, Set.of());
+
+        assertThat(level.centerFrequency()).isEqualTo(400);
+        assertThat(level.wordWidth()).isEqualTo(1600);
+        assertThat(level.words()).hasSize(12);
+        assertThat(level.words()).extracting(VocabularySurveyLevelWord::wordId).doesNotHaveDuplicates();
+    }
+
+    @Test
+    void nextLevelDoesNotMoveHarderWhenEasyWordsAreUnknownButHarderWordsAreKnown() {
+        VocabularySurveyLevel submitted = submittedLevel(
+                400,
+                1600,
+                List.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200),
+                Set.of(1, 110, 219));
+
+        VocabularySurveyLevel next = planner.planNextLevel(
+                "lvl_2",
+                submitted,
+                List.of(submitted),
+                2000,
+                provider,
+                wordIds(submitted));
+
+        assertThat(next.centerFrequency()).isLessThanOrEqualTo(submitted.centerFrequency());
+        assertThat(next.feedback()).contains("不稳定");
+    }
+
+    @Test
+    void nextLevelUsesCurrentLevelInversionInsteadOfPunishingHistoricalInversionForever() {
+        VocabularySurveyLevel suspiciousFirst = submittedLevel(
+                "lvl_1",
+                1,
+                400,
+                1600,
+                List.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200),
+                Set.of(1, 110, 219));
+        VocabularySurveyLevel stableCurrent = submittedLevel(
+                "lvl_2",
+                2,
+                400,
+                1200,
+                List.of(100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200),
+                Set.of(1000, 1100, 1200));
+
+        VocabularySurveyLevel next = planner.planNextLevel(
+                "lvl_3",
+                stableCurrent,
+                List.of(suspiciousFirst, stableCurrent),
+                2000,
+                provider,
+                wordIds(suspiciousFirst, stableCurrent));
+
+        assertThat(next.centerFrequency()).isGreaterThan(stableCurrent.centerFrequency());
+        assertThat(next.feedback()).doesNotContain("不稳定");
+    }
+
+    @Test
+    void nextLevelMovesAroundEstimatedBoundaryWhenHardestWordsAreUnknown() {
+        VocabularySurveyLevel submitted = submittedLevel(
+                400,
+                1600,
+                List.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200),
+                Set.of(982, 1091, 1200));
+
+        VocabularySurveyLevel next = planner.planNextLevel(
+                "lvl_2",
+                submitted,
+                List.of(submitted),
+                2000,
+                provider,
+                wordIds(submitted));
+
+        assertThat(next.centerFrequency()).isBetween(800, 900);
+        assertThat(next.feedback()).contains("挑战");
+    }
+
+    @Test
+    void nextLevelMovesHarderTowardObservedBoundaryWhenAllWordsAreKnown() {
+        VocabularySurveyLevel submitted = submittedLevel(
+                400,
+                1600,
+                List.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200),
+                Set.of());
+
+        VocabularySurveyLevel next = planner.planNextLevel(
+                "lvl_2",
+                submitted,
+                List.of(submitted),
+                2000,
+                provider,
+                wordIds(submitted));
+
+        assertThat(next.centerFrequency()).isGreaterThanOrEqualTo(1000);
+        assertThat(next.feedback()).contains("挑战");
+    }
+
+    @Test
+    void nextLevelMovesEasierWhenAtLeastNineWordsAreUnknown() {
+        VocabularySurveyLevel submitted = submittedLevel(
+                400,
+                1600,
+                List.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200),
+                Set.of(1, 110, 219, 328, 437, 546, 655, 764, 873, 982, 1091, 1200));
+
+        VocabularySurveyLevel next = planner.planNextLevel("lvl_2", submitted, List.of(submitted), 2000, provider, wordIds(submitted));
+
+        assertThat(next.centerFrequency()).isLessThan(400);
+    }
+
+    @Test
+    void nextLevelKeepsCenterWhenEstimatedBoundaryMatchesCurrentCenter() {
+        VocabularySurveyLevel submitted = submittedLevel(
+                400,
+                1600,
+                List.of(40, 80, 120, 160, 200, 240, 280, 320, 360, 400, 500, 600),
+                Set.of(500, 600));
+
+        assertThat(planner.planNextLevel("lvl_2", submitted, List.of(submitted), 2000, provider, wordIds(submitted)).centerFrequency())
+                .isEqualTo(400);
+    }
+
+    private Set<Integer> wordIds(VocabularySurveyLevel level) {
+        return level.words().stream()
+                .map(VocabularySurveyLevelWord::wordId)
+                .collect(java.util.stream.Collectors.toSet());
+    }
+
+    private Set<Integer> wordIds(VocabularySurveyLevel... levels) {
+        return java.util.Arrays.stream(levels)
+                .flatMap(level -> level.words().stream())
+                .map(VocabularySurveyLevelWord::wordId)
+                .collect(java.util.stream.Collectors.toSet());
+    }
+
+    private VocabularySurveyLevel submittedLevel(
+            int centerFrequency,
+            int wordWidth,
+            List<Integer> frequencies,
+            Set<Integer> unknownFrequencies) {
+        List<VocabularySurveyLevelWord> words = frequencies.stream()
+                .map(frequency -> new VocabularySurveyLevelWord(100000 + frequency, "word" + frequency, frequency, false))
+                .toList();
+        Set<Integer> unknownWordIds = unknownFrequencies.stream()
+                .map(frequency -> 100000 + frequency)
+                .collect(java.util.stream.Collectors.toSet());
+        return VocabularySurveyLevel.unsubmitted("lvl_1", 1, centerFrequency, wordWidth, "feedback", words)
+                .markSubmitted(unknownWordIds);
+    }
+
+    private VocabularySurveyLevel submittedLevel(
+            String levelId,
+            int levelNo,
+            int centerFrequency,
+            int wordWidth,
+            List<Integer> frequencies,
+            Set<Integer> unknownFrequencies) {
+        List<VocabularySurveyLevelWord> words = frequencies.stream()
+                .map(frequency -> new VocabularySurveyLevelWord(100000 + frequency, "word" + frequency, frequency, false))
+                .toList();
+        Set<Integer> unknownWordIds = unknownFrequencies.stream()
+                .map(frequency -> 100000 + frequency)
+                .collect(java.util.stream.Collectors.toSet());
+        return VocabularySurveyLevel.unsubmitted(levelId, levelNo, centerFrequency, wordWidth, "feedback", words)
+                .markSubmitted(unknownWordIds);
+    }
+
+    private static final class FakeVocabularyWordProvider implements VocabularyWordProvider {
+
+        private final List<VocabularyWord> words;
+
+        private FakeVocabularyWordProvider(int maxFrequency) {
+            this.words = IntStream.rangeClosed(1, maxFrequency)
+                    .mapToObj(frequency -> new VocabularyWord(100000 + frequency, "word" + frequency, frequency))
+                    .toList();
+        }
+
+        @Override
+        public List<VocabularyWord> findWordsAroundFrequency(
+                int centerFrequency,
+                int width,
+                int count,
+                Set<Integer> excludedWordIds) {
+            return words.stream()
+                    .filter(word -> !excludedWordIds.contains(word.wordId()))
+                    .sorted(Comparator.comparingInt(word -> Math.abs(word.wordFrequency() - centerFrequency)))
+                    .limit(count)
+                    .toList();
+        }
+
+        @Override
+        public Optional<VocabularyWord> findByWordId(int wordId) {
+            return words.stream().filter(word -> word.wordId() == wordId).findFirst();
+        }
+
+        @Override
+        public int maxWordFrequency() {
+            return words.get(words.size() - 1).wordFrequency();
+        }
+    }
+}

+ 57 - 0
abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveyReportGeneratorTest.java

@@ -0,0 +1,57 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VocabularySurveyReportGeneratorTest {
+
+    private final VocabularySurveyReportGenerator generator =
+            new VocabularySurveyReportGenerator(new VocabularySurveyKEstimator());
+
+    @Test
+    void usesKAsVocabularySizeAndMapsStableGradeAbility() {
+        VocabularySurveyReport report = generator.generate(7, 1600, 2000, List.of(submittedLevel(
+                1600,
+                List.of(word(1, 1500), word(2, 1600), word(3, 1700), word(4, 1800)),
+                Set.of(3, 4))));
+
+        assertThat(report.k()).isEqualTo(1600);
+        assertThat(report.vocabularySize()).isEqualTo(report.k());
+        assertThat(report.abilityLevel()).isEqualTo("年级稳定");
+        assertThat(report.testedWordCount()).isEqualTo(4);
+        assertThat(report.unknownWordCount()).isEqualTo(2);
+    }
+
+    @Test
+    void boundsLearningRangeByDictionaryChallengeMaxFrequency() {
+        VocabularySurveyReport report = generator.generate(7, 1600, 1650, List.of(submittedLevel(
+                1600,
+                List.of(word(1, 1500), word(2, 1600), word(3, 1700)),
+                Set.of(3))));
+
+        assertThat(report.learningRange().min()).isGreaterThanOrEqualTo(1);
+        assertThat(report.learningRange().max()).isLessThanOrEqualTo(1650);
+    }
+
+    private VocabularySurveyLevel submittedLevel(
+            int centerFrequency,
+            List<VocabularySurveyLevelWord> words,
+            Set<Integer> unknownWordIds) {
+        return VocabularySurveyLevel.unsubmitted(
+                        "lvl_" + centerFrequency,
+                        1,
+                        centerFrequency,
+                        100,
+                        "feedback",
+                        words)
+                .markSubmitted(unknownWordIds);
+    }
+
+    private VocabularySurveyLevelWord word(int id, int frequency) {
+        return new VocabularySurveyLevelWord(id, "word" + id, frequency, false);
+    }
+}

+ 212 - 0
abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/VocabularySurveySessionTest.java

@@ -0,0 +1,212 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class VocabularySurveySessionTest {
+
+    private final FakeVocabularyWordProvider provider = new FakeVocabularyWordProvider(3000);
+    private final VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner();
+    private final VocabularySurveyReportGenerator reportGenerator =
+            new VocabularySurveyReportGenerator(new VocabularySurveyKEstimator());
+    private final Instant now = Instant.parse("2026-05-25T00:00:00Z");
+
+    @Test
+    void rejectsUnknownWordIdsOutsideCurrentLevel() {
+        VocabularySurveySession session = startSession();
+
+        assertThatThrownBy(() -> session.submitCurrentLevel(Set.of(999999), planner, reportGenerator, now.plusSeconds(1)))
+                .isInstanceOf(BusinessException.class)
+                .extracting("errorCode")
+                .isEqualTo(ErrorCode.INVALID_SURVEY_WORD);
+    }
+
+    @Test
+    void repeatedSubmitOfSubmittedLevelReturnsCurrentStateWithoutAdvancingAgain() {
+        VocabularySurveySession session = startSession();
+        VocabularySurveyLevel firstLevel = session.currentLevel();
+        int firstWordId = firstLevel.words().get(0).wordId();
+
+        session.submitLevel(firstLevel.levelId(), Set.of(firstWordId), planner, reportGenerator, now.plusSeconds(1));
+        int afterFirstSubmitLevelNo = session.currentLevel().levelNo();
+
+        session.submitLevel(firstLevel.levelId(), Set.of(), planner, reportGenerator, now.plusSeconds(2));
+
+        assertThat(session.currentLevel().levelNo()).isEqualTo(afterFirstSubmitLevelNo);
+        assertThat(session.completedLevelCount()).isEqualTo(1);
+        assertThat(session.levels()).hasSize(2);
+    }
+
+    @Test
+    void rejectsSubmitOfAlreadySubmittedLevelAfterSessionFinished() {
+        VocabularySurveySession session = startSession();
+        String firstLevelId = session.currentLevel().levelId();
+        for (int level = 0; level < 4; level++) {
+            VocabularySurveyLevel currentLevel = session.currentLevel();
+            Set<Integer> unknownWordIds = currentLevel.words().stream()
+                    .limit(4)
+                    .map(VocabularySurveyLevelWord::wordId)
+                    .collect(Collectors.toSet());
+            session.submitLevel(
+                    currentLevel.levelId(),
+                    unknownWordIds,
+                    planner,
+                    reportGenerator,
+                    now.plusSeconds(level + 1));
+        }
+
+        assertThat(session.status()).isEqualTo(VocabularySurveyStatus.FINISHED);
+        assertThatThrownBy(() -> session.submitLevel(
+                firstLevelId,
+                Set.of(),
+                planner,
+                reportGenerator,
+                now.plusSeconds(10)))
+                .isInstanceOf(BusinessException.class)
+                .extracting("errorCode")
+                .isEqualTo(ErrorCode.SURVEY_SESSION_FINISHED);
+    }
+
+    @Test
+    void suspiciousEasyUnknownsDoNotAdvanceSessionToHarderLevel() {
+        VocabularySurveySession session = startSession();
+        VocabularySurveyLevel firstLevel = session.currentLevel();
+        List<VocabularySurveyLevelWord> sortedWords = firstLevel.words().stream()
+                .sorted(Comparator.comparingInt(VocabularySurveyLevelWord::wordFrequency))
+                .toList();
+        Set<Integer> easyUnknownWordIds = sortedWords.stream()
+                .limit(3)
+                .map(VocabularySurveyLevelWord::wordId)
+                .collect(Collectors.toSet());
+
+        session.submitLevel(firstLevel.levelId(), easyUnknownWordIds, planner, reportGenerator, now.plusSeconds(1));
+
+        assertThat(session.currentLevel().centerFrequency()).isLessThanOrEqualTo(firstLevel.centerFrequency());
+        assertThat(session.currentLevel().feedback()).contains("不稳定");
+    }
+
+    @Test
+    void stableHardestUnknownsAdvanceSessionAroundEstimatedBoundary() {
+        VocabularySurveySession session = startSession();
+        VocabularySurveyLevel firstLevel = session.currentLevel();
+        List<VocabularySurveyLevelWord> sortedWords = firstLevel.words().stream()
+                .sorted(Comparator.comparingInt(VocabularySurveyLevelWord::wordFrequency))
+                .toList();
+        Set<Integer> hardestUnknownWordIds = sortedWords.stream()
+                .skip(9)
+                .map(VocabularySurveyLevelWord::wordId)
+                .collect(Collectors.toSet());
+        int hardestKnownFrequency = sortedWords.get(8).wordFrequency();
+
+        session.submitLevel(firstLevel.levelId(), hardestUnknownWordIds, planner, reportGenerator, now.plusSeconds(1));
+
+        assertThat(session.currentLevel().centerFrequency()).isEqualTo(hardestKnownFrequency);
+        assertThat(session.currentLevel().feedback()).contains("挑战");
+    }
+
+    @Test
+    void concurrentRepeatedSubmitsDoNotAdvanceTheSameLevelMoreThanOnce() throws Exception {
+        for (int attempt = 0; attempt < 50; attempt++) {
+            VocabularySurveySession session = startSession();
+            String levelId = session.currentLevel().levelId();
+            ExecutorService executor = Executors.newFixedThreadPool(12);
+            CountDownLatch ready = new CountDownLatch(12);
+            CountDownLatch start = new CountDownLatch(1);
+            List<Future<Object>> futures = IntStream.range(0, 12)
+                    .mapToObj(index -> executor.submit(() -> {
+                        ready.countDown();
+                        start.await(1, TimeUnit.SECONDS);
+                        session.submitLevel(levelId, Set.of(), planner, reportGenerator, now.plusSeconds(1));
+                        return null;
+                    }))
+                    .toList();
+
+            assertThat(ready.await(1, TimeUnit.SECONDS)).isTrue();
+            start.countDown();
+            for (Future<Object> future : futures) {
+                future.get(1, TimeUnit.SECONDS);
+            }
+            executor.shutdownNow();
+
+            assertThat(session.completedLevelCount()).isEqualTo(1);
+            assertThat(session.levels()).hasSize(2);
+        }
+    }
+
+    @Test
+    void expiresSevenDaysAfterCreationAndCanBeMarkedExpired() {
+        VocabularySurveySession session = startSession();
+
+        assertThat(session.createdAt()).isEqualTo(now);
+        assertThat(session.updatedAt()).isEqualTo(now);
+        assertThat(session.expiresAt()).isEqualTo(now.plus(Duration.ofDays(7)));
+        assertThat(session.isExpired(now.plus(Duration.ofDays(7)).minusMillis(1))).isFalse();
+        assertThat(session.isExpired(now.plus(Duration.ofDays(7)))).isTrue();
+
+        session.markExpired(now.plus(Duration.ofDays(7)));
+
+        assertThat(session.status()).isEqualTo(VocabularySurveyStatus.EXPIRED);
+    }
+
+    private VocabularySurveySession startSession() {
+        return VocabularySurveySession.start(
+                "surv_1",
+                7,
+                provider,
+                planner,
+                now,
+                Duration.ofDays(7));
+    }
+
+    private static final class FakeVocabularyWordProvider implements VocabularyWordProvider {
+
+        private final List<VocabularyWord> words;
+
+        private FakeVocabularyWordProvider(int maxFrequency) {
+            this.words = IntStream.rangeClosed(1, maxFrequency)
+                    .mapToObj(frequency -> new VocabularyWord(100000 + frequency, "word" + frequency, frequency))
+                    .toList();
+        }
+
+        @Override
+        public List<VocabularyWord> findWordsAroundFrequency(
+                int centerFrequency,
+                int width,
+                int count,
+                Set<Integer> excludedWordIds) {
+            return words.stream()
+                    .filter(word -> !excludedWordIds.contains(word.wordId()))
+                    .sorted(Comparator.comparingInt(word -> Math.abs(word.wordFrequency() - centerFrequency)))
+                    .limit(count)
+                    .toList();
+        }
+
+        @Override
+        public Optional<VocabularyWord> findByWordId(int wordId) {
+            return words.stream().filter(word -> word.wordId() == wordId).findFirst();
+        }
+
+        @Override
+        public int maxWordFrequency() {
+            return words.get(words.size() - 1).wordFrequency();
+        }
+    }
+}

+ 59 - 0
abilities/vocabulary-survey/infrastructure/pom.xml

@@ -0,0 +1,59 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>dcjxb.microservice</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>vocabulary-survey-infrastructure</artifactId>
+    <name>vocabulary-survey-infrastructure</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-domain</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-context</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-autoconfigure</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.msgpack</groupId>
+            <artifactId>jackson-dataformat-msgpack</artifactId>
+            <version>0.9.8</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 8 - 0
abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryExchange.java

@@ -0,0 +1,8 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+public class DictionaryExchange {
+
+    public int wordId;
+    public String spell;
+    public String property;
+}

+ 8 - 0
abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryMeaning.java

@@ -0,0 +1,8 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+public class DictionaryMeaning {
+
+    public int id;
+    public int wordId;
+    public String meaning;
+}

+ 11 - 0
abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryObject.java

@@ -0,0 +1,11 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import java.util.List;
+
+public class DictionaryObject {
+
+    public List<DictionaryWord> words = List.of();
+    public List<DictionaryExchange> exchanges = List.of();
+    public List<DictionaryMeaning> meanings = List.of();
+    public List<DictionaryPhrase> phrases = List.of();
+}

+ 10 - 0
abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryPhrase.java

@@ -0,0 +1,10 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+public class DictionaryPhrase {
+
+    public int id;
+    public int meaningId;
+    public String english;
+    public String chinese;
+    public String pronunciation;
+}

+ 10 - 0
abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/DictionaryWord.java

@@ -0,0 +1,10 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+public class DictionaryWord {
+
+    public int wordId;
+    public String spell;
+    public int wordFrequency;
+    public String britishPronunciation;
+    public String americanPronunciation;
+}

+ 22 - 0
abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/MessagePackDictionaryLoader.java

@@ -0,0 +1,22 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.msgpack.jackson.dataformat.MessagePackFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public class MessagePackDictionaryLoader {
+
+    private final ObjectMapper mapper;
+
+    public MessagePackDictionaryLoader() {
+        this.mapper = new ObjectMapper(new MessagePackFactory())
+                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+    }
+
+    public DictionaryObject load(InputStream inputStream) throws IOException {
+        return mapper.readValue(inputStream, DictionaryObject.class);
+    }
+}

+ 173 - 0
abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/MessagePackVocabularyWordProvider.java

@@ -0,0 +1,173 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.Resource;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+
+public class MessagePackVocabularyWordProvider implements VocabularyWordProvider {
+
+    private final Map<Integer, VocabularyWord> byWordId;
+    private final NavigableMap<Integer, List<VocabularyWord>> byFrequency;
+    private final List<VocabularyWord> sortedByFrequency;
+    private final int maxWordFrequency;
+
+    public MessagePackVocabularyWordProvider(DictionaryObject dictionaryObject) {
+        Set<Integer> wordIdsWithMeaning = new HashSet<>();
+        for (DictionaryMeaning meaning : dictionaryObject.meanings) {
+            wordIdsWithMeaning.add(meaning.wordId);
+        }
+        int dictionaryMaxWordFrequency = dictionaryObject.words.stream()
+                .mapToInt(word -> word.wordFrequency)
+                .max()
+                .orElse(0);
+        Map<Integer, VocabularyWord> mutableByWordId = new LinkedHashMap<>();
+        NavigableMap<Integer, List<VocabularyWord>> mutableByFrequency = new TreeMap<>();
+        for (DictionaryWord dictionaryWord : dictionaryObject.words) {
+            if (!wordIdsWithMeaning.contains(dictionaryWord.wordId)) {
+                continue;
+            }
+            VocabularyWord word = new VocabularyWord(
+                    dictionaryWord.wordId,
+                    dictionaryWord.spell,
+                    dictionaryWord.wordFrequency);
+            mutableByWordId.put(word.wordId(), word);
+            mutableByFrequency.computeIfAbsent(word.wordFrequency(), ignored -> new ArrayList<>()).add(word);
+        }
+        this.byWordId = Map.copyOf(mutableByWordId);
+        this.byFrequency = new TreeMap<>();
+        mutableByFrequency.forEach((frequency, words) -> this.byFrequency.put(frequency, List.copyOf(words)));
+        this.sortedByFrequency = this.byFrequency.values().stream()
+                .flatMap(List::stream)
+                .toList();
+        this.maxWordFrequency = dictionaryMaxWordFrequency;
+    }
+
+    public static MessagePackVocabularyWordProvider fromClasspath(String classpathLocation) {
+        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
+        try (InputStream inputStream = classLoader.getResourceAsStream(classpathLocation)) {
+            if (inputStream == null) {
+                throw new IllegalArgumentException("Dictionary resource not found: " + classpathLocation);
+            }
+            return fromInputStream(inputStream);
+        } catch (IOException ex) {
+            throw new IllegalStateException("Failed to load dictionary resource: " + classpathLocation, ex);
+        }
+    }
+
+    public static MessagePackVocabularyWordProvider fromLocation(String location) {
+        Resource resource = new DefaultResourceLoader().getResource(location);
+        try (InputStream inputStream = resource.getInputStream()) {
+            return fromInputStream(inputStream);
+        } catch (IOException ex) {
+            throw new IllegalStateException("Failed to load dictionary resource: " + location, ex);
+        }
+    }
+
+    public static MessagePackVocabularyWordProvider fromInputStream(InputStream inputStream) throws IOException {
+        DictionaryObject dictionaryObject = new MessagePackDictionaryLoader().load(inputStream);
+        return new MessagePackVocabularyWordProvider(dictionaryObject);
+    }
+
+    @Override
+    public List<VocabularyWord> findWordsAroundFrequency(
+            int centerFrequency,
+            int width,
+            int count,
+            Set<Integer> excludedWordIds) {
+        if (count <= 0) {
+            return List.of();
+        }
+        Set<Integer> excluded = new HashSet<>(Objects.requireNonNull(excludedWordIds, "excludedWordIds"));
+        Map<Integer, VocabularyWord> selected = new LinkedHashMap<>();
+        int left = Math.max(1, centerFrequency - width / 2);
+        int right = Math.max(left, centerFrequency + width / 2);
+        for (int i = 0; i < count; i++) {
+            int sampleFrequency = count == 1
+                    ? centerFrequency
+                    : left + (int) Math.round((right - left) * (double) i / (count - 1));
+            nearestAvailable(sampleFrequency, excluded, selected.keySet())
+                    .ifPresent(word -> selected.put(word.wordId(), word));
+        }
+        if (selected.size() < count) {
+            sortedByFrequency.stream()
+                    .filter(word -> !excluded.contains(word.wordId()))
+                    .filter(word -> !selected.containsKey(word.wordId()))
+                    .sorted(Comparator.comparingInt(word -> Math.abs(word.wordFrequency() - centerFrequency)))
+                    .limit(count - selected.size())
+                    .forEach(word -> selected.put(word.wordId(), word));
+        }
+        if (selected.size() != count) {
+            throw new BusinessException(ErrorCode.SURVEY_WORD_GENERATION_FAILED);
+        }
+        return new ArrayList<>(selected.values());
+    }
+
+    @Override
+    public Optional<VocabularyWord> findByWordId(int wordId) {
+        return Optional.ofNullable(byWordId.get(wordId));
+    }
+
+    @Override
+    public int maxWordFrequency() {
+        return maxWordFrequency;
+    }
+
+    private Optional<VocabularyWord> nearestAvailable(
+            int targetFrequency,
+            Set<Integer> excludedWordIds,
+            Set<Integer> selectedWordIds) {
+        Map.Entry<Integer, List<VocabularyWord>> lower = byFrequency.floorEntry(targetFrequency);
+        Map.Entry<Integer, List<VocabularyWord>> higher = byFrequency.ceilingEntry(targetFrequency);
+        while (lower != null || higher != null) {
+            if (lower != null && higher != null && lower.getKey().equals(higher.getKey())) {
+                Optional<VocabularyWord> candidate = firstAvailable(lower.getValue(), excludedWordIds, selectedWordIds);
+                if (candidate.isPresent()) {
+                    return candidate;
+                }
+                lower = byFrequency.lowerEntry(lower.getKey());
+                higher = byFrequency.higherEntry(higher.getKey());
+                continue;
+            }
+            boolean useLower = lower != null && (higher == null
+                    || Math.abs(lower.getKey() - targetFrequency) <= Math.abs(higher.getKey() - targetFrequency));
+            Map.Entry<Integer, List<VocabularyWord>> candidateEntry = useLower ? lower : higher;
+            Optional<VocabularyWord> candidate = firstAvailable(candidateEntry.getValue(), excludedWordIds, selectedWordIds);
+            if (candidate.isPresent()) {
+                return candidate;
+            }
+            if (useLower) {
+                lower = byFrequency.lowerEntry(lower.getKey());
+            } else {
+                higher = byFrequency.higherEntry(higher.getKey());
+            }
+        }
+        return Optional.empty();
+    }
+
+    private Optional<VocabularyWord> firstAvailable(
+            List<VocabularyWord> words,
+            Set<Integer> excludedWordIds,
+            Set<Integer> selectedWordIds) {
+        return words.stream()
+                .filter(word -> !excludedWordIds.contains(word.wordId()))
+                .filter(word -> !selectedWordIds.contains(word.wordId()))
+                .findFirst();
+    }
+}

+ 29 - 0
abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/repository/InMemoryVocabularySurveySessionRepository.java

@@ -0,0 +1,29 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.repository;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+public class InMemoryVocabularySurveySessionRepository implements VocabularySurveySessionRepository {
+
+    private final ConcurrentMap<String, VocabularySurveySession> sessions = new ConcurrentHashMap<>();
+
+    @Override
+    public Optional<VocabularySurveySession> findById(String sessionId) {
+        return Optional.ofNullable(sessions.get(sessionId));
+    }
+
+    @Override
+    public void save(VocabularySurveySession session) {
+        sessions.put(session.sessionId(), session);
+    }
+
+    @Override
+    public void deleteExpired(Instant now) {
+        sessions.values().removeIf(session -> session.isExpired(now));
+    }
+}

BIN
abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data


+ 59 - 0
abilities/vocabulary-survey/infrastructure/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/dictionary/MessagePackVocabularyWordProviderTest.java

@@ -0,0 +1,59 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MessagePackVocabularyWordProviderTest {
+
+    @Test
+    void loadsRealDictionaryAndBuildsFrequencyIndex() {
+        MessagePackVocabularyWordProvider provider = MessagePackVocabularyWordProvider.fromClasspath("data/dict.data");
+
+        assertThat(provider.maxWordFrequency()).isEqualTo(11993);
+        assertThat(provider.findWordsAroundFrequency(400, 100, 12, Set.of())).hasSize(12);
+        assertThat(provider.findByWordId(100000)).get().extracting(VocabularyWord::spell).isEqualTo("a");
+        assertThat(provider.findWordsAroundFrequency(11000, 2000, 100, Set.of()))
+                .extracting(VocabularyWord::spell)
+                .doesNotContain("url", "napa", "bio", "atm");
+    }
+
+    @Test
+    void keepsAllWordsWhenMultipleWordsShareTheSameFrequency() {
+        DictionaryObject dictionaryObject = new DictionaryObject();
+        dictionaryObject.words = List.of(
+                word(1, "alpha", 100),
+                word(2, "beta", 100),
+                word(3, "gamma", 200));
+        dictionaryObject.meanings = List.of(meaning(1), meaning(2), meaning(3));
+        MessagePackVocabularyWordProvider provider = new MessagePackVocabularyWordProvider(dictionaryObject);
+
+        assertThat(provider.findWordsAroundFrequency(100, 10, 2, Set.of()))
+                .extracting(VocabularyWord::wordId)
+                .containsExactlyInAnyOrder(1, 2);
+        assertThat(provider.findWordsAroundFrequency(100, 10, 1, Set.of(1)))
+                .singleElement()
+                .extracting(VocabularyWord::wordId)
+                .isEqualTo(2);
+    }
+
+    private DictionaryWord word(int wordId, String spell, int frequency) {
+        DictionaryWord word = new DictionaryWord();
+        word.wordId = wordId;
+        word.spell = spell;
+        word.wordFrequency = frequency;
+        return word;
+    }
+
+    private DictionaryMeaning meaning(int wordId) {
+        DictionaryMeaning meaning = new DictionaryMeaning();
+        meaning.id = wordId;
+        meaning.wordId = wordId;
+        meaning.meaning = "meaning" + wordId;
+        return meaning;
+    }
+}

+ 85 - 0
abilities/vocabulary-survey/infrastructure/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/repository/InMemoryVocabularySurveySessionRepositoryTest.java

@@ -0,0 +1,85 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.repository;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevelPlanner;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InMemoryVocabularySurveySessionRepositoryTest {
+
+    private final FakeVocabularyWordProvider provider = new FakeVocabularyWordProvider(3000);
+    private final VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner();
+
+    @Test
+    void savesAndFindsSessionById() {
+        InMemoryVocabularySurveySessionRepository repository = new InMemoryVocabularySurveySessionRepository();
+        VocabularySurveySession session = session("surv_1", Instant.parse("2026-05-25T00:00:00Z"));
+
+        repository.save(session);
+
+        assertThat(repository.findById("surv_1")).contains(session);
+    }
+
+    @Test
+    void deletesExpiredSessions() {
+        InMemoryVocabularySurveySessionRepository repository = new InMemoryVocabularySurveySessionRepository();
+        VocabularySurveySession expiredSession = session("surv_expired", Instant.parse("2026-05-20T00:00:00Z"));
+        VocabularySurveySession activeSession = session("surv_active", Instant.parse("2026-05-31T00:00:00Z"));
+        repository.save(expiredSession);
+        repository.save(activeSession);
+
+        repository.deleteExpired(Instant.parse("2026-06-01T00:00:00Z"));
+
+        assertThat(repository.findById(expiredSession.sessionId())).isEmpty();
+        assertThat(repository.findById(activeSession.sessionId())).contains(activeSession);
+    }
+
+    private VocabularySurveySession session(String sessionId, Instant createdAt) {
+        return VocabularySurveySession.start(sessionId, 7, provider, planner, createdAt, Duration.ofDays(7));
+    }
+
+    private static final class FakeVocabularyWordProvider implements VocabularyWordProvider {
+
+        private final List<VocabularyWord> words;
+
+        private FakeVocabularyWordProvider(int maxFrequency) {
+            this.words = IntStream.rangeClosed(1, maxFrequency)
+                    .mapToObj(frequency -> new VocabularyWord(100000 + frequency, "word" + frequency, frequency))
+                    .toList();
+        }
+
+        @Override
+        public List<VocabularyWord> findWordsAroundFrequency(
+                int centerFrequency,
+                int width,
+                int count,
+                Set<Integer> excludedWordIds) {
+            return words.stream()
+                    .filter(word -> !excludedWordIds.contains(word.wordId()))
+                    .sorted(Comparator.comparingInt(word -> Math.abs(word.wordFrequency() - centerFrequency)))
+                    .limit(count)
+                    .toList();
+        }
+
+        @Override
+        public Optional<VocabularyWord> findByWordId(int wordId) {
+            return words.stream().filter(word -> word.wordId() == wordId).findFirst();
+        }
+
+        @Override
+        public int maxWordFrequency() {
+            return words.get(words.size() - 1).wordFrequency();
+        }
+    }
+}

+ 9 - 0
ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java

@@ -7,6 +7,15 @@ public enum ErrorCode {
             "EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE",
             "exam sprint report download unavailable",
             500),
+    INVALID_GRADE("INVALID_GRADE", "invalid grade", 400),
+    SURVEY_SESSION_NOT_FOUND("SURVEY_SESSION_NOT_FOUND", "survey session not found", 404),
+    SURVEY_SESSION_EXPIRED("SURVEY_SESSION_EXPIRED", "survey session expired", 410),
+    SURVEY_SESSION_FINISHED("SURVEY_SESSION_FINISHED", "survey session finished", 409),
+    SURVEY_LEVEL_NOT_FOUND("SURVEY_LEVEL_NOT_FOUND", "survey level not found", 404),
+    SURVEY_LEVEL_NOT_CURRENT("SURVEY_LEVEL_NOT_CURRENT", "survey level not current", 409),
+    INVALID_SURVEY_WORD("INVALID_SURVEY_WORD", "invalid survey word", 400),
+    SURVEY_WORD_GENERATION_FAILED("SURVEY_WORD_GENERATION_FAILED", "survey word generation failed", 500),
+    SURVEY_REPORT_GENERATION_FAILED("SURVEY_REPORT_GENERATION_FAILED", "survey report generation failed", 500),
     VALIDATION_ERROR("VALIDATION_ERROR", "validation error", 400),
     INTERNAL_ERROR("INTERNAL_ERROR", "internal server error", 500);
 

+ 20 - 0
ability-center-runtime/pom.xml

@@ -50,6 +50,26 @@
             <artifactId>exam-sprint-infrastructure</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-contracts</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-application</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-domain</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-infrastructure</artifactId>
+            <version>${project.version}</version>
+        </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>

+ 48 - 0
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/adapter/http/VocabularySurveyController.java

@@ -0,0 +1,48 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.adapter.http;
+
+import cn.yunzhixue.ability.center.kernel.BaseResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.StartVocabularySurveyCommand;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.SubmitVocabularySurveyLevelCommand;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyApplicationService;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.StartVocabularySurveyRequest;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelRequest;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/vocabulary-survey")
+public class VocabularySurveyController {
+
+    private final VocabularySurveyApplicationService applicationService;
+
+    public VocabularySurveyController(VocabularySurveyApplicationService applicationService) {
+        this.applicationService = applicationService;
+    }
+
+    @PostMapping("/start")
+    public BaseResponse<VocabularySurveySessionResponse> start(
+            @Valid @RequestBody StartVocabularySurveyRequest request) {
+        return BaseResponse.success(applicationService.start(new StartVocabularySurveyCommand(request.grade())));
+    }
+
+    @PostMapping("/sessions/{sessionId}/levels/{levelId}/submit")
+    public BaseResponse<SubmitVocabularySurveyLevelResponse> submit(
+            @PathVariable String sessionId,
+            @PathVariable String levelId,
+            @Valid @RequestBody SubmitVocabularySurveyLevelRequest request) {
+        return BaseResponse.success(applicationService.submit(
+                new SubmitVocabularySurveyLevelCommand(sessionId, levelId, request.unknownWordIds())));
+    }
+
+    @GetMapping("/sessions/{sessionId}")
+    public BaseResponse<VocabularySurveySessionResponse> getSession(@PathVariable String sessionId) {
+        return BaseResponse.success(applicationService.getSession(sessionId));
+    }
+}

+ 71 - 0
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/configuration/VocabularySurveyRuntimeConfiguration.java

@@ -0,0 +1,71 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.configuration;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.application.DefaultVocabularySurveyApplicationService;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyApplicationService;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyProperties;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyKEstimator;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevelPlanner;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReportGenerator;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+import cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary.MessagePackVocabularyWordProvider;
+import cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.repository.InMemoryVocabularySurveySessionRepository;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Clock;
+import java.time.Duration;
+
+@Configuration
+@EnableConfigurationProperties(VocabularySurveyProperties.class)
+public class VocabularySurveyRuntimeConfiguration {
+
+    @Bean
+    @ConditionalOnMissingBean(Clock.class)
+    public Clock vocabularySurveyClock() {
+        return Clock.systemUTC();
+    }
+
+    @Bean
+    public MessagePackVocabularyWordProvider vocabularySurveyWordProvider(VocabularySurveyProperties properties) {
+        return MessagePackVocabularyWordProvider.fromLocation(properties.getDictionary().getLocation());
+    }
+
+    @Bean
+    public InMemoryVocabularySurveySessionRepository vocabularySurveySessionRepository() {
+        return new InMemoryVocabularySurveySessionRepository();
+    }
+
+    @Bean
+    public VocabularySurveyLevelPlanner vocabularySurveyLevelPlanner() {
+        return new VocabularySurveyLevelPlanner();
+    }
+
+    @Bean
+    public VocabularySurveyKEstimator vocabularySurveyKEstimator() {
+        return new VocabularySurveyKEstimator();
+    }
+
+    @Bean
+    public VocabularySurveyReportGenerator vocabularySurveyReportGenerator(VocabularySurveyKEstimator estimator) {
+        return new VocabularySurveyReportGenerator(estimator);
+    }
+
+    @Bean
+    public VocabularySurveyApplicationService vocabularySurveyApplicationService(
+            VocabularyWordProvider wordProvider,
+            VocabularySurveySessionRepository repository,
+            VocabularySurveyLevelPlanner planner,
+            VocabularySurveyReportGenerator reportGenerator,
+            Clock clock) {
+        return new DefaultVocabularySurveyApplicationService(
+                wordProvider,
+                repository,
+                planner,
+                reportGenerator,
+                clock,
+                Duration.ofDays(7));
+    }
+}

+ 3 - 0
ability-center-runtime/src/main/resources/application.yml

@@ -14,3 +14,6 @@ ability:
         endpoint:
         account-name:
         account-key:
+  vocabulary-survey:
+    dictionary:
+      location: classpath:data/dict.data

+ 34 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/VocabularySurveyArchitectureTest.java

@@ -0,0 +1,34 @@
+package cn.yunzhixue.ability.center.architecture;
+
+import com.tngtech.archunit.core.importer.ImportOption;
+import com.tngtech.archunit.junit.AnalyzeClasses;
+import com.tngtech.archunit.junit.ArchTest;
+import com.tngtech.archunit.lang.ArchRule;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+
+@AnalyzeClasses(
+        packages = "cn.yunzhixue.ability.center.vocabularysurvey",
+        importOptions = ImportOption.DoNotIncludeTests.class)
+class VocabularySurveyArchitectureTest {
+
+    @ArchTest
+    static final ArchRule domain_should_not_depend_on_contracts = noClasses()
+            .that().resideInAPackage("..domain..")
+            .should().dependOnClassesThat().resideInAPackage("..contracts..");
+
+    @ArchTest
+    static final ArchRule domain_should_not_depend_on_jackson_or_msgpack_or_spring = noClasses()
+            .that().resideInAPackage("..domain..")
+            .should().dependOnClassesThat().resideInAnyPackage(
+                    "com.fasterxml.jackson..",
+                    "org.msgpack..",
+                    "org.springframework..");
+
+    @ArchTest
+    static final ArchRule infrastructure_should_not_depend_on_runtime_adapters = noClasses()
+            .that().resideInAPackage("..infrastructure..")
+            .should().dependOnClassesThat().resideInAnyPackage(
+                    "..adapter..",
+                    "..configuration..");
+}

+ 132 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/adapter/http/VocabularySurveyControllerWebMvcTest.java

@@ -0,0 +1,132 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.adapter.http;
+
+import cn.yunzhixue.ability.center.GlobalExceptionHandler;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.StartVocabularySurveyCommand;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.SubmitVocabularySurveyLevelCommand;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyApplicationService;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyStatus;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyWordResponse;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(VocabularySurveyController.class)
+@Import({VocabularySurveyController.class, GlobalExceptionHandler.class})
+class VocabularySurveyControllerWebMvcTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @MockBean
+    private VocabularySurveyApplicationService applicationService;
+
+    @Test
+    void startCreatesSurveySessionWithoutStudentIdOrSurveyWordFields() throws Exception {
+        given(applicationService.start(new StartVocabularySurveyCommand(7)))
+                .willReturn(new VocabularySurveySessionResponse(
+                        "surv_1",
+                        VocabularySurveyStatus.inProgress,
+                        7,
+                        level("lvl_1", 1),
+                        null));
+
+        mockMvc.perform(post("/api/vocabulary-survey/start")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"grade\":7}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.sessionId").value("surv_1"))
+                .andExpect(jsonPath("$.data.level.words[0].wordId").value(101))
+                .andExpect(jsonPath("$.data.level.words[0].surveyWordId").doesNotExist())
+                .andExpect(jsonPath("$.data.level.words[0].meaningId").doesNotExist());
+    }
+
+    @Test
+    void submitUsesUnknownWordIdsAndReturnsNextSessionState() throws Exception {
+        given(applicationService.submit(new SubmitVocabularySurveyLevelCommand("surv_1", "lvl_1", List.of(101, 102))))
+                .willReturn(new SubmitVocabularySurveyLevelResponse(
+                        "surv_1",
+                        VocabularySurveyStatus.inProgress,
+                        1,
+                        level("lvl_2", 2),
+                        null));
+
+        mockMvc.perform(post("/api/vocabulary-survey/sessions/surv_1/levels/lvl_1/submit")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"unknownWordIds\":[101,102]}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.submittedLevelNo").value(1))
+                .andExpect(jsonPath("$.data.level.levelNo").value(2));
+
+        ArgumentCaptor<SubmitVocabularySurveyLevelCommand> captor = ArgumentCaptor.forClass(SubmitVocabularySurveyLevelCommand.class);
+        verify(applicationService).submit(captor.capture());
+        assertThat(captor.getValue().sessionId()).isEqualTo("surv_1");
+        assertThat(captor.getValue().levelId()).isEqualTo("lvl_1");
+        assertThat(captor.getValue().unknownWordIds()).containsExactly(101, 102);
+    }
+
+    @Test
+    void getSessionRestoresBySessionId() throws Exception {
+        given(applicationService.getSession(eq("surv_1")))
+                .willReturn(new VocabularySurveySessionResponse(
+                        "surv_1",
+                        VocabularySurveyStatus.inProgress,
+                        7,
+                        level("lvl_2", 2),
+                        null));
+
+        mockMvc.perform(get("/api/vocabulary-survey/sessions/surv_1"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.sessionId").value("surv_1"))
+                .andExpect(jsonPath("$.data.level.levelNo").value(2));
+    }
+
+    @Test
+    void submitRejectsNullUnknownWordIdAsValidationError() throws Exception {
+        mockMvc.perform(post("/api/vocabulary-survey/sessions/surv_1/levels/lvl_1/submit")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"unknownWordIds\":[null]}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
+
+        verifyNoInteractions(applicationService);
+    }
+
+    private VocabularySurveyLevelResponse level(String levelId, int levelNo) {
+        return new VocabularySurveyLevelResponse(
+                levelId,
+                levelNo,
+                1,
+                "feedback",
+                List.of(new VocabularySurveyWordResponse(101, "happy", 403)));
+    }
+
+    @SpringBootConfiguration
+    @EnableAutoConfiguration
+    static class TestApplication {
+    }
+}

+ 36 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/configuration/VocabularySurveyRuntimeConfigurationTest.java

@@ -0,0 +1,36 @@
+package cn.yunzhixue.ability.center.vocabularysurvey.configuration;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyApplicationService;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevelPlanner;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReportGenerator;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+import cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary.MessagePackVocabularyWordProvider;
+import cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.repository.InMemoryVocabularySurveySessionRepository;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import java.time.Clock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VocabularySurveyRuntimeConfigurationTest {
+
+    private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+            .withUserConfiguration(VocabularySurveyRuntimeConfiguration.class)
+            .withPropertyValues("ability.vocabulary-survey.dictionary.location=classpath:data/dict.data");
+
+    @Test
+    void wiresVocabularySurveyRuntimeBeans() {
+        contextRunner.run(context -> {
+            assertThat(context).hasSingleBean(VocabularyWordProvider.class);
+            assertThat(context).hasSingleBean(MessagePackVocabularyWordProvider.class);
+            assertThat(context).hasSingleBean(VocabularySurveySessionRepository.class);
+            assertThat(context).hasSingleBean(InMemoryVocabularySurveySessionRepository.class);
+            assertThat(context).hasSingleBean(VocabularySurveyLevelPlanner.class);
+            assertThat(context).hasSingleBean(VocabularySurveyReportGenerator.class);
+            assertThat(context).hasSingleBean(VocabularySurveyApplicationService.class);
+            assertThat(context).hasSingleBean(Clock.class);
+        });
+    }
+}

Разница между файлами не показана из-за своего большого размера
+ 376 - 2665
docs/superpowers/plans/2026-05-24-vocabulary-survey.md


+ 115 - 0
docs/superpowers/plans/2026-05-25-vocabulary-survey-miniprogram.md

@@ -0,0 +1,115 @@
+# Vocabulary Survey Mini Program 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:** Build a Taro mini-program vocabulary survey page that matches the generated 春笋英语 design and connects to the existing `/api/vocabulary-survey` backend flow.
+
+**Architecture:** Add a self-contained `src/pages/vocabulary-survey` page with a four-state flow: grade selection, question level, submitting transition, and report. Keep API DTOs in `src/types/vocabularySurvey.ts`, HTTP calls in `src/services/api/vocabularySurvey.ts`, and static visual assets under `src/assets/images/vocabulary-survey`. Reuse the project request wrapper and existing asset import style.
+
+**Tech Stack:** Taro 4.1.7, React 18, TypeScript, SCSS, WeChat Mini Program components (`View`, `Text`, `Image`).
+
+---
+
+## File Structure
+
+- Create `src/assets/images/vocabulary-survey/`
+  - Holds copied GPT Image assets for the survey page: hero bamboo shoot, loading illustration, report illustration, paper texture, bottom mountain background, decorative leaf, and selected icon.
+- Create `src/types/vocabularySurvey.ts`
+  - Defines frontend DTOs matching backend contracts: start request, submit request, level response, word response, report response, and UI grade/stage types.
+- Modify `src/types/index.ts`
+  - Exports vocabulary survey types.
+- Create `src/services/api/vocabularySurvey.ts`
+  - Wraps start, submit, and get-session backend endpoints.
+- Modify `src/services/api/index.ts`
+  - Exports vocabulary survey API functions.
+- Create `src/pages/vocabulary-survey/index.config.ts`
+  - Configures custom navigation and page title.
+- Create `src/pages/vocabulary-survey/index.tsx`
+  - Implements the four-state survey UI and backend orchestration.
+- Create `src/pages/vocabulary-survey/index.scss`
+  - Implements the generated visual style using code-driven UI and copied assets.
+- Modify `src/app.config.ts`
+  - Registers `pages/vocabulary-survey/index` without adding it to the TabBar.
+
+## Constraints
+
+- Do not modify existing landing page files except allowing navigation from that page in a later iteration; this plan only creates the survey page and route.
+- Do not overwrite existing landing assets.
+- Do not add dependencies.
+- Do not use full-page screenshots as UI backgrounds.
+- Do not show Chinese definitions, phonetics, word frequency, or examples on word cards.
+- Do not display fixed total levels such as `2/5`.
+- Do not add multiple report CTAs; report page keeps `分享报告` as the only primary action.
+- Do not commit changes unless explicitly requested.
+
+---
+
+### Task 1: Copy survey visual assets
+
+**Files:**
+- Create directory: `src/assets/images/vocabulary-survey/`
+- Copy from `/Users/exiao/Downloads`:
+  - `bamboo-shoot-hero.png`
+  - `bamboo-shoot-loading.png`
+  - `bamboo-shoot-report.png`
+  - `paper-texture.png`
+  - `mountain-bg-bottom.png`
+  - `decor-leaf.png`
+  - `icon-selected.png`
+
+- [ ] **Step 1: Create the destination directory**
+- [ ] **Step 2: Copy only the seven approved files**
+- [ ] **Step 3: Confirm the destination contains only intended survey assets**
+
+### Task 2: Add types and API wrapper
+
+**Files:**
+- Create `src/types/vocabularySurvey.ts`
+- Modify `src/types/index.ts`
+- Create `src/services/api/vocabularySurvey.ts`
+- Modify `src/services/api/index.ts`
+
+- [ ] **Step 1: Define DTOs using backend field names**
+- [ ] **Step 2: Export DTOs from `src/types/index.ts`**
+- [ ] **Step 3: Add API functions for start, submit, and restore session**
+- [ ] **Step 4: Export API functions from `src/services/api/index.ts`**
+
+### Task 3: Add page route and config
+
+**Files:**
+- Modify `src/app.config.ts`
+- Create `src/pages/vocabulary-survey/index.config.ts`
+
+- [ ] **Step 1: Register `pages/vocabulary-survey/index` in `pages`**
+- [ ] **Step 2: Keep TabBar unchanged**
+- [ ] **Step 3: Add custom navigation page config**
+
+### Task 4: Implement the survey page
+
+**Files:**
+- Create `src/pages/vocabulary-survey/index.tsx`
+- Create `src/pages/vocabulary-survey/index.scss`
+
+- [ ] **Step 1: Implement grade/stage selection with required grade before start**
+- [ ] **Step 2: Implement 12-word card selection with yellow selected state**
+- [ ] **Step 3: Implement submitting transition state**
+- [ ] **Step 4: Implement report state with share preview and single `分享报告` CTA**
+- [ ] **Step 5: Wire API calls and local `sessionId` persistence**
+
+### Task 5: Verify
+
+**Files:**
+- Verify all created/modified files.
+
+- [ ] **Step 1: Run `npm run build:weapp` from `/Users/exiao/Codes/yzx-parent-wxminiprogram`**
+- [ ] **Step 2: If build fails, report exact errors and fix vocabulary-survey-related errors only**
+- [ ] **Step 3: Run `git status --short` and summarize touched files**
+
+---
+
+## Self-Review
+
+- Spec coverage: Covers asset copying, Taro page creation, backend DTO/API integration, route registration, four UI states, no fixed total levels, no definitions/phonetics/frequency on word cards, yellow unknown-card selection, report share CTA, and build verification.
+- Placeholder scan: No TBD/TODO placeholders. Conditional failure handling is explicit.
+- Type consistency: Field names match backend contracts: `sessionId`, `status`, `grade`, `level`, `levelId`, `levelNo`, `wordCount`, `feedback`, `words`, `wordId`, `spell`, `wordFrequency`, `unknownWordIds`, `report`, `vocabularySize`, `k`, `abilityLevel`, `learningRange`.
+- Commit policy: No commit steps because user did not request commit.

+ 89 - 108
docs/superpowers/specs/2026-05-24-vocabulary-survey-design.md

@@ -1,6 +1,6 @@
 # 小程序词汇摸底后端能力设计
 
-> Status: 用户已确认设计方向,设计文档已写入,待用户审阅后进入实施计划。
+> Status: 设计已按用户采访结论修订,正在完成自审;自审后进入实施计划。
 
 ## 目标
 
@@ -12,10 +12,10 @@
 2. 后端返回第 1 关词列表。
 3. 每关展示 12 个英文单词,学生只勾选“不认识”的词。
 4. 小程序提交本关不认识词。
-5. 后端根据本关 unknownRate 调整下一关中心词频和取词宽度
+5. 后端根据所有已提交关卡估算能力边界 K,并结合当前关答题倒挂情况调整下一关中心词频;取词宽度逐关收敛
 6. 达到停止条件后生成报告,返回词汇量、K 值、能力等级和学习区间。
 
-初版运行环境假设:单机部署。session、level、word 明细使用内存保存;同一进程内支持断点恢复和重复提交幂等,服务重启后 session 丢失可接受
+初版运行环境假设:单机部署。session、level、word 明细使用内存保存;同一进程内支持断点恢复;进行中的已提交关卡支持弱网重复提交幂等。session finished 后不再接受 submit,返回 `SURVEY_SESSION_FINISHED`。服务重启后 session/report 丢失可接受;报告分享传播和持久化存储另起设计
 
 ## 当前项目上下文
 
@@ -40,7 +40,7 @@
 
 ## 词库来源
 
-词库不通过 RDS 表查询。
+词库不通过数据库表查询。
 
 真实词库来自 `Qingti.Teaching.Content.Dictionary.EnglishDictionary` 加载的 MessagePack 二进制文件,现有 .NET 业务通过 `IEnglishDictionary` 查询:
 
@@ -51,25 +51,33 @@
 本次 Java 微服务初版直接加载 MessagePack `dict.data`。用户授权从本机路径复制文件到当前项目:
 
 ```text
-源文件:F:\Codes\Projects\qingti.teaching.api\Qingti.Teaching.Dictionary\Resources\dict.data
+源文件:/Users/exiao/Codes/qingti.teaching.api/Qingti.Teaching.Dictionary/Resources/dict.data
 目标文件:abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data
 ```
 
-顶层 MessagePack 对象:
+已用真实 `dict.data` 验证顶层 MessagePack 对象结构
 
 ```text
 DictionaryObject
-├── words: DictionaryWord[]
-├── exchanges: DictionaryExchange[]
-└── meanings: DictionaryMeaning[]
+├── words: DictionaryWord[]          11992 条
+├── exchanges: DictionaryExchange[]  35980 条
+├── meanings: DictionaryMeaning[]    17082 条
+└── phrases: DictionaryPhrase[]      404461 条
 ```
 
-小程序摸底选词优先基于 `wordFrequency`,核心字段为:
+小程序摸底选词优先基于 `wordFrequency`。真实词库中 `wordFrequency` 是近似 rank/难度序号:越小越高频、越基础;越大越低频、越难。真实范围为 `1..11993`,其中 `5212` 缺失,因此实现必须支持“按词频附近查找”,不能假设每个词频值都有单词。
+
+`words` 核心字段为:
 
 - `wordId`
 - `spell`
 - `wordFrequency`
-- `meaningId`
+
+`words` 不直接包含 `meaningId`。`meaningId` 来自 `meanings`,通过 `meanings.wordId` 与 `words.wordId` 关联。一个单词可能有多个释义;初版接口不返回 `meaningId`,但加载索引时仍使用 `meanings` 过滤掉没有任何释义的 29 个词,避免返回无释义词。本轮不直接修改二进制 `dict.data`;如果后续要清理源词库文件,应作为词库发布/校验流程单独处理。
+
+词汇摸底是必需能力。`dict.data` 缺失、损坏或 MessagePack 结构无法解析时,应让 Spring context 启动失败,以便发布或运维阶段尽早暴露配置/资源问题,而不是在运行时静默降级。
+
+`phrases` 初版摸底不使用,不需要为 40 万条 phrase 建业务索引。Java loader 初版优先使用 Jackson MessagePack 完整绑定 `DictionaryObject`,以降低实现风险;即使完整绑定读取了 `phrases`,业务索引仍只建立 `words` 和 `meanings` 所需数据。若验证发现启动慢或内存占用过高,再改用 `msgpack-core` 流式/低层解析并跳过 `phrases`。
 
 实现时允许新增 MessagePack 解析依赖,优先尝试:
 
@@ -80,7 +88,7 @@ DictionaryObject
 </dependency>
 ```
 
-如果真实文件结构不适合 Jackson 绑定,再改用 `org.msgpack:msgpack-core` 做低层解析。
+如果真实文件结构不适合 Jackson 绑定,或完整绑定带来不可接受的启动耗时/内存占用,再改用 `org.msgpack:msgpack-core` 做低层解析。
 
 ## 方案选择
 
@@ -170,9 +178,7 @@ DTO 使用小程序期望的 JSON 字段:
 - `level`
 - `currentLevel`
 - `report`
-- `surveyWordId`
 - `wordId`
-- `meaningId`
 - `spell`
 - `wordFrequency`
 
@@ -203,7 +209,7 @@ domain 不依赖 contracts、Spring、Jackson、MessagePack。
 - `StartVocabularySurveyCommand`
 - `SubmitVocabularySurveyLevelCommand`
 - `VocabularySurveyResultMapper`
-- session 过期和 restart 编排
+- session 过期编排
 
 application 依赖 domain,返回 contracts DTO 或 application result。为了贴合现有项目风格,初版可由 application service 直接返回 contracts response,但 domain 仍不依赖 contracts。
 
@@ -217,15 +223,17 @@ application 依赖 domain,返回 contracts DTO 或 application result。为了
 - `DictionaryWord`
 - `DictionaryMeaning`
 - `DictionaryExchange`
+- `DictionaryPhrase`(如解析库要求完整绑定;业务上可忽略)
 - `InMemoryVocabularySurveySessionRepository`
 
 `MessagePackVocabularyWordProvider` 启动时加载 `classpath:data/dict.data`,建立索引:
 
 - `wordId -> VocabularyWord`
-- `meaningId -> VocabularyWord` 或 `meaningId -> DictionaryMeaning + word`
-- `wordFrequency -> List<VocabularyWord>`
+- `wordFrequency -> VocabularyWord`
 - `sortedByFrequency`
 
+加载索引时根据 `meanings.wordId` 过滤没有任何 meaning 的单词;初版 API 不返回 `meaningId`,也不需要建立 `meaningId -> VocabularyWord` 业务查询索引。
+
 ## 词库 port 设计
 
 业务算法不直接读取 `dict.data`,也不关心 MessagePack 结构。domain 定义一个词库查询接口,例如:
@@ -241,8 +249,6 @@ public interface VocabularyWordProvider {
 
     Optional<VocabularyWord> findByWordId(int wordId);
 
-    Optional<VocabularyWord> findByMeaningId(int meaningId);
-
     int maxWordFrequency();
 }
 ```
@@ -252,7 +258,6 @@ domain 使用的单词模型:
 ```java
 public record VocabularyWord(
         int wordId,
-        int meaningId,
         String spell,
         int wordFrequency) {
 }
@@ -267,29 +272,27 @@ public record VocabularyWord(
 
 ## HTTP 接口
 
-接口路径按需求实现,不额外加 `/api` 前缀
+接口路径统一使用 `/api/vocabulary-survey` 前缀,与现有后端 controller 风格保持一致
 
 ### 开始摸底
 
 ```text
-POST /mini-program/vocabulary-survey/start
+POST /api/vocabulary-survey/start
 ```
 
 请求:
 
 ```json
 {
-  "studentId": 123,
-  "grade": 7,
-  "restart": false
+  "grade": 7
 }
 ```
 
-`studentId` 在当前无登录态时用于幂等恢复;如果缺失,可用匿名 session 策略,但推荐小程序传入
+初版不传 `studentId`,也不提供 `restart`/`forceNew` 机制。每次开始摸底都会创建一个新的匿名 session。小程序端负责可靠保存未完成摸底的 `sessionId`;如果本地已有未完成 `sessionId`,前端不应再次调用 start,而应调用查询接口恢复当前关
 
 响应:
 
-以下响应示例中的 `surv_xxx`、`lvl_xxx`、`sw_xxx` 是示例 ID,不是未决内容;实现时使用对应前缀生成真实 ID。
+以下响应示例中的 `surv_xxx`、`lvl_xxx` 是示例 ID,不是未决内容;实现时使用对应前缀生成真实 ID。
 
 ```json
 {
@@ -303,9 +306,7 @@ POST /mini-program/vocabulary-survey/start
     "feedback": "先来热个身,选出你不认识的单词吧",
     "words": [
       {
-        "surveyWordId": "sw_xxx",
         "wordId": 101,
-        "meaningId": 2001,
         "spell": "happy",
         "wordFrequency": 403
       }
@@ -317,15 +318,14 @@ POST /mini-program/vocabulary-survey/start
 ### 提交某一关
 
 ```text
-POST /mini-program/vocabulary-survey/levels/{levelId}/submit
+POST /api/vocabulary-survey/sessions/{sessionId}/levels/{levelId}/submit
 ```
 
 请求:
 
 ```json
 {
-  "sessionId": "surv_xxx",
-  "unknownSurveyWordIds": ["sw_001", "sw_002"]
+  "unknownWordIds": [101, 102]
 }
 ```
 
@@ -373,7 +373,7 @@ POST /mini-program/vocabulary-survey/levels/{levelId}/submit
 ### 查询 session 状态
 
 ```text
-GET /mini-program/vocabulary-survey/sessions/{sessionId}
+GET /api/vocabulary-survey/sessions/{sessionId}
 ```
 
 进行中返回当前关,已完成返回报告。过期 session 初版返回业务错误码 `SURVEY_SESSION_EXPIRED`,避免前端继续提交旧 session。
@@ -402,33 +402,38 @@ GET /mini-program/vocabulary-survey/sessions/{sessionId}
 ```text
 gradeMaxFrequency = 根据年级映射得到
 challengeMaxFrequency = 下一年级上限;最高不超过词库最大 wordFrequency
-currentCenterFrequency = 0.35 * gradeMaxFrequency
+currentCenterFrequency = 0.25 * gradeMaxFrequency
 currentWordWidth = gradeMaxFrequency
 minWordWidth = 100
 ```
 
+第一关有意偏简单,降低学生挫败感;后续根据每关 `unknownCount` 自适应向更难或更简单的词频中心移动。
+
 ## session 流程
 
+初版 session 使用内存存储,过期时间为 7 天。repository 保存 `createdAt`、`updatedAt`、`expiresAt`;每次 start、submit、get session 时都需要判断是否过期。当前内存 repository 提供 `deleteExpired(now)` 能力,但初版 runtime 不引入持久化、分享链接或跨进程恢复;面向报告裂变传播的持久化 report/shareId 设计后续单独展开。
+
 ### start
 
 1. 校验年级。
-2. 如果同一 `studentId + grade` 已有未完成且未过期 session:
-   - `restart != true`:返回已有 session 当前关。
-   - `restart == true`:旧 session 标记 `cancelled`,创建新 session。
-3. 初始化算法状态。
-4. 生成第 1 关 12 个词。
-5. 保存到内存 repository。
+2. 初始化算法状态。
+3. 生成第 1 关 12 个词。
+4. 保存到内存 repository。
+
+初版没有学生身份,也没有 restart 机制;未完成 session 的继续能力由前端可靠保存 `sessionId` 后调用 `GET /api/vocabulary-survey/sessions/{sessionId}` 或提交当前关实现。如果前端丢失 `sessionId`,后端无法恢复旧匿名 session,只能重新开始。
 
 ### submit
 
-1. 校验 session 存在、未过期、未 finished。
-2. 校验 level 存在且是当前关。
-3. 校验所有 `unknownSurveyWordIds` 属于当前 session 和当前 level。
-4. 如果本关已经提交过,直接返回 session 最新状态,不重复计算。
-5. 未提交的本关词默认认识。
-6. 计算 unknownRate 并更新 level。
-7. 判断是否结束;未结束则生成下一关。
-8. 保存并返回最新状态。
+1. 校验 session 存在、未过期。
+2. 如果 session 已 finished,返回 `SURVEY_SESSION_FINISHED`,不再允许提交任何历史关卡。
+3. 校验 level 属于当前 session。
+4. 如果 session 仍 inProgress 且该 level 已提交过,直接返回 session 最新状态,不比较第二次提交内容,不重复计算,也不重复推进下一关。该策略用于处理弱网重试、响应丢失、用户连点等重复提交场景。
+5. 如果该 level 未提交,校验 level 是当前关。
+6. 校验所有 `unknownWordIds` 都属于当前 session 的当前 level。即使提交的是词库真实 `wordId`,也不能接受未在本关展示过的词,避免污染答题分析和 K 值估算。
+7. 未提交的本关词默认认识。
+8. 更新 level,并基于已提交关卡估算下一关中心或生成报告。
+9. 判断是否结束;未结束则生成下一关。
+10. 保存并返回最新状态。
 
 ### get session
 
@@ -454,31 +459,34 @@ right = center + width / 2
 5. 局部找不到时扩大查找半径。
 6. 仍不足 12 个则抛 `SURVEY_WORD_GENERATION_FAILED`。
 
-`surveyWordId` 使用 `sw_` 前缀加随机或序列化 ID;`levelId` 使用 `lvl_`;`sessionId` 使用 `surv_`。
+`levelId` 使用 `lvl_` 前缀加随机或序列化 ID;`sessionId` 使用 `surv_` 前缀加随机或序列化 ID。单词提交使用真实词库 `wordId`,不额外生成 `surveyWordId`。
 
 ## 收敛算法
 
 提交后:
 
 ```text
-unknownRate = unknownCount / wordCount
 nextWordWidth = max(currentWordWidth * 0.75, 100)
 offset = nextWordWidth / 6
+analysis = analyze(allSubmittedLevels)
 ```
 
-规则
+当前实现以 K 估算为主,而不是只根据本关 `unknownCount` 阈值移动
 
 ```text
-unknownRate <= 0.4:
-  nextCenter = currentCenterFrequency + offset
-
-unknownRate >= 0.6:
+if currentSubmittedLevel has inversion:
   nextCenter = currentCenterFrequency - offset
+  feedback = 本关结果不稳定,我们再确认一下基础词。
 
-0.4 < unknownRate < 0.6:
-  nextCenter = currentCenterFrequency
+else:
+  nextCenter = analysis.estimatedK
+  feedback 根据 nextCenter 与 currentCenterFrequency 的相对关系提示“挑战稍高一点 / 巩固更基础 / 继续保持”。
 ```
 
+`inversion` 仅看当前已提交关:如果当前关同时存在认识词和不认识词,且最高频认识词比最低频不认识词更难,则认为本关答题倒挂,下一关保守下探基础词。历史倒挂不会永久惩罚后续关卡。
+
+`analysis.estimatedK` 使用所有已提交关卡的实测词计算,估算规则见“K 值估算”。这使下一关围绕当前估计能力边界取词,而不是只按本关未知词数量做固定步长移动。
+
 边界:
 
 ```text
@@ -491,19 +499,19 @@ unknownRate >= 0.6:
 
 ```text
 if completedLevelCount >= 6: end
-else if completedLevelCount >= 4 && recent two levels boundary: end
+else if completedLevelCount >= 4 && recent two levels in convergenceBand: end
 else if completedLevelCount >= 5: end
 else continue
 ```
 
-“接近边界”定义为 `0.4 < unknownRate < 0.6`
+`convergenceBand` 定义为 `4 <= unknownCount <= 8`,表示最近关卡的未知数处于中间区间,当前中心词频已接近学生能力分界区间。该阈值对应每关 12 词下的保守版 `0.33/0.67` 策略
 
 该策略满足:
 
 - 最少 4 关。
 - 默认 5 关。
 - 最多 6 关。
-- 最近两关均接近边界可提前结束。
+- 最近两关均落入 `convergenceBand` 可提前结束。
 
 ## K 值估算
 
@@ -521,7 +529,7 @@ error(K) = K 左侧不认识词数量 + K 右侧认识词数量
 - `wordFrequency > K` 属于 K 右侧。
 
 4. 选择 error 最小的 K。
-5. 如果多个 K 相同,优先选择最接近最近边界关卡中心频率的 K;如果没有边界关,则选择最接近最后一关 center 的 K。
+5. 如果多个 K 相同,优先选择最接近最近落入 `convergenceBand` 关卡中心频率的 K;如果没有此类关卡,则选择最接近最后一关 center 的 K。
 
 ## 词汇量估算
 
@@ -531,7 +539,7 @@ error(K) = K 左侧不认识词数量 + K 右侧认识词数量
 vocabularySize = K
 ```
 
-原因:`wordFrequency` 在当前词库中接近 rank/频段位置,用 K 作为初版词汇量估算最稳定、最可解释。后续如引入完整掌握度模型,可升级为基于实测词掌握度和未实测词估计掌握度的聚合模型。
+原因:`wordFrequency` 在当前词库中接近 rank/频段位置,用 K 作为初版词汇量估算最稳定、最可解释。API 字段名保持 `vocabularySize`,但业务语义是估算值;前端展示时建议使用“约”“估算”等措辞。后续如引入完整掌握度模型,可升级为基于实测词掌握度和未实测词估计掌握度的聚合模型。
 
 ## 能力等级
 
@@ -598,36 +606,6 @@ ability:
       location: classpath:data/dict.data
 ```
 
-### 数据库配置
-
-按用户要求直接创建实际配置文本,用户名和密码留空,由用户本地填写。不使用 `.example` 模板。
-
-实现时需要合并到现有配置文件,不能覆盖 `ability.exam-sprint.report` 等已有配置;尤其是 `application-test.yml` 中已有的 report 测试配置必须保留,只新增或更新 `spring.datasource` 和 `ability.vocabulary-survey` 相关配置。
-
-`ability-center-runtime/src/main/resources/application-dev.yml` 指向 `qingti_db_dev`:
-
-```yaml
-spring:
-  datasource:
-    url: jdbc:mysql://rm-uf6881jgyy065rdxdoo.rwlb.rds.aliyuncs.com:3306/qingti_db_dev?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
-    username:
-    password:
-    driver-class-name: com.mysql.cj.jdbc.Driver
-```
-
-`ability-center-runtime/src/main/resources/application-test.yml` 指向 `qingti_test`:
-
-```yaml
-spring:
-  datasource:
-    url: jdbc:mysql://rm-uf6881jgyy065rdxdoo.rwlb.rds.aliyuncs.com:3306/qingti_test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
-    username:
-    password:
-    driver-class-name: com.mysql.cj.jdbc.Driver
-```
-
-初版词库不依赖 datasource;该配置只为当前项目环境后续数据库接入预留。
-
 ## 错误码
 
 在 `ability-center-kernel` 的 `ErrorCode` 增加:
@@ -654,7 +632,7 @@ HTTP 层继续通过 `BusinessException + GlobalExceptionHandler` 返回统一 `
 2. 第一关生成。
 3. 每关返回 12 个词。
 4. 同一 session 不重复出词。
-5. `unknownRate` 影响下一关中心词频。
+5. `unknownCount` 影响下一关中心词频。
 6. `currentWordWidth` 每关按 0.75 收敛。
 7. 最少 4 关、默认 5 关、最多 6 关结束规则。
 8. K 值估算。
@@ -672,7 +650,7 @@ HTTP 层继续通过 `BusinessException + GlobalExceptionHandler` 返回统一 `
 - MessagePack 字典文件可以加载。
 - 能按词频附近取词。
 - 能通过 `wordId` 查词。
-- 能通过 `meaningId` 查词。
+- 能过滤没有释义的词。
 
 真实 `dict.data` 加载测试可单独隔离,避免影响核心算法测试速度。核心测试使用 fake provider。
 
@@ -680,10 +658,10 @@ HTTP 层继续通过 `BusinessException + GlobalExceptionHandler` 返回统一 `
 
 使用 MockMvc/WebMvcTest 风格覆盖:
 
-- `POST /mini-program/vocabulary-survey/start`
-- `POST /mini-program/vocabulary-survey/levels/{levelId}/submit` 返回下一关。
-- `POST /mini-program/vocabulary-survey/levels/{levelId}/submit` 返回最终报告。
-- `GET /mini-program/vocabulary-survey/sessions/{sessionId}` 恢复当前关或返回报告。
+- `POST /api/vocabulary-survey/start`
+- `POST /api/vocabulary-survey/sessions/{sessionId}/levels/{levelId}/submit` 返回下一关。
+- `POST /api/vocabulary-survey/sessions/{sessionId}/levels/{levelId}/submit` 返回最终报告。
+- `GET /api/vocabulary-survey/sessions/{sessionId}` 恢复当前关或返回报告。
 - 错误码映射。
 
 ### 架构测试
@@ -698,17 +676,17 @@ HTTP 层继续通过 `BusinessException + GlobalExceptionHandler` 返回统一 `
 
 实现完成后至少运行:
 
-```powershell
+```bash
 mvn test
 ```
 
 如果全量测试受现有 Playwright/PDF 测试影响,可补充运行目标模块:
 
-```powershell
-mvn -pl abilities/vocabulary-survey/domain test
-mvn -pl abilities/vocabulary-survey/application test
-mvn -pl abilities/vocabulary-survey/infrastructure test
-mvn -pl ability-center-runtime test
+```bash
+mvn -pl abilities/vocabulary-survey/domain -am test
+mvn -pl abilities/vocabulary-survey/application -am test
+mvn -pl abilities/vocabulary-survey/infrastructure -am test
+mvn -pl ability-center-runtime -am test
 ```
 
 最终汇报必须说明真实验证结果;未跑或失败不得声称通过。
@@ -718,23 +696,26 @@ mvn -pl ability-center-runtime test
 ### 初版风险
 
 - session 为内存存储,服务重启后丢失。
-- `dict.data` MessagePack 结构需要通过真实文件验证 Java 反序列化字段名和类型
+- `dict.data` 已验证真实结构,并通过 Java MessagePack loader 加载;词汇摸底是必需能力,因此资源缺失或解析失败应导致启动失败
 - 词汇量初版采用 `vocabularySize = K`,不是完整掌握度模型。
-- 当前项目未接登录态,`studentId` 暂由请求体传入
+- 当前项目未接登录态,初版使用匿名 session;前端确认会可靠保存 `sessionId`,后端按 `sessionId` 支持恢复未完成摸底
 - 复制二进制词库到仓库后,仓库体积可能增加;词库更新需要替换文件并重新发版。
+- 当前无释义词由 Java loader 过滤,本轮不直接清理二进制词库文件;如果需要源头清理,应建立独立词库清洗、版本校验和发布流程。
+- 当前 report 随内存 session 保存,不支持服务重启后恢复、跨实例访问或长期分享传播;报告持久化和分享 ID 后续单独设计。
 
 ### 后续演进
 
 - session、level、word 明细落库,支持服务重启恢复和多实例部署。
 - 接入真实登录态,从认证上下文获取学生 ID。
-- 将词库更新流程从复制文件升级为 OSS 下载、版本校验或配置化挂载。
+- 引入 report 持久化和 shareId/share link,支持报告裂变传播、跨重启访问和有效期管理。
+- 将词库更新流程从复制文件升级为 OSS 下载、版本校验、配置化挂载或源文件清洗。
 - 引入更完整的词汇量估算模型,使用实测词掌握度和未实测词估算掌握度聚合。
 - 根据真实小程序体验调整停止条件、反馈文案和报告文案。
 
 ## 实施顺序
 
 1. 新增 Maven 模块和基础 package。
-2. 复制 `dict.data` 到 infrastructure resources
+2. 确认已复制的 `dict.data` 位于 infrastructure resources,并纳入模块资源
 3. 新增 MessagePack 依赖和词库加载 adapter。
 4. 实现 domain 模型、年级策略、选词规划、K 估算和报告生成。
 5. 实现内存 session repository。

+ 4 - 0
pom.xml

@@ -28,5 +28,9 @@
         <module>abilities/exam-sprint/application</module>
         <module>abilities/exam-sprint/domain</module>
         <module>abilities/exam-sprint/infrastructure</module>
+        <module>abilities/vocabulary-survey/contracts</module>
+        <module>abilities/vocabulary-survey/domain</module>
+        <module>abilities/vocabulary-survey/application</module>
+        <module>abilities/vocabulary-survey/infrastructure</module>
     </modules>
 </project>

Некоторые файлы не были показаны из-за большого количества измененных файлов