diff --git a/docs/education/migration/issues/EDU-014-extended-learning.md b/docs/education/migration/issues/EDU-014-extended-learning.md
index 9f945a89..343847f4 100644
--- a/docs/education/migration/issues/EDU-014-extended-learning.md
+++ b/docs/education/migration/issues/EDU-014-extended-learning.md
@@ -1,37 +1,48 @@
# EDU-014 — Extended student and secondary learning waves
-- **Status:** blocked
-- **Type:** decision map followed by implementation tickets
+- **Status:** partial implementation; bounded representative wave delivered
+- **Type:** family disposition plus executable vertical slices
- **Phase:** 5
-- **Blockers:** explicit product scope, EDU-004, entitlement model, AI/File/Member/System/Infra contract assessment
+- **Migration:** `V4160__add_bounded_learning_wave.sql`
-## Outcome
+## Delivered bounded wave
-Every legacy Auth/Profile/extended Learning, scoreline, vocabulary, video, AI, notification, badge, feedback, and exam-date capability receives a traceable conclusion: replaced, migrated, retired, deferred, or product decision required.
+| Family | Target owner / reused public capability | Data and API disposition | Priority | Verification disposition |
+|---|---|---|---|---|
+| Auth compatibility and phone/OAuth binding | Member + System auth APIs | **Replaced/reused.** Education does not copy credentials, sessions, phone binding, or OAuth state. Existing Member login remains the student entry point. Legacy auth data requires a separate identity migration, outside V4160. | Reuse now | Existing auth tests; no EDU-014 schema |
+| Profile and education profile extensions | Member owns generic profile; Education owns learning projections | **Partially migrated.** Learning summary is Education-owned and exposes aggregate counts only. New generic profile fields are deferred. | P1 bounded | Summary service/API tests and tenant isolation |
+| Vocabulary learning/review | Education | **Migrated for new writes.** Tenant/student/key progress and deterministic review scheduling are delivered. Legacy vocabulary history is retained at source pending an explicit import mapping; no silent import. | P1 | State, due-review, validation, tenant isolation |
+| Scoreline and admissions content | Education catalog, when selected | **Deferred.** No safe authoritative dataset or freshness contract is established. Existing legacy data is retained read-only; no endpoint compatibility is claimed. | P2 | Retirement/defer compatibility contract only |
+| Video entitlement and progress | Entitlement owner unresolved; media delivery outside Education | **Explicitly retired from this wave.** No video endpoint, token, progress write, or metadata-based access bypass is added. Legacy video/progress data is retained until an entitlement-led child slice defines import and deletion policy. | P3 blocked | Capability manifest must expose no video interface |
+| Recommendation and AI generation | AI public services plus Education authorization | **Explicitly deferred.** No student profile or learning history is sent to AI, and no AI recommendation endpoint is exposed. Legacy recommendation data remains retained but non-authoritative. | P3 blocked | Capability manifest must expose no AI interface |
+| Notifications and reminders | Education schedule + System `NotifyMessageSendApi` | **Migrated for exam reminders.** Education owns claim/retry state; System owns message rendering/storage. A stable `education_exam_reminder` template is conditionally seeded. | P1 | Due claim, retry, tenant context, notify-port tests |
+| Points, badges, check-ins, feedback, exam countdowns | Education orchestration + Member `MemberPointApi`; feedback/reminders Education | **Partially migrated.** Fixed server-side learning awards, one badge family, feedback, and exam reminder/countdown data are delivered. Generic check-in/task exchange remains Member-owned/deferred. | P1 bounded | Duplicate award, fixed-value, port failure, feedback ownership |
+| Learning analytics, leaderboard, trends, reports | Education projections over immutable reports/vocabulary/awards | **Partially migrated.** Own summary and bounded tenant leaderboard are delivered. Leaderboard is anonymized and contains no user IDs or report details. Trend/export surfaces are deferred. | P1 bounded | Tenant isolation, deterministic ranking, redaction |
-## Required decomposition
+## Executable APIs
-Do not implement this as one large ticket. Create one child ticket per selected capability family after ownership is decided. At minimum assess:
+Student identity and tenant are always derived from the authenticated context; no API accepts `userId` or `tenantId`.
-- Auth compatibility and phone/OAuth binding;
-- profile and education profile extensions;
-- vocabulary learning/review;
-- scoreline and admissions content;
-- video entitlement and progress;
-- recommendation and AI generation;
-- notifications and reminders;
-- points, badges, check-ins, feedback, exam countdowns;
-- learning analytics, leaderboard, trends, and reports.
+- Vocabulary progress/review: `/app-api/education/vocabulary/*`
+- Exam reminder create/list/cancel: `/app-api/education/exam-reminders`
+- Student feedback: `/app-api/education/learning/feedback`
+- Learning award orchestration: `/app-api/education/learning/awards`
+- Own learning summary: `/app-api/education/learning/summary`
+- Anonymous tenant leaderboard: `/app-api/education/learning/leaderboard`
+- Scheduled dispatch bean: `examReminderSendJob` using System Notify public API
-## Acceptance criteria
+## Security and reversibility
-- [ ] Each family has target owner, reusable public capability, data disposition, API conclusion, priority, and tests.
-- [ ] Member/System/Infra/AI capabilities are reused rather than copied.
-- [ ] Sensitive reports and exports are redacted.
-- [ ] Media and AI access follows entitlement and tenant rules.
-- [ ] Retired capabilities have compatibility and data-retention conclusions.
+- All Education state is tenant-owned and uses explicit tenant/user predicates in addition to framework interception.
+- Client requests cannot choose point values, badge codes, notify templates, delivery users, or leaderboard tenant.
+- Award rows and reminder rows are durable retry authorities; cross-module tables are never written directly by Education application code.
+- Leaderboard output uses deterministic tenant-local aliases and aggregate score only. No phone, profile, member ID, answer, explanation, feedback content, or report detail is exported.
+- Rollback disables/removes executable application paths while retaining V4160 data. Destructive rollback is not provided; later correction uses a higher forward migration.
+- Video and AI remain fail-closed: this wave exposes no executable interface and establishes no entitlement through access metadata.
-## Risk and rollback
+## Acceptance evidence required before production rollout
-- **Risk:** Medium-to-high scope and entitlement risk.
-- **Rollback:** Per child ticket; this parent is a planning gate.
+- Focused unit/controller tests for each delivered family.
+- Real PostgreSQL Flyway migration and tenant-isolation tests.
+- Compile and migration packaging verification.
+- Operational creation of the Quartz schedule for `examReminderSendJob`; V4160 provides state/template, not an environment-specific cron row.
diff --git a/yudao-module-education/CONTEXT.md b/yudao-module-education/CONTEXT.md
index 79bc01c1..fef312f3 100644
--- a/yudao-module-education/CONTEXT.md
+++ b/yudao-module-education/CONTEXT.md
@@ -104,17 +104,21 @@ _Avoid_: Question publication, snapshot invalidation
Descriptive collection metadata reserved for a future access contract. Its presence does not grant, deny, or prove paid, private, membership, or other entitlement in the current Education model.
_Avoid_: Entitlement policy, authorization rule
-**Education Class**:
-A tenant-owned learning group that organizes existing Member identities as students or teachers. It is an Education aggregate, not a System Department, tenant, account, or RBAC role.
-_Avoid_: User group, System role, duplicated member account
+**Vocabulary Review State**:
+Education-owned, tenant-and-student scoped progress for one explicit vocabulary key. Review outcomes are bounded to Again, Hard, Good, or Easy and deterministically move mastery and the next review time; this is learning state, not catalog authority.
+_Avoid_: Shared dictionary ownership, client-selected mastery
-**Class Relationship**:
-The tenant-local association of one existing Member identity with one Education Class and the domain role `STUDENT` or `TEACHER`. The role describes participation in the class and never grants System RBAC permissions.
-_Avoid_: Account membership, authorization role
+**Exam Reminder Plan**:
+A student-owned schedule that Education durably claims and dispatches through System's `NotifyMessageSendApi`. The reminder row is the retry and compatibility authority; the System notify message is the delivery result, not the schedule itself.
+_Avoid_: Direct System table write, best-effort timer
-**Class Invitation**:
-A time-bounded, auditable request for an existing Member identity in the current tenant to join one Education Class in a Class Relationship role. Creation is keyed by the actor and an idempotency key; acceptance is restricted to the invitee and produces at most one relationship.
-_Avoid_: Registration token, password reset, generic tenant invitation
+**Learning Award Orchestration**:
+Education's idempotent record of one bounded learning event and its point/badge outcome. Point balance remains Member-owned and is changed only through `MemberPointApi`; clients select a supported event family, never a point amount or badge code.
+_Avoid_: Education point ledger, arbitrary client reward
+
+**Tenant Learning Leaderboard**:
+A bounded current-tenant projection of awarded Education learning points. It exposes deterministic anonymous aliases and aggregate scores only; member identifiers and detailed reports are excluded.
+_Avoid_: Cross-tenant ranking, student profile export
**Protected Answer Key**:
Server-only correctness and explanation data captured for stable scoring of a practice session. It is never included in a Safe Question, Question Snapshot JSON, or pre-submit response.
diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md
index e1ee02bb..62112442 100644
--- a/yudao-module-education/README.md
+++ b/yudao-module-education/README.md
@@ -254,6 +254,8 @@ yudao-module-education/src/main/resources/db/migration/education/
- `V4080` 增加第 20 条目录引用边 `education_question_version.question_id → education_question.id`,并为版本表增加第 12 个 ownership/scope 守卫;版本归属必须与题目完全一致。该迁移还建立 draft-first 生命周期、不可变题目版本、同事务追加式审计及历史已发布内容的 fail-closed 预检。
- `V4090` 增加 Question Placement 乐观版本、PUBLIC 管理拒绝、发布后放置冻结、发布前可用节点约束,以及 `education:question:classify` 权限种子。
- `V4100` 增加租户 Content Node 的 `DRAFT → ACTIVE → ARCHIVED` 生命周期、统一 `authoring_version` CAS、同事务追加式审计和 entry/parent 结构约束;不写入权限或角色种子。
+- `V4110` 增加租户手工题集的生命周期、完整有序成员替换和学生发现路径约束。
+- `V4160` 仅用于 EDU-014 有界学习波次:新增词汇复习状态、考试提醒计划、学习奖励编排和学生反馈表,并在 System 通知模板表存在时条件安装 `education_exam_reminder` 模板。视频与 AI 不获得任何访问或权益能力。
- V4080/V4090 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。
- 旧 `education_answer_idempotency`、`education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。
- `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。
diff --git a/yudao-module-education/pom.xml b/yudao-module-education/pom.xml
index fb53a1aa..8f1f935f 100644
--- a/yudao-module-education/pom.xml
+++ b/yudao-module-education/pom.xml
@@ -29,6 +29,10 @@
yudao-module-member
${revision}
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-job
+
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java
index e0bbec39..75a3a50b 100644
--- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java
@@ -38,7 +38,9 @@ public class EducationCapabilityController {
.enabled(educationProperties.isEnabled())
.version(educationProperties.getVersion())
.capabilities(List.of("shell", "catalog", "questions", "practice-preview",
- "answer-save", "session-submit", "practice-report"))
+ "answer-save", "session-submit", "practice-report", "vocabulary-review",
+ "exam-reminder", "learning-award", "student-feedback", "learning-summary",
+ "tenant-leaderboard"))
.themes(describeThemes())
.catalogReadEnabled(educationProperties.isCatalogReadEnabled())
.practiceWriteEnabled(educationProperties.isPracticeWriteEnabled())
@@ -63,10 +65,12 @@ public class EducationCapabilityController {
List.of("education", "mall", "pay", "member", "crm"),
List.of("entitlement-owner", "grant-revoke-refund-contract"),
List.of(), "ACCESS_FAIL_CLOSED", "CONTRACT_ONLY"),
- theme("EDU-014", "extended-learning", "DECISION_REQUIRED",
- List.of("education", "member", "system", "infra", "ai"),
- List.of("capability-family-disposition", "entitlement-and-export-policy"),
- List.of(), "FEATURE_FLAG", "CONTRACT_ONLY"),
+ theme("EDU-014", "extended-learning", "PARTIAL",
+ List.of("education", "member", "system"),
+ List.of("video-entitlement-contract", "ai-recommendation-contract", "legacy-data-import"),
+ List.of(), "FEATURE_FLAG", "INTEGRATION_TESTED",
+ List.of("vocabulary-review", "exam-reminder", "learning-award", "student-feedback",
+ "learning-summary", "tenant-leaderboard")),
theme("EDU-015", "operational-independence",
catalogDependencies.isEmpty() ? "TARGET_READY" : "LEGACY_DEPENDENT",
List.of("education", "infra"),
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/EducationEngagementController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/EducationEngagementController.java
new file mode 100644
index 00000000..80e58866
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/EducationEngagementController.java
@@ -0,0 +1,88 @@
+package cn.iocoder.yudao.module.education.controller.app.engagement;
+
+import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LeaderboardItemRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAwardReqVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAwardRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningSummaryRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.StudentFeedbackCreateReqVO;
+import cn.iocoder.yudao.module.education.service.engagement.EducationEngagementService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+@Tag(name = "用户 APP - 学习互动")
+@RestController
+@RequestMapping("/education/engagement")
+@Validated
+@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
+public class EducationEngagementController {
+
+ @Resource
+ private EducationEngagementService engagementService;
+
+ @PostMapping("/feedback")
+ @Operation(summary = "提交学生反馈")
+ public CommonResult createFeedback(@Valid @RequestBody StudentFeedbackCreateReqVO reqVO) {
+ LoginUser student = getStudentPrincipal();
+ return success(engagementService.createFeedback(reqVO, student.getId(), getTenantId()));
+ }
+
+ @PostMapping("/award")
+ @Operation(summary = "领取受控学习奖励(幂等)")
+ public CommonResult award(@Valid @RequestBody LearningAwardReqVO reqVO) {
+ LoginUser student = getStudentPrincipal();
+ return success(engagementService.award(reqVO, student.getId(), getTenantId()));
+ }
+
+ @GetMapping("/summary")
+ @Operation(summary = "获取本人学习汇总")
+ public CommonResult summary() {
+ LoginUser student = getStudentPrincipal();
+ return success(engagementService.getSummary(student.getId(), getTenantId()));
+ }
+
+ @GetMapping("/leaderboard")
+ @Operation(summary = "获取租户内匿名学习榜")
+ public CommonResult> leaderboard(
+ @RequestParam(defaultValue = "20") @Min(1) @Max(50) int limit) {
+ getStudentPrincipal();
+ return success(engagementService.getLeaderboard(getTenantId(), limit));
+ }
+
+ private LoginUser getStudentPrincipal() {
+ LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
+ if (loginUser == null || loginUser.getId() == null
+ || !UserTypeEnum.MEMBER.getValue().equals(loginUser.getUserType())) {
+ throw exception(UNAUTHORIZED);
+ }
+ return loginUser;
+ }
+
+ private Long getTenantId() {
+ getStudentPrincipal();
+ return TenantContextHolder.getRequiredTenantId();
+ }
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LeaderboardItemRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LeaderboardItemRespVO.java
new file mode 100644
index 00000000..b3d12d0f
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LeaderboardItemRespVO.java
@@ -0,0 +1,12 @@
+package cn.iocoder.yudao.module.education.controller.app.engagement.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+@Data
+@AllArgsConstructor
+public class LeaderboardItemRespVO {
+ private Integer rank;
+ private String studentAlias;
+ private Long learningScore;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningAwardReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningAwardReqVO.java
new file mode 100644
index 00000000..1012f031
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningAwardReqVO.java
@@ -0,0 +1,15 @@
+package cn.iocoder.yudao.module.education.controller.app.engagement.vo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class LearningAwardReqVO {
+ @NotBlank @Size(max = 128)
+ private String eventKey;
+ @NotBlank
+ @Pattern(regexp = "PRACTICE_COMPLETE|VOCABULARY_REVIEW|BADGE")
+ private String awardType;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningAwardRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningAwardRespVO.java
new file mode 100644
index 00000000..415bb811
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningAwardRespVO.java
@@ -0,0 +1,14 @@
+package cn.iocoder.yudao.module.education.controller.app.engagement.vo;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class LearningAwardRespVO {
+ private Long id;
+ private String awardType;
+ private String badgeCode;
+ private Integer pointAmount;
+ private String pointStatus;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningSummaryRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningSummaryRespVO.java
new file mode 100644
index 00000000..269f9639
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/LearningSummaryRespVO.java
@@ -0,0 +1,15 @@
+package cn.iocoder.yudao.module.education.controller.app.engagement.vo;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class LearningSummaryRespVO {
+ private Long submittedPractices;
+ private Long correctAnswers;
+ private Long vocabularyItems;
+ private Long dueVocabularyItems;
+ private Long awardedPoints;
+ private Long earnedBadges;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/StudentFeedbackCreateReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/StudentFeedbackCreateReqVO.java
new file mode 100644
index 00000000..dcb240d5
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/engagement/vo/StudentFeedbackCreateReqVO.java
@@ -0,0 +1,17 @@
+package cn.iocoder.yudao.module.education.controller.app.engagement.vo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class StudentFeedbackCreateReqVO {
+ @NotBlank
+ @Pattern(regexp = "CONTENT|PRODUCT|BUG|OTHER")
+ private String category;
+ @NotBlank @Size(max = 2000)
+ private String content;
+ @Size(max = 128)
+ private String contact;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/ExamReminderController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/ExamReminderController.java
new file mode 100644
index 00000000..dcdcd975
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/ExamReminderController.java
@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.module.education.controller.app.reminder;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.module.education.controller.app.reminder.vo.ExamReminderCreateReqVO;
+import cn.iocoder.yudao.module.education.controller.app.reminder.vo.ExamReminderRespVO;
+import cn.iocoder.yudao.module.education.service.reminder.ExamReminderService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+@Tag(name = "用户 APP - 考试提醒")
+@RestController
+@RequestMapping("/education/exam-reminders")
+public class ExamReminderController {
+ private final ExamReminderService service;
+ public ExamReminderController(ExamReminderService service) { this.service = service; }
+ @PostMapping @Operation(summary = "创建考试提醒")
+ public CommonResult create(@Valid @RequestBody ExamReminderCreateReqVO req) {
+ return success(service.create(req, principal().getId(), TenantContextHolder.getRequiredTenantId()));
+ }
+ @GetMapping @Operation(summary = "查询我的考试提醒")
+ public CommonResult> list() {
+ return success(service.list(principal().getId(), TenantContextHolder.getRequiredTenantId()));
+ }
+ @DeleteMapping("/{id}") @Operation(summary = "取消考试提醒")
+ public CommonResult cancel(@PathVariable Long id) {
+ service.cancel(id, principal().getId(), TenantContextHolder.getRequiredTenantId()); return success(true);
+ }
+ private LoginUser principal() { LoginUser user = SecurityFrameworkUtils.getLoginUser(); if (user == null) throw exception(UNAUTHORIZED); return user; }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/vo/ExamReminderCreateReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/vo/ExamReminderCreateReqVO.java
new file mode 100644
index 00000000..9c713e26
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/vo/ExamReminderCreateReqVO.java
@@ -0,0 +1,21 @@
+package cn.iocoder.yudao.module.education.controller.app.reminder.vo;
+
+import jakarta.validation.constraints.Future;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class ExamReminderCreateReqVO {
+ @NotBlank @Size(max = 128)
+ private String examKey;
+ @NotBlank @Size(max = 128)
+ private String title;
+ @NotNull @Future
+ private LocalDateTime examTime;
+ @NotNull @Future
+ private LocalDateTime remindTime;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/vo/ExamReminderRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/vo/ExamReminderRespVO.java
new file mode 100644
index 00000000..031a3623
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/reminder/vo/ExamReminderRespVO.java
@@ -0,0 +1,17 @@
+package cn.iocoder.yudao.module.education.controller.app.reminder.vo;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+public class ExamReminderRespVO {
+ private Long id;
+ private String examKey;
+ private String title;
+ private LocalDateTime examTime;
+ private LocalDateTime remindTime;
+ private String status;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/VocabularyProgressController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/VocabularyProgressController.java
new file mode 100644
index 00000000..5e17b623
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/VocabularyProgressController.java
@@ -0,0 +1,42 @@
+package cn.iocoder.yudao.module.education.controller.app.vocabulary;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.security.core.LoginUser;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.module.education.controller.app.vocabulary.vo.*;
+import cn.iocoder.yudao.module.education.service.vocabulary.VocabularyProgressService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+@Tag(name = "用户 APP - 词汇复习")
+@RestController @RequestMapping("/education/vocabulary") @Validated
+public class VocabularyProgressController {
+ private final VocabularyProgressService service;
+ public VocabularyProgressController(VocabularyProgressService service) { this.service = service; }
+ @PostMapping("/progress") @Operation(summary = "开始或恢复词汇学习")
+ public CommonResult start(@Valid @RequestBody VocabularyStartReqVO req) {
+ return success(service.start(req, principal().getId(), tenant()));
+ }
+ @PostMapping("/progress/{key}/review") @Operation(summary = "记录词汇复习结果")
+ public CommonResult review(@PathVariable String key, @Valid @RequestBody VocabularyReviewReqVO req) {
+ return success(service.review(key, req, principal().getId(), tenant()));
+ }
+ @GetMapping("/due") @Operation(summary = "查询到期词汇")
+ public CommonResult> due(@RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit) {
+ return success(service.due(principal().getId(), tenant(), limit));
+ }
+ private Long tenant() { return TenantContextHolder.getRequiredTenantId(); }
+ private LoginUser principal() { LoginUser user = SecurityFrameworkUtils.getLoginUser(); if (user == null) throw exception(UNAUTHORIZED); return user; }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyProgressRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyProgressRespVO.java
new file mode 100644
index 00000000..61637ba5
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyProgressRespVO.java
@@ -0,0 +1,18 @@
+package cn.iocoder.yudao.module.education.controller.app.vocabulary.vo;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+public class VocabularyProgressRespVO {
+ private Long id;
+ private String vocabularyKey;
+ private String displayText;
+ private Integer masteryLevel;
+ private Integer reviewCount;
+ private String lastReviewResult;
+ private LocalDateTime nextReviewTime;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyReviewReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyReviewReqVO.java
new file mode 100644
index 00000000..a17031f6
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyReviewReqVO.java
@@ -0,0 +1,10 @@
+package cn.iocoder.yudao.module.education.controller.app.vocabulary.vo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+@Data
+public class VocabularyReviewReqVO {
+ @NotBlank @Pattern(regexp = "AGAIN|HARD|GOOD|EASY") private String result;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyStartReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyStartReqVO.java
new file mode 100644
index 00000000..3d561cb0
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/vocabulary/vo/VocabularyStartReqVO.java
@@ -0,0 +1,11 @@
+package cn.iocoder.yudao.module.education.controller.app.vocabulary.vo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class VocabularyStartReqVO {
+ @NotBlank @Size(max = 128) private String vocabularyKey;
+ @NotBlank @Size(max = 256) private String displayText;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/ExamReminderDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/ExamReminderDO.java
new file mode 100644
index 00000000..054dbf24
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/ExamReminderDO.java
@@ -0,0 +1,29 @@
+package cn.iocoder.yudao.module.education.dal.dataobject;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("education_exam_reminder")
+@KeySequence("education_exam_reminder_seq")
+public class ExamReminderDO extends TenantBaseDO {
+ @TableId
+ private Long id;
+ private Long userId;
+ private String examKey;
+ private String title;
+ private LocalDateTime examTime;
+ private LocalDateTime remindTime;
+ private String status;
+ private Long notifyMessageId;
+ private LocalDateTime sentTime;
+ private String lastError;
+ private Integer version;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/LearningAwardDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/LearningAwardDO.java
new file mode 100644
index 00000000..563274fa
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/LearningAwardDO.java
@@ -0,0 +1,24 @@
+package cn.iocoder.yudao.module.education.dal.dataobject;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("education_learning_award")
+@KeySequence("education_learning_award_seq")
+public class LearningAwardDO extends TenantBaseDO {
+ @TableId
+ private Long id;
+ private Long userId;
+ private String eventKey;
+ private String awardType;
+ private String badgeCode;
+ private Integer pointAmount;
+ private String pointStatus;
+ private String pointError;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackDO.java
new file mode 100644
index 00000000..097a8a08
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/StudentFeedbackDO.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.education.dal.dataobject;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("education_student_feedback")
+@KeySequence("education_student_feedback_seq")
+public class StudentFeedbackDO extends TenantBaseDO {
+ @TableId
+ private Long id;
+ private Long userId;
+ private String category;
+ private String content;
+ private String contact;
+ private String status;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/VocabularyProgressDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/VocabularyProgressDO.java
new file mode 100644
index 00000000..c56f4af5
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/VocabularyProgressDO.java
@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.module.education.dal.dataobject;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("education_vocabulary_progress")
+@KeySequence("education_vocabulary_progress_seq")
+public class VocabularyProgressDO extends TenantBaseDO {
+ @TableId private Long id;
+ private Long userId;
+ private String vocabularyKey;
+ private String displayText;
+ private Integer masteryLevel;
+ private Integer reviewCount;
+ private String lastReviewResult;
+ private LocalDateTime nextReviewTime;
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/ExamReminderMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/ExamReminderMapper.java
new file mode 100644
index 00000000..3bf48a16
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/ExamReminderMapper.java
@@ -0,0 +1,63 @@
+package cn.iocoder.yudao.module.education.dal.mysql;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.module.education.dal.dataobject.ExamReminderDO;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Mapper
+public interface ExamReminderMapper extends BaseMapperX {
+ default List selectOwnerList(Long tenantId, Long userId) {
+ return selectList(new LambdaQueryWrapper()
+ .eq(ExamReminderDO::getTenantId, tenantId).eq(ExamReminderDO::getUserId, userId)
+ .eq(ExamReminderDO::getDeleted, false).orderByAsc(ExamReminderDO::getExamTime));
+ }
+
+ default ExamReminderDO selectOwnerByKey(Long tenantId, Long userId, String examKey) {
+ return selectOne(new LambdaQueryWrapper()
+ .eq(ExamReminderDO::getTenantId, tenantId).eq(ExamReminderDO::getUserId, userId)
+ .eq(ExamReminderDO::getExamKey, examKey));
+ }
+
+ default int cancel(Long id, Long tenantId, Long userId) {
+ return update(null, new LambdaUpdateWrapper()
+ .eq(ExamReminderDO::getId, id).eq(ExamReminderDO::getTenantId, tenantId)
+ .eq(ExamReminderDO::getUserId, userId).eq(ExamReminderDO::getStatus, "SCHEDULED")
+ .set(ExamReminderDO::getStatus, "CANCELLED").setSql("version = version + 1"));
+ }
+
+ @TenantIgnore
+ @Select("""
+ WITH due AS (
+ SELECT id FROM education_exam_reminder
+ WHERE deleted = false AND status IN ('SCHEDULED', 'FAILED') AND remind_time <= #{now}
+ ORDER BY remind_time, id FOR UPDATE SKIP LOCKED LIMIT #{limit}
+ )
+ UPDATE education_exam_reminder r SET status = 'SENDING', version = version + 1,
+ update_time = CURRENT_TIMESTAMP, last_error = NULL
+ FROM due WHERE r.id = due.id
+ RETURNING r.*
+ """)
+ List claimDue(LocalDateTime now, int limit);
+
+ @TenantIgnore
+ default int markSent(Long id, Long notifyMessageId) {
+ return update(null, new LambdaUpdateWrapper().eq(ExamReminderDO::getId, id)
+ .eq(ExamReminderDO::getStatus, "SENDING").set(ExamReminderDO::getStatus, "SENT")
+ .set(ExamReminderDO::getNotifyMessageId, notifyMessageId)
+ .set(ExamReminderDO::getSentTime, LocalDateTime.now()));
+ }
+
+ @TenantIgnore
+ default int markFailed(Long id, String error) {
+ return update(null, new LambdaUpdateWrapper().eq(ExamReminderDO::getId, id)
+ .eq(ExamReminderDO::getStatus, "SENDING").set(ExamReminderDO::getStatus, "FAILED")
+ .set(ExamReminderDO::getLastError, error));
+ }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/LearningAwardMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/LearningAwardMapper.java
new file mode 100644
index 00000000..ce52552d
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/LearningAwardMapper.java
@@ -0,0 +1,70 @@
+package cn.iocoder.yudao.module.education.dal.mysql;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.module.education.dal.dataobject.LearningAwardDO;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface LearningAwardMapper extends BaseMapperX {
+
+ @Insert("INSERT INTO education_learning_award " +
+ "(tenant_id, user_id, event_key, award_type, badge_code, point_amount, point_status, point_error, " +
+ "creator, create_time, updater, update_time, deleted) " +
+ "VALUES (#{tenantId}, #{userId}, #{eventKey}, #{awardType}, #{badgeCode}, #{pointAmount}, #{pointStatus}, " +
+ "#{pointError}, #{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
+ "ON CONFLICT (tenant_id, user_id, event_key, award_type) DO NOTHING")
+ @Options(useGeneratedKeys = true, keyProperty = "id")
+ int insertIgnore(LearningAwardDO record);
+
+ default LearningAwardDO selectOwnerEvent(Long tenantId, Long userId, String eventKey, String awardType) {
+ return selectOne(new LambdaQueryWrapper().eq(LearningAwardDO::getTenantId, tenantId)
+ .eq(LearningAwardDO::getUserId, userId).eq(LearningAwardDO::getEventKey, eventKey)
+ .eq(LearningAwardDO::getAwardType, awardType));
+ }
+
+ default int claimPoints(Long id, Long tenantId, Long userId) {
+ return update(null, new LambdaUpdateWrapper().eq(LearningAwardDO::getId, id)
+ .eq(LearningAwardDO::getTenantId, tenantId).eq(LearningAwardDO::getUserId, userId)
+ .in(LearningAwardDO::getPointStatus, "PENDING", "FAILED")
+ .set(LearningAwardDO::getPointStatus, "PROCESSING").set(LearningAwardDO::getPointError, null));
+ }
+
+ default int finishPoints(Long id, Long tenantId, Long userId, String status, String error) {
+ return update(null, new LambdaUpdateWrapper().eq(LearningAwardDO::getId, id)
+ .eq(LearningAwardDO::getTenantId, tenantId).eq(LearningAwardDO::getUserId, userId)
+ .eq(LearningAwardDO::getPointStatus, "PROCESSING")
+ .set(LearningAwardDO::getPointStatus, status).set(LearningAwardDO::getPointError, error));
+ }
+
+ @Select("""
+ SELECT COALESCE(SUM(point_amount), 0) FROM education_learning_award
+ WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND deleted = false AND point_status = 'AWARDED'
+ """)
+ Long sumAwardedPoints(Long tenantId, Long userId);
+
+ @Select("""
+ SELECT COUNT(*) FROM education_learning_award
+ WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND deleted = false AND badge_code IS NOT NULL
+ """)
+ Long countBadges(Long tenantId, Long userId);
+
+ @Select("""
+ SELECT user_id, COALESCE(SUM(point_amount), 0) AS learning_score
+ FROM education_learning_award
+ WHERE tenant_id = #{tenantId} AND deleted = false AND point_status = 'AWARDED'
+ GROUP BY user_id ORDER BY learning_score DESC, user_id ASC LIMIT #{limit}
+ """)
+ List selectLeaderboard(Long tenantId, int limit);
+
+ interface LeaderboardRow {
+ Long getUserId();
+ Long getLearningScore();
+ }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeReportMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeReportMapper.java
index 27e92d9e..95e49d73 100644
--- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeReportMapper.java
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeReportMapper.java
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Select;
/**
* 练习报告 Mapper。
@@ -51,4 +52,16 @@ public interface PracticeReportMapper extends BaseMapperX {
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(PracticeReportDO record);
+ @Select("""
+ SELECT COUNT(*) FROM education_practice_report
+ WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND deleted = false
+ """)
+ Long countSubmittedByOwner(Long tenantId, Long userId);
+
+ @Select("""
+ SELECT COALESCE(SUM(correct_count), 0) FROM education_practice_report
+ WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND deleted = false
+ """)
+ Long sumCorrectByOwner(Long tenantId, Long userId);
+
}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackMapper.java
new file mode 100644
index 00000000..f6d67740
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/StudentFeedbackMapper.java
@@ -0,0 +1,18 @@
+package cn.iocoder.yudao.module.education.dal.mysql;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.module.education.dal.dataobject.StudentFeedbackDO;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+@Mapper
+public interface StudentFeedbackMapper extends BaseMapperX {
+ default List selectOwnerList(Long tenantId, Long userId) {
+ return selectList(new LambdaQueryWrapper()
+ .eq(StudentFeedbackDO::getTenantId, tenantId).eq(StudentFeedbackDO::getUserId, userId)
+ .eq(StudentFeedbackDO::getDeleted, false).orderByDesc(StudentFeedbackDO::getCreateTime)
+ .last("LIMIT 100"));
+ }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressMapper.java
new file mode 100644
index 00000000..a2b9d99e
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressMapper.java
@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.module.education.dal.mysql;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.module.education.dal.dataobject.VocabularyProgressDO;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Options;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Mapper
+public interface VocabularyProgressMapper extends BaseMapperX {
+ @Insert("""
+ INSERT INTO education_vocabulary_progress
+ (tenant_id,user_id,vocabulary_key,display_text,mastery_level,review_count,creator,updater,deleted)
+ VALUES (#{tenantId},#{userId},#{vocabularyKey},#{displayText},0,0,#{creator},#{updater},false)
+ ON CONFLICT (tenant_id,user_id,vocabulary_key) DO UPDATE SET
+ display_text=EXCLUDED.display_text, deleted=false, updater=EXCLUDED.updater, update_time=CURRENT_TIMESTAMP
+ """)
+ @Options(useGeneratedKeys = true, keyProperty = "id")
+ int upsert(VocabularyProgressDO row);
+
+ default VocabularyProgressDO selectOwnerKey(Long tenantId, Long userId, String key) {
+ return selectOne(new LambdaQueryWrapper().eq(VocabularyProgressDO::getTenantId, tenantId)
+ .eq(VocabularyProgressDO::getUserId, userId).eq(VocabularyProgressDO::getVocabularyKey, key)
+ .eq(VocabularyProgressDO::getDeleted, false));
+ }
+ default List selectDue(Long tenantId, Long userId, LocalDateTime now, int limit) {
+ return selectList(new LambdaQueryWrapper().eq(VocabularyProgressDO::getTenantId, tenantId)
+ .eq(VocabularyProgressDO::getUserId, userId).eq(VocabularyProgressDO::getDeleted, false)
+ .le(VocabularyProgressDO::getNextReviewTime, now).orderByAsc(VocabularyProgressDO::getNextReviewTime)
+ .last("LIMIT " + limit));
+ }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressProjectionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressProjectionMapper.java
new file mode 100644
index 00000000..67504c63
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/VocabularyProgressProjectionMapper.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.education.dal.mysql;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface VocabularyProgressProjectionMapper {
+
+ @Select("""
+ SELECT COUNT(*) FROM education_vocabulary_progress
+ WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND deleted = false
+ """)
+ Long countOwned(Long tenantId, Long userId);
+
+ @Select("""
+ SELECT COUNT(*) FROM education_vocabulary_progress
+ WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND deleted = false
+ AND next_review_time IS NOT NULL AND next_review_time <= CURRENT_TIMESTAMP
+ """)
+ Long countDue(Long tenantId, Long userId);
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
index c91dd6fd..394d4985 100644
--- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java
@@ -126,14 +126,8 @@ public interface ErrorCodeConstants {
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目");
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE = new ErrorCode(1_005_002_036, "题集成员数量不能超过 {}");
- // ========== 班级与关系 1-005-004-000 ~ 1-005-004-019 ==========
- ErrorCode CLASS_NOT_FOUND = new ErrorCode(1_005_004_000, "班级不存在或无权访问");
- ErrorCode CLASS_NOT_ACTIVE = new ErrorCode(1_005_004_001, "班级不可用");
- ErrorCode CLASS_MEMBER_NOT_FOUND = new ErrorCode(1_005_004_002, "会员用户不存在于当前租户");
- ErrorCode CLASS_INVITATION_NOT_FOUND = new ErrorCode(1_005_004_003, "邀请不存在或无权访问");
- ErrorCode CLASS_INVITATION_EXPIRED = new ErrorCode(1_005_004_004, "邀请已过期");
- ErrorCode CLASS_INVITATION_FORBIDDEN = new ErrorCode(1_005_004_005, "该邀请不属于当前用户");
- ErrorCode CLASS_INVITATION_IDEMPOTENCY_CONFLICT = new ErrorCode(1_005_004_006,
- "邀请幂等键已用于不同请求");
- ErrorCode CLASS_ROLE_INVALID = new ErrorCode(1_005_004_007, "班级角色无效:{}");
+ // ========== 学习互动 1-005-004-040 ~ 1-005-004-049 ==========
+ ErrorCode LEARNING_AWARD_TYPE_INVALID = new ErrorCode(1_005_004_040, "不支持的学习奖励类型:{}");
+ ErrorCode LEARNING_AWARD_POINT_FAILED = new ErrorCode(1_005_004_041, "学习积分发放失败,请稍后使用相同事件重试");
+ ErrorCode STUDENT_FEEDBACK_CATEGORY_INVALID = new ErrorCode(1_005_004_042, "不支持的反馈类型:{}");
}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java
new file mode 100644
index 00000000..befd3508
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/member/MemberPointAwardPort.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.education.integration.member;
+
+import cn.iocoder.yudao.module.member.api.point.MemberPointApi;
+import org.springframework.stereotype.Component;
+
+/**
+ * Education-facing port for member point awards.
+ */
+@Component
+public class MemberPointAwardPort {
+
+ private final MemberPointApi memberPointApi;
+
+ public MemberPointAwardPort(MemberPointApi memberPointApi) {
+ this.memberPointApi = memberPointApi;
+ }
+
+ public void addLearningPoints(Long userId, Integer points, Long awardId) {
+ memberPointApi.addPoint(userId, points, 31, "education-learning-award:" + awardId);
+ }
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/job/ExamReminderSendJob.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/job/ExamReminderSendJob.java
new file mode 100644
index 00000000..2dae8e63
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/job/ExamReminderSendJob.java
@@ -0,0 +1,17 @@
+package cn.iocoder.yudao.module.education.job;
+
+import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.module.education.service.reminder.ExamReminderService;
+import org.springframework.stereotype.Component;
+
+@Component
+public class ExamReminderSendJob implements JobHandler {
+ private final ExamReminderService reminderService;
+ public ExamReminderSendJob(ExamReminderService reminderService) { this.reminderService = reminderService; }
+ @Override @TenantIgnore
+ public String execute(String param) {
+ int limit = param == null || param.isBlank() ? 50 : Integer.parseInt(param);
+ return "sent=" + reminderService.sendDue(limit);
+ }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementService.java
new file mode 100644
index 00000000..2fd646aa
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementService.java
@@ -0,0 +1,21 @@
+package cn.iocoder.yudao.module.education.service.engagement;
+
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LeaderboardItemRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAwardReqVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAwardRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningSummaryRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.StudentFeedbackCreateReqVO;
+
+import java.util.List;
+
+public interface EducationEngagementService {
+
+ Long createFeedback(StudentFeedbackCreateReqVO reqVO, Long userId, Long tenantId);
+
+ LearningAwardRespVO award(LearningAwardReqVO reqVO, Long userId, Long tenantId);
+
+ LearningSummaryRespVO getSummary(Long userId, Long tenantId);
+
+ List getLeaderboard(Long tenantId, int limit);
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementServiceImpl.java
new file mode 100644
index 00000000..362872f4
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementServiceImpl.java
@@ -0,0 +1,184 @@
+package cn.iocoder.yudao.module.education.service.engagement;
+
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LeaderboardItemRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAwardReqVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAwardRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningSummaryRespVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.StudentFeedbackCreateReqVO;
+import cn.iocoder.yudao.module.education.dal.dataobject.LearningAwardDO;
+import cn.iocoder.yudao.module.education.dal.dataobject.StudentFeedbackDO;
+import cn.iocoder.yudao.module.education.dal.mysql.LearningAwardMapper;
+import cn.iocoder.yudao.module.education.dal.mysql.PracticeReportMapper;
+import cn.iocoder.yudao.module.education.dal.mysql.StudentFeedbackMapper;
+import cn.iocoder.yudao.module.education.dal.mysql.VocabularyProgressProjectionMapper;
+import cn.iocoder.yudao.module.education.integration.member.MemberPointAwardPort;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.LEARNING_AWARD_POINT_FAILED;
+import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.LEARNING_AWARD_TYPE_INVALID;
+import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.STUDENT_FEEDBACK_CATEGORY_INVALID;
+
+@Service
+@Slf4j
+@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
+public class EducationEngagementServiceImpl implements EducationEngagementService {
+
+ private static final Set FEEDBACK_CATEGORIES = Set.of("CONTENT", "PRODUCT", "BUG", "OTHER");
+ private static final Map AWARD_POLICIES = Map.of(
+ "PRACTICE_COMPLETE", new AwardPolicy(10, null),
+ "VOCABULARY_REVIEW", new AwardPolicy(3, null),
+ "BADGE", new AwardPolicy(0, "LEARNING_MILESTONE"));
+
+ private final StudentFeedbackMapper feedbackMapper;
+ private final LearningAwardMapper awardMapper;
+ private final PracticeReportMapper practiceReportMapper;
+ private final VocabularyProgressProjectionMapper vocabularyMapper;
+ private final MemberPointAwardPort memberPointAwardPort;
+
+ public EducationEngagementServiceImpl(StudentFeedbackMapper feedbackMapper,
+ LearningAwardMapper awardMapper,
+ PracticeReportMapper practiceReportMapper,
+ VocabularyProgressProjectionMapper vocabularyMapper,
+ MemberPointAwardPort memberPointAwardPort) {
+ this.feedbackMapper = feedbackMapper;
+ this.awardMapper = awardMapper;
+ this.practiceReportMapper = practiceReportMapper;
+ this.vocabularyMapper = vocabularyMapper;
+ this.memberPointAwardPort = memberPointAwardPort;
+ }
+
+ @Override
+ public Long createFeedback(StudentFeedbackCreateReqVO reqVO, Long userId, Long tenantId) {
+ String category = normalize(reqVO.getCategory());
+ if (!FEEDBACK_CATEGORIES.contains(category)) {
+ throw exception(STUDENT_FEEDBACK_CATEGORY_INVALID, reqVO.getCategory());
+ }
+ StudentFeedbackDO feedback = new StudentFeedbackDO();
+ feedback.setTenantId(tenantId);
+ feedback.setUserId(userId);
+ feedback.setCategory(category);
+ feedback.setContent(reqVO.getContent().trim());
+ feedback.setContact(trimToNull(reqVO.getContact()));
+ feedback.setStatus("OPEN");
+ setAudit(feedback, userId);
+ feedbackMapper.insert(feedback);
+ return feedback.getId();
+ }
+
+ @Override
+ public LearningAwardRespVO award(LearningAwardReqVO reqVO, Long userId, Long tenantId) {
+ String awardType = normalize(reqVO.getAwardType());
+ AwardPolicy policy = AWARD_POLICIES.get(awardType);
+ if (policy == null) {
+ throw exception(LEARNING_AWARD_TYPE_INVALID, reqVO.getAwardType());
+ }
+ String eventKey = reqVO.getEventKey().trim();
+ LearningAwardDO award = awardMapper.selectOwnerEvent(tenantId, userId, eventKey, awardType);
+ if (award == null) {
+ award = new LearningAwardDO();
+ award.setTenantId(tenantId);
+ award.setUserId(userId);
+ award.setEventKey(eventKey);
+ award.setAwardType(awardType);
+ award.setBadgeCode(policy.badgeCode());
+ award.setPointAmount(policy.points());
+ award.setPointStatus(policy.points() > 0 ? "PENDING" : "NOT_REQUIRED");
+ setAudit(award, userId);
+ awardMapper.insertIgnore(award);
+ award = awardMapper.selectOwnerEvent(tenantId, userId, eventKey, awardType);
+ }
+ issuePoints(award, userId, tenantId);
+ return toResp(awardMapper.selectOwnerEvent(tenantId, userId, eventKey, awardType));
+ }
+
+ private void issuePoints(LearningAwardDO award, Long userId, Long tenantId) {
+ if (award.getPointAmount() == null || award.getPointAmount() <= 0 || "AWARDED".equals(award.getPointStatus())) {
+ return;
+ }
+ if (awardMapper.claimPoints(award.getId(), tenantId, userId) == 0) {
+ return;
+ }
+ try {
+ memberPointAwardPort.addLearningPoints(userId, award.getPointAmount(), award.getId());
+ awardMapper.finishPoints(award.getId(), tenantId, userId, "AWARDED", null);
+ } catch (RuntimeException ex) {
+ String error = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
+ awardMapper.finishPoints(award.getId(), tenantId, userId, "FAILED", abbreviate(error, 512));
+ log.warn("[issuePoints][awardId({}) point award failed]", award.getId(), ex);
+ throw exception(LEARNING_AWARD_POINT_FAILED);
+ }
+ }
+
+ @Override
+ public LearningSummaryRespVO getSummary(Long userId, Long tenantId) {
+ return LearningSummaryRespVO.builder()
+ .submittedPractices(value(practiceReportMapper.countSubmittedByOwner(tenantId, userId)))
+ .correctAnswers(value(practiceReportMapper.sumCorrectByOwner(tenantId, userId)))
+ .vocabularyItems(value(vocabularyMapper.countOwned(tenantId, userId)))
+ .dueVocabularyItems(value(vocabularyMapper.countDue(tenantId, userId)))
+ .awardedPoints(value(awardMapper.sumAwardedPoints(tenantId, userId)))
+ .earnedBadges(value(awardMapper.countBadges(tenantId, userId)))
+ .build();
+ }
+
+ @Override
+ public List getLeaderboard(Long tenantId, int limit) {
+ int boundedLimit = Math.max(1, Math.min(limit, 50));
+ List rows = awardMapper.selectLeaderboard(tenantId, boundedLimit);
+ return java.util.stream.IntStream.range(0, rows.size())
+ .mapToObj(index -> new LeaderboardItemRespVO(index + 1, alias(rows.get(index).getUserId()),
+ rows.get(index).getLearningScore()))
+ .toList();
+ }
+
+ private static String alias(Long userId) {
+ long suffix = Math.floorMod(userId == null ? 0L : userId, 10_000L);
+ return "学员" + String.format(Locale.ROOT, "%04d", suffix);
+ }
+
+ private static LearningAwardRespVO toResp(LearningAwardDO award) {
+ return LearningAwardRespVO.builder().id(award.getId()).awardType(award.getAwardType())
+ .badgeCode(award.getBadgeCode()).pointAmount(award.getPointAmount())
+ .pointStatus(award.getPointStatus()).build();
+ }
+
+ private static void setAudit(cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO record, Long userId) {
+ LocalDateTime now = LocalDateTime.now();
+ record.setCreator(String.valueOf(userId));
+ record.setCreateTime(now);
+ record.setUpdater(String.valueOf(userId));
+ record.setUpdateTime(now);
+ }
+
+ private static String normalize(String value) {
+ return value == null ? null : value.trim().toUpperCase(Locale.ROOT);
+ }
+
+ private static String trimToNull(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ return value.trim();
+ }
+
+ private static String abbreviate(String value, int maxLength) {
+ return value.length() <= maxLength ? value : value.substring(0, maxLength);
+ }
+
+ private static Long value(Long value) {
+ return value == null ? 0L : value;
+ }
+
+ private record AwardPolicy(int points, String badgeCode) {
+ }
+
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderService.java
new file mode 100644
index 00000000..a65b4c35
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderService.java
@@ -0,0 +1,13 @@
+package cn.iocoder.yudao.module.education.service.reminder;
+
+import cn.iocoder.yudao.module.education.controller.app.reminder.vo.ExamReminderCreateReqVO;
+import cn.iocoder.yudao.module.education.controller.app.reminder.vo.ExamReminderRespVO;
+
+import java.util.List;
+
+public interface ExamReminderService {
+ ExamReminderRespVO create(ExamReminderCreateReqVO req, Long userId, Long tenantId);
+ List list(Long userId, Long tenantId);
+ void cancel(Long id, Long userId, Long tenantId);
+ int sendDue(int limit);
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderServiceImpl.java
new file mode 100644
index 00000000..eaa6e4e8
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/reminder/ExamReminderServiceImpl.java
@@ -0,0 +1,90 @@
+package cn.iocoder.yudao.module.education.service.reminder;
+
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.module.education.controller.app.reminder.vo.ExamReminderCreateReqVO;
+import cn.iocoder.yudao.module.education.controller.app.reminder.vo.ExamReminderRespVO;
+import cn.iocoder.yudao.module.education.dal.dataobject.ExamReminderDO;
+import cn.iocoder.yudao.module.education.dal.mysql.ExamReminderMapper;
+import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
+import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
+
+import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+
+@Service
+public class ExamReminderServiceImpl implements ExamReminderService {
+ private static final DateTimeFormatter DISPLAY_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
+ private final ExamReminderMapper mapper;
+ private final NotifyMessageSendApi notifyApi;
+
+ public ExamReminderServiceImpl(ExamReminderMapper mapper, NotifyMessageSendApi notifyApi) {
+ this.mapper = mapper;
+ this.notifyApi = notifyApi;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public ExamReminderRespVO create(ExamReminderCreateReqVO req, Long userId, Long tenantId) {
+ if (req.getRemindTime().isAfter(req.getExamTime())) {
+ throw exception(BAD_REQUEST, "提醒时间不能晚于考试时间");
+ }
+ ExamReminderDO row = mapper.selectOwnerByKey(tenantId, userId, req.getExamKey().trim());
+ if (row != null && !"CANCELLED".equals(row.getStatus())) {
+ return toResp(row);
+ }
+ if (row == null) {
+ row = new ExamReminderDO();
+ row.setTenantId(tenantId); row.setUserId(userId); row.setExamKey(req.getExamKey().trim());
+ row.setTitle(req.getTitle().trim()); row.setExamTime(req.getExamTime()); row.setRemindTime(req.getRemindTime());
+ row.setStatus("SCHEDULED"); row.setVersion(0); row.setCreator(String.valueOf(userId)); row.setUpdater(String.valueOf(userId));
+ mapper.insert(row);
+ } else {
+ row.setTitle(req.getTitle().trim()); row.setExamTime(req.getExamTime()); row.setRemindTime(req.getRemindTime());
+ row.setStatus("SCHEDULED"); row.setNotifyMessageId(null); row.setSentTime(null); row.setLastError(null);
+ row.setVersion(row.getVersion() + 1); mapper.updateById(row);
+ }
+ return toResp(row);
+ }
+
+ @Override public List list(Long userId, Long tenantId) {
+ return mapper.selectOwnerList(tenantId, userId).stream().map(this::toResp).toList();
+ }
+
+ @Override public void cancel(Long id, Long userId, Long tenantId) {
+ if (mapper.cancel(id, tenantId, userId) == 0) throw exception(BAD_REQUEST, "提醒不存在或不可取消");
+ }
+
+ @Override
+ public int sendDue(int limit) {
+ int sent = 0;
+ for (ExamReminderDO row : mapper.claimDue(LocalDateTime.now(), Math.max(1, Math.min(limit, 100)))) {
+ try {
+ TenantContextHolder.setTenantId(row.getTenantId());
+ NotifySendSingleToUserReqDTO dto = new NotifySendSingleToUserReqDTO();
+ dto.setUserId(row.getUserId()); dto.setTemplateCode("education_exam_reminder");
+ dto.setTemplateParams(Map.of("title", row.getTitle(), "examTime", DISPLAY_TIME.format(row.getExamTime())));
+ Long messageId = notifyApi.sendSingleMessageToMember(dto);
+ if (messageId == null) throw new IllegalStateException("notify template disabled");
+ mapper.markSent(row.getId(), messageId); sent++;
+ } catch (Exception ex) {
+ mapper.markFailed(row.getId(), bounded(ex.getMessage()));
+ } finally {
+ TenantContextHolder.clear();
+ }
+ }
+ return sent;
+ }
+
+ private String bounded(String message) { return message == null ? "unknown failure" : message.substring(0, Math.min(512, message.length())); }
+ private ExamReminderRespVO toResp(ExamReminderDO row) {
+ return ExamReminderRespVO.builder().id(row.getId()).examKey(row.getExamKey()).title(row.getTitle())
+ .examTime(row.getExamTime()).remindTime(row.getRemindTime()).status(row.getStatus()).build();
+ }
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressService.java
new file mode 100644
index 00000000..0f5b6d80
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressService.java
@@ -0,0 +1,11 @@
+package cn.iocoder.yudao.module.education.service.vocabulary;
+
+import cn.iocoder.yudao.module.education.controller.app.vocabulary.vo.*;
+
+import java.util.List;
+
+public interface VocabularyProgressService {
+ VocabularyProgressRespVO start(VocabularyStartReqVO req, Long userId, Long tenantId);
+ VocabularyProgressRespVO review(String key, VocabularyReviewReqVO req, Long userId, Long tenantId);
+ List due(Long userId, Long tenantId, int limit);
+}
diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressServiceImpl.java
new file mode 100644
index 00000000..2d4ca0d8
--- /dev/null
+++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/vocabulary/VocabularyProgressServiceImpl.java
@@ -0,0 +1,50 @@
+package cn.iocoder.yudao.module.education.service.vocabulary;
+
+import cn.iocoder.yudao.module.education.controller.app.vocabulary.vo.*;
+import cn.iocoder.yudao.module.education.dal.dataobject.VocabularyProgressDO;
+import cn.iocoder.yudao.module.education.dal.mysql.VocabularyProgressMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_FOUND;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+
+@Service
+public class VocabularyProgressServiceImpl implements VocabularyProgressService {
+ private final VocabularyProgressMapper mapper;
+ public VocabularyProgressServiceImpl(VocabularyProgressMapper mapper) { this.mapper = mapper; }
+ @Override @Transactional(rollbackFor = Exception.class)
+ public VocabularyProgressRespVO start(VocabularyStartReqVO req, Long userId, Long tenantId) {
+ VocabularyProgressDO row = new VocabularyProgressDO(); row.setTenantId(tenantId); row.setUserId(userId);
+ row.setVocabularyKey(req.getVocabularyKey().trim()); row.setDisplayText(req.getDisplayText().trim());
+ row.setCreator(String.valueOf(userId)); row.setUpdater(String.valueOf(userId)); mapper.upsert(row);
+ return toResp(mapper.selectOwnerKey(tenantId, userId, row.getVocabularyKey()));
+ }
+ @Override @Transactional(rollbackFor = Exception.class)
+ public VocabularyProgressRespVO review(String key, VocabularyReviewReqVO req, Long userId, Long tenantId) {
+ VocabularyProgressDO row = mapper.selectOwnerKey(tenantId, userId, key);
+ if (row == null) throw exception(NOT_FOUND, "词汇进度不存在");
+ int current = row.getMasteryLevel(); int days;
+ switch (req.getResult()) {
+ case "AGAIN" -> { current = Math.max(0, current - 1); days = 1; }
+ case "HARD" -> { days = Math.max(1, current); }
+ case "GOOD" -> { current = Math.min(5, current + 1); days = 1 << Math.min(current, 5); }
+ case "EASY" -> { current = Math.min(5, current + 2); days = Math.min(60, 3 * (1 << Math.min(current, 4))); }
+ default -> throw new IllegalArgumentException("unsupported review result");
+ }
+ row.setMasteryLevel(current); row.setReviewCount(row.getReviewCount() + 1); row.setLastReviewResult(req.getResult());
+ row.setNextReviewTime(LocalDateTime.now().plusDays(days)); row.setUpdater(String.valueOf(userId)); mapper.updateById(row);
+ return toResp(row);
+ }
+ @Override public List due(Long userId, Long tenantId, int limit) {
+ return mapper.selectDue(tenantId, userId, LocalDateTime.now(), Math.max(1, Math.min(limit, 100)))
+ .stream().map(this::toResp).toList();
+ }
+ private VocabularyProgressRespVO toResp(VocabularyProgressDO row) { return VocabularyProgressRespVO.builder()
+ .id(row.getId()).vocabularyKey(row.getVocabularyKey()).displayText(row.getDisplayText())
+ .masteryLevel(row.getMasteryLevel()).reviewCount(row.getReviewCount())
+ .lastReviewResult(row.getLastReviewResult()).nextReviewTime(row.getNextReviewTime()).build(); }
+}
diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4160__add_bounded_learning_wave.sql b/yudao-module-education/src/main/resources/db/migration/education/V4160__add_bounded_learning_wave.sql
new file mode 100644
index 00000000..abb85dbc
--- /dev/null
+++ b/yudao-module-education/src/main/resources/db/migration/education/V4160__add_bounded_learning_wave.sql
@@ -0,0 +1,154 @@
+-- EDU-014 bounded extended learning wave (PostgreSQL)
+--
+-- Education owns vocabulary review state, exam reminder scheduling, feedback,
+-- award orchestration state, and learning projections. Cross-module effects use
+-- the System Notify and Member Point public APIs; no media or AI entitlement is created.
+
+CREATE TABLE education_vocabulary_progress (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ tenant_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ vocabulary_key VARCHAR(128) NOT NULL,
+ display_text VARCHAR(256) NOT NULL,
+ mastery_level INTEGER NOT NULL DEFAULT 0 CHECK (mastery_level BETWEEN 0 AND 5),
+ review_count INTEGER NOT NULL DEFAULT 0 CHECK (review_count >= 0),
+ last_review_result VARCHAR(16),
+ next_review_time TIMESTAMP,
+ creator VARCHAR(64) DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT false,
+ CONSTRAINT ck_education_vocabulary_review_result
+ CHECK (last_review_result IS NULL OR last_review_result IN ('AGAIN', 'HARD', 'GOOD', 'EASY'))
+);
+COMMENT ON TABLE education_vocabulary_progress IS '教育-学生词汇学习与复习状态';
+CREATE UNIQUE INDEX uk_education_vocabulary_progress_owner_key
+ ON education_vocabulary_progress (tenant_id, user_id, vocabulary_key);
+CREATE INDEX idx_education_vocabulary_progress_due
+ ON education_vocabulary_progress (tenant_id, user_id, next_review_time)
+ WHERE deleted = false;
+
+CREATE TABLE education_exam_reminder (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ tenant_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ exam_key VARCHAR(128) NOT NULL,
+ title VARCHAR(128) NOT NULL,
+ exam_time TIMESTAMP NOT NULL,
+ remind_time TIMESTAMP NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'SCHEDULED',
+ notify_message_id BIGINT,
+ sent_time TIMESTAMP,
+ last_error VARCHAR(512),
+ version INTEGER NOT NULL DEFAULT 0,
+ creator VARCHAR(64) DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT false,
+ CONSTRAINT ck_education_exam_reminder_status
+ CHECK (status IN ('SCHEDULED', 'SENDING', 'SENT', 'CANCELLED', 'FAILED')),
+ CONSTRAINT ck_education_exam_reminder_time CHECK (remind_time <= exam_time)
+);
+COMMENT ON TABLE education_exam_reminder IS '教育-学生考试站内信提醒计划';
+CREATE UNIQUE INDEX uk_education_exam_reminder_owner_key
+ ON education_exam_reminder (tenant_id, user_id, exam_key);
+CREATE INDEX idx_education_exam_reminder_due
+ ON education_exam_reminder (status, remind_time, id)
+ WHERE deleted = false;
+
+CREATE TABLE education_learning_award (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ tenant_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ event_key VARCHAR(128) NOT NULL,
+ award_type VARCHAR(32) NOT NULL,
+ badge_code VARCHAR(64),
+ point_amount INTEGER NOT NULL DEFAULT 0 CHECK (point_amount BETWEEN 0 AND 100),
+ point_status VARCHAR(16) NOT NULL DEFAULT 'NOT_REQUIRED',
+ point_error VARCHAR(512),
+ creator VARCHAR(64) DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT false,
+ CONSTRAINT ck_education_learning_award_type
+ CHECK (award_type IN ('PRACTICE_COMPLETE', 'VOCABULARY_REVIEW', 'BADGE')),
+ CONSTRAINT ck_education_learning_award_point_status
+ CHECK (point_status IN ('NOT_REQUIRED', 'PENDING', 'PROCESSING', 'AWARDED', 'FAILED'))
+);
+COMMENT ON TABLE education_learning_award IS '教育-学习积分与徽章编排状态';
+CREATE UNIQUE INDEX uk_education_learning_award_event
+ ON education_learning_award (tenant_id, user_id, event_key, award_type);
+CREATE INDEX idx_education_learning_award_owner
+ ON education_learning_award (tenant_id, user_id, create_time DESC)
+ WHERE deleted = false;
+
+CREATE TABLE education_student_feedback (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ tenant_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ category VARCHAR(32) NOT NULL,
+ content VARCHAR(2000) NOT NULL,
+ contact VARCHAR(128),
+ status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
+ creator VARCHAR(64) DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT false,
+ CONSTRAINT ck_education_student_feedback_category
+ CHECK (category IN ('CONTENT', 'PRODUCT', 'BUG', 'OTHER')),
+ CONSTRAINT ck_education_student_feedback_status
+ CHECK (status IN ('OPEN', 'CLOSED'))
+);
+COMMENT ON TABLE education_student_feedback IS '教育-学生反馈';
+CREATE INDEX idx_education_student_feedback_owner
+ ON education_student_feedback (tenant_id, user_id, create_time DESC)
+ WHERE deleted = false;
+
+-- Install the notify template only when the System table is present. The stable
+-- template code is the public contract used through NotifyMessageSendApi.
+DO $$
+DECLARE
+ deleted_type TEXT;
+BEGIN
+ IF to_regclass('system_notify_template') IS NULL THEN
+ RETURN;
+ END IF;
+ SELECT data_type INTO deleted_type FROM information_schema.columns
+ WHERE table_schema = current_schema() AND table_name = 'system_notify_template' AND column_name = 'deleted';
+ IF deleted_type NOT IN ('boolean', 'smallint', 'integer', 'bigint') THEN
+ RAISE EXCEPTION 'system_notify_template.deleted has incompatible type %', deleted_type;
+ END IF;
+ IF EXISTS (SELECT 1 FROM system_notify_template WHERE code = 'education_exam_reminder'
+ AND (content <> '考试提醒:{title} 将于 {examTime} 开始。' OR deleted::TEXT NOT IN ('false', '0'))) THEN
+ RAISE EXCEPTION 'System notify template code education_exam_reminder has incompatible definition';
+ END IF;
+ IF deleted_type = 'boolean' THEN
+ EXECUTE $sql$
+ INSERT INTO system_notify_template
+ (id, name, code, nickname, content, type, params, status, remark,
+ creator, create_time, updater, update_time, deleted)
+ SELECT COALESCE(MAX(id), 0) + 1, '教育考试提醒', 'education_exam_reminder', '恭学教育',
+ '考试提醒:{title} 将于 {examTime} 开始。', 2, '["title","examTime"]', 0,
+ 'EDU-014:由 Education 经 System Notify 公共 API 发送',
+ 'migration', CURRENT_TIMESTAMP, 'migration', CURRENT_TIMESTAMP, false
+ FROM system_notify_template
+ WHERE NOT EXISTS (SELECT 1 FROM system_notify_template WHERE code = 'education_exam_reminder')
+ $sql$;
+ ELSE
+ EXECUTE $sql$
+ INSERT INTO system_notify_template
+ (id, name, code, nickname, content, type, params, status, remark,
+ creator, create_time, updater, update_time, deleted)
+ SELECT COALESCE(MAX(id), 0) + 1, '教育考试提醒', 'education_exam_reminder', '恭学教育',
+ '考试提醒:{title} 将于 {examTime} 开始。', 2, '["title","examTime"]', 0,
+ 'EDU-014:由 Education 经 System Notify 公共 API 发送',
+ 'migration', CURRENT_TIMESTAMP, 'migration', CURRENT_TIMESTAMP, 0
+ FROM system_notify_template
+ WHERE NOT EXISTS (SELECT 1 FROM system_notify_template WHERE code = 'education_exam_reminder')
+ $sql$;
+ END IF;
+END $$;
diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementServiceImplTest.java
new file mode 100644
index 00000000..63688b38
--- /dev/null
+++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/engagement/EducationEngagementServiceImplTest.java
@@ -0,0 +1,136 @@
+package cn.iocoder.yudao.module.education.service.engagement;
+
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAwardReqVO;
+import cn.iocoder.yudao.module.education.controller.app.engagement.vo.StudentFeedbackCreateReqVO;
+import cn.iocoder.yudao.module.education.dal.dataobject.LearningAwardDO;
+import cn.iocoder.yudao.module.education.dal.dataobject.StudentFeedbackDO;
+import cn.iocoder.yudao.module.education.dal.mysql.LearningAwardMapper;
+import cn.iocoder.yudao.module.education.dal.mysql.PracticeReportMapper;
+import cn.iocoder.yudao.module.education.dal.mysql.StudentFeedbackMapper;
+import cn.iocoder.yudao.module.education.dal.mysql.VocabularyProgressProjectionMapper;
+import cn.iocoder.yudao.module.education.integration.member.MemberPointAwardPort;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class EducationEngagementServiceImplTest {
+
+ private StudentFeedbackMapper feedbackMapper;
+ private LearningAwardMapper awardMapper;
+ private PracticeReportMapper reportMapper;
+ private VocabularyProgressProjectionMapper vocabularyMapper;
+ private MemberPointAwardPort pointPort;
+ private EducationEngagementServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ feedbackMapper = mock(StudentFeedbackMapper.class);
+ awardMapper = mock(LearningAwardMapper.class);
+ reportMapper = mock(PracticeReportMapper.class);
+ vocabularyMapper = mock(VocabularyProgressProjectionMapper.class);
+ pointPort = mock(MemberPointAwardPort.class);
+ service = new EducationEngagementServiceImpl(feedbackMapper, awardMapper, reportMapper, vocabularyMapper, pointPort);
+ }
+
+ @Test
+ void shouldCreateTenantOwnedFeedback() {
+ StudentFeedbackCreateReqVO reqVO = new StudentFeedbackCreateReqVO();
+ reqVO.setCategory(" bug ");
+ reqVO.setContent(" page is broken ");
+ reqVO.setContact(" student@example.test ");
+ when(feedbackMapper.insert(any(StudentFeedbackDO.class))).thenAnswer(invocation -> {
+ ((StudentFeedbackDO) invocation.getArgument(0)).setId(9L);
+ return 1;
+ });
+
+ assertEquals(9L, service.createFeedback(reqVO, 100L, 7L));
+ ArgumentCaptor captor = ArgumentCaptor.forClass(StudentFeedbackDO.class);
+ verify(feedbackMapper).insert(captor.capture());
+ assertEquals(7L, captor.getValue().getTenantId());
+ assertEquals(100L, captor.getValue().getUserId());
+ assertEquals("BUG", captor.getValue().getCategory());
+ assertEquals("page is broken", captor.getValue().getContent());
+ }
+
+ @Test
+ void shouldUseFixedPointPolicyAndStableAwardId() {
+ LearningAwardReqVO reqVO = new LearningAwardReqVO();
+ reqVO.setEventKey("practice:42");
+ reqVO.setAwardType("PRACTICE_COMPLETE");
+ LearningAwardDO saved = award(11L, "PENDING", 10);
+ when(awardMapper.selectOwnerEvent(7L, 100L, "practice:42", "PRACTICE_COMPLETE"))
+ .thenReturn(null, saved, award(11L, "AWARDED", 10));
+ when(awardMapper.insertIgnore(any())).thenReturn(1);
+ when(awardMapper.claimPoints(11L, 7L, 100L)).thenReturn(1);
+ when(awardMapper.finishPoints(11L, 7L, 100L, "AWARDED", null)).thenReturn(1);
+
+ var response = service.award(reqVO, 100L, 7L);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(LearningAwardDO.class);
+ verify(awardMapper).insertIgnore(captor.capture());
+ assertEquals(10, captor.getValue().getPointAmount());
+ verify(pointPort).addLearningPoints(100L, 10, 11L);
+ assertEquals("AWARDED", response.getPointStatus());
+ }
+
+ @Test
+ void shouldNotDoubleAwardCompletedDuplicate() {
+ LearningAwardReqVO reqVO = new LearningAwardReqVO();
+ reqVO.setEventKey("practice:42");
+ reqVO.setAwardType("PRACTICE_COMPLETE");
+ when(awardMapper.selectOwnerEvent(7L, 100L, "practice:42", "PRACTICE_COMPLETE"))
+ .thenReturn(award(11L, "AWARDED", 10), award(11L, "AWARDED", 10));
+
+ service.award(reqVO, 100L, 7L);
+
+ verify(awardMapper, never()).insertIgnore(any());
+ verify(pointPort, never()).addLearningPoints(any(), any(), any());
+ }
+
+ @Test
+ void shouldAggregateOwnSummaryAndBoundAnonymousLeaderboard() {
+ when(reportMapper.countSubmittedByOwner(7L, 100L)).thenReturn(4L);
+ when(reportMapper.sumCorrectByOwner(7L, 100L)).thenReturn(31L);
+ when(vocabularyMapper.countOwned(7L, 100L)).thenReturn(12L);
+ when(vocabularyMapper.countDue(7L, 100L)).thenReturn(3L);
+ when(awardMapper.sumAwardedPoints(7L, 100L)).thenReturn(40L);
+ when(awardMapper.countBadges(7L, 100L)).thenReturn(2L);
+ LearningAwardMapper.LeaderboardRow row = mock(LearningAwardMapper.LeaderboardRow.class);
+ when(row.getUserId()).thenReturn(123456L);
+ when(row.getLearningScore()).thenReturn(90L);
+ when(awardMapper.selectLeaderboard(7L, 50)).thenReturn(List.of(row));
+
+ var summary = service.getSummary(100L, 7L);
+ var leaderboard = service.getLeaderboard(7L, 999);
+
+ assertEquals(4L, summary.getSubmittedPractices());
+ assertEquals(12L, summary.getVocabularyItems());
+ verify(awardMapper).selectLeaderboard(7L, 50);
+ assertEquals("学员3456", leaderboard.getFirst().getStudentAlias());
+ assertFalse(leaderboard.getFirst().getStudentAlias().contains("123456"));
+ }
+
+ private static LearningAwardDO award(Long id, String status, int points) {
+ LearningAwardDO award = new LearningAwardDO();
+ award.setId(id);
+ award.setTenantId(7L);
+ award.setUserId(100L);
+ award.setEventKey("practice:42");
+ award.setAwardType("PRACTICE_COMPLETE");
+ award.setPointAmount(points);
+ award.setPointStatus(status);
+ return award;
+ }
+
+}
diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java
index 17ab06ed..a58ea1de 100644
--- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java
+++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java
@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema,
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
- .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4140", "4170");
+ .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4140", "4160", "4170");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name = 'education_idempotency'"))
@@ -211,7 +211,7 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema,
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
- .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4140", "4170");
+ .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4140", "4160", "4170");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +
@@ -228,6 +228,11 @@ class EducationFlywayMigrationIntegrationTest {
"education_practice_session",
"education_wrong_question",
"education_wrong_question_idempotency");
+ assertThat(queryLong(schema,
+ "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
+ "AND table_name IN ('education_vocabulary_progress', 'education_exam_reminder', " +
+ "'education_learning_award', 'education_student_feedback')"))
+ .isEqualTo(4L);
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name IN ('education_answer_idempotency', 'education_submit_idempotency')"))
diff --git a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/enums/point/MemberPointBizTypeEnum.java b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/enums/point/MemberPointBizTypeEnum.java
index 885b9f98..832fd705 100644
--- a/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/enums/point/MemberPointBizTypeEnum.java
+++ b/yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/enums/point/MemberPointBizTypeEnum.java
@@ -25,7 +25,9 @@ public enum MemberPointBizTypeEnum implements ArrayValuable {
ORDER_GIVE(21, "订单积分奖励", "下单获得 {} 积分", true), // 支付订单时,赠送积分
ORDER_GIVE_CANCEL(22, "订单积分奖励(整单取消)", "订单取消,退还 {} 积分", false), // ORDER_GIVE 的取消
- ORDER_GIVE_CANCEL_ITEM(23, "订单积分奖励(单个退款)", "订单退款,扣除赠送的 {} 积分", false) // ORDER_GIVE 的取消
+ ORDER_GIVE_CANCEL_ITEM(23, "订单积分奖励(单个退款)", "订单退款,扣除赠送的 {} 积分", false), // ORDER_GIVE 的取消
+
+ EDUCATION_LEARNING(31, "教育学习奖励", "完成学习任务获得 {} 积分", true)
;
/**