feat(education): deliver bounded EDU-014 learning wave
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/` 是过时归档;两者都不是运行时交付入口。
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
<artifactId>yudao-module-member</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.boot</groupId>
|
||||
<artifactId>yudao-spring-boot-starter-job</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test 测试相关 -->
|
||||
<dependency>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Long> createFeedback(@Valid @RequestBody StudentFeedbackCreateReqVO reqVO) {
|
||||
LoginUser student = getStudentPrincipal();
|
||||
return success(engagementService.createFeedback(reqVO, student.getId(), getTenantId()));
|
||||
}
|
||||
|
||||
@PostMapping("/award")
|
||||
@Operation(summary = "领取受控学习奖励(幂等)")
|
||||
public CommonResult<LearningAwardRespVO> award(@Valid @RequestBody LearningAwardReqVO reqVO) {
|
||||
LoginUser student = getStudentPrincipal();
|
||||
return success(engagementService.award(reqVO, student.getId(), getTenantId()));
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
@Operation(summary = "获取本人学习汇总")
|
||||
public CommonResult<LearningSummaryRespVO> summary() {
|
||||
LoginUser student = getStudentPrincipal();
|
||||
return success(engagementService.getSummary(student.getId(), getTenantId()));
|
||||
}
|
||||
|
||||
@GetMapping("/leaderboard")
|
||||
@Operation(summary = "获取租户内匿名学习榜")
|
||||
public CommonResult<List<LeaderboardItemRespVO>> 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ExamReminderRespVO> create(@Valid @RequestBody ExamReminderCreateReqVO req) {
|
||||
return success(service.create(req, principal().getId(), TenantContextHolder.getRequiredTenantId()));
|
||||
}
|
||||
@GetMapping @Operation(summary = "查询我的考试提醒")
|
||||
public CommonResult<List<ExamReminderRespVO>> list() {
|
||||
return success(service.list(principal().getId(), TenantContextHolder.getRequiredTenantId()));
|
||||
}
|
||||
@DeleteMapping("/{id}") @Operation(summary = "取消考试提醒")
|
||||
public CommonResult<Boolean> 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<VocabularyProgressRespVO> start(@Valid @RequestBody VocabularyStartReqVO req) {
|
||||
return success(service.start(req, principal().getId(), tenant()));
|
||||
}
|
||||
@PostMapping("/progress/{key}/review") @Operation(summary = "记录词汇复习结果")
|
||||
public CommonResult<VocabularyProgressRespVO> review(@PathVariable String key, @Valid @RequestBody VocabularyReviewReqVO req) {
|
||||
return success(service.review(key, req, principal().getId(), tenant()));
|
||||
}
|
||||
@GetMapping("/due") @Operation(summary = "查询到期词汇")
|
||||
public CommonResult<List<VocabularyProgressRespVO>> 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ExamReminderDO> {
|
||||
default List<ExamReminderDO> selectOwnerList(Long tenantId, Long userId) {
|
||||
return selectList(new LambdaQueryWrapper<ExamReminderDO>()
|
||||
.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<ExamReminderDO>()
|
||||
.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<ExamReminderDO>()
|
||||
.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<ExamReminderDO> claimDue(LocalDateTime now, int limit);
|
||||
|
||||
@TenantIgnore
|
||||
default int markSent(Long id, Long notifyMessageId) {
|
||||
return update(null, new LambdaUpdateWrapper<ExamReminderDO>().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<ExamReminderDO>().eq(ExamReminderDO::getId, id)
|
||||
.eq(ExamReminderDO::getStatus, "SENDING").set(ExamReminderDO::getStatus, "FAILED")
|
||||
.set(ExamReminderDO::getLastError, error));
|
||||
}
|
||||
}
|
||||
@@ -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<LearningAwardDO> {
|
||||
|
||||
@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<LearningAwardDO>().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<LearningAwardDO>().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<LearningAwardDO>().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<LeaderboardRow> selectLeaderboard(Long tenantId, int limit);
|
||||
|
||||
interface LeaderboardRow {
|
||||
Long getUserId();
|
||||
Long getLearningScore();
|
||||
}
|
||||
}
|
||||
@@ -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<PracticeReportDO> {
|
||||
@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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<StudentFeedbackDO> {
|
||||
default List<StudentFeedbackDO> selectOwnerList(Long tenantId, Long userId) {
|
||||
return selectList(new LambdaQueryWrapper<StudentFeedbackDO>()
|
||||
.eq(StudentFeedbackDO::getTenantId, tenantId).eq(StudentFeedbackDO::getUserId, userId)
|
||||
.eq(StudentFeedbackDO::getDeleted, false).orderByDesc(StudentFeedbackDO::getCreateTime)
|
||||
.last("LIMIT 100"));
|
||||
}
|
||||
}
|
||||
@@ -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<VocabularyProgressDO> {
|
||||
@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<VocabularyProgressDO>().eq(VocabularyProgressDO::getTenantId, tenantId)
|
||||
.eq(VocabularyProgressDO::getUserId, userId).eq(VocabularyProgressDO::getVocabularyKey, key)
|
||||
.eq(VocabularyProgressDO::getDeleted, false));
|
||||
}
|
||||
default List<VocabularyProgressDO> selectDue(Long tenantId, Long userId, LocalDateTime now, int limit) {
|
||||
return selectList(new LambdaQueryWrapper<VocabularyProgressDO>().eq(VocabularyProgressDO::getTenantId, tenantId)
|
||||
.eq(VocabularyProgressDO::getUserId, userId).eq(VocabularyProgressDO::getDeleted, false)
|
||||
.le(VocabularyProgressDO::getNextReviewTime, now).orderByAsc(VocabularyProgressDO::getNextReviewTime)
|
||||
.last("LIMIT " + limit));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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, "不支持的反馈类型:{}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<LeaderboardItemRespVO> getLeaderboard(Long tenantId, int limit);
|
||||
|
||||
}
|
||||
@@ -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<String> FEEDBACK_CATEGORIES = Set.of("CONTENT", "PRODUCT", "BUG", "OTHER");
|
||||
private static final Map<String, AwardPolicy> 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<LeaderboardItemRespVO> getLeaderboard(Long tenantId, int limit) {
|
||||
int boundedLimit = Math.max(1, Math.min(limit, 50));
|
||||
List<LearningAwardMapper.LeaderboardRow> 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) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<ExamReminderRespVO> list(Long userId, Long tenantId);
|
||||
void cancel(Long id, Long userId, Long tenantId);
|
||||
int sendDue(int limit);
|
||||
}
|
||||
@@ -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<ExamReminderRespVO> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<VocabularyProgressRespVO> due(Long userId, Long tenantId, int limit);
|
||||
}
|
||||
@@ -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<VocabularyProgressRespVO> 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(); }
|
||||
}
|
||||
@@ -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 $$;
|
||||
@@ -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<StudentFeedbackDO> 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<LearningAwardDO> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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')"))
|
||||
|
||||
@@ -25,7 +25,9 @@ public enum MemberPointBizTypeEnum implements ArrayValuable<Integer> {
|
||||
|
||||
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)
|
||||
;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user