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) ; /**