feat(education): add authoring, import, operations, supervision, badges, appearance and activation codes

This commit is contained in:
2026-08-01 12:19:37 +08:00
parent c409cf47e5
commit a841652fd4
162 changed files with 15151 additions and 96 deletions

View File

@@ -45,6 +45,23 @@
<artifactId>yudao-spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-module-promotion</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-module-trade</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>

View File

@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.education.controller.admin.activationcode;
import cn.iocoder.yudao.framework.common.pojo.*;
import cn.iocoder.yudao.module.education.controller.admin.activationcode.vo.ActivationCodeAdminVOs.*;
import cn.iocoder.yudao.module.education.service.activationcode.ActivationCodeService;
import io.swagger.v3.oas.annotations.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name="管理后台 - 学习激活码") @RestController @RequestMapping("/education/activation-code") @Validated
public class ActivationCodeAdminController {
private final ActivationCodeService service;
public ActivationCodeAdminController(ActivationCodeService service){this.service=service;}
@GetMapping("/batch/page") @Operation(summary="分页查询激活码批次")
@PreAuthorize("@ss.hasPermission('education:activation-code:query')")
public CommonResult<PageResult<BatchResp>> batchPage(@Valid BatchPageReq req){return success(service.batchPage(req));}
@PostMapping("/batch") @Operation(summary="创建激活码批次")
@PreAuthorize("@ss.hasPermission('education:activation-code:manage')")
public CommonResult<BatchResp> create(@Valid @RequestBody BatchSaveReq req){return success(service.createBatch(req,getLoginUserId()));}
@PutMapping("/batch/{id}") @Operation(summary="修改激活码批次")
@PreAuthorize("@ss.hasPermission('education:activation-code:manage')")
public CommonResult<BatchResp> update(@PathVariable Long id,@Valid @RequestBody BatchSaveReq req){return success(service.updateBatch(id,req,getLoginUserId()));}
@PostMapping("/batch/{id}/generate") @Operation(summary="安全生成激活码(明文仅在本次响应返回)")
@PreAuthorize("@ss.hasPermission('education:activation-code:generate')")
public CommonResult<GenerateResp> generate(@PathVariable Long id,@Valid @RequestBody GenerateReq req){return success(service.generate(id,req,getLoginUserId()));}
@GetMapping("/code/page") @Operation(summary="分页查询激活码掩码与兑换状态")
@PreAuthorize("@ss.hasPermission('education:activation-code:query')")
public CommonResult<PageResult<CodeResp>> codePage(@Valid CodePageReq req){return success(service.codePage(req));}
@PutMapping("/code/{id}/disable") @Operation(summary="停用未兑换激活码")
@PreAuthorize("@ss.hasPermission('education:activation-code:manage')")
public CommonResult<CodeResp> disable(@PathVariable Long id,@RequestParam Integer expectedVersion){return success(service.disable(id,expectedVersion));}
}

View File

@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.education.controller.admin.activationcode.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
public final class ActivationCodeAdminVOs {
private ActivationCodeAdminVOs() {}
@Data @EqualsAndHashCode(callSuper = true)
public static class BatchPageReq extends PageParam { @Size(max=120) private String keyword; private String status; }
@Data public static class BatchSaveReq {
@NotBlank @Size(max=120) private String name;
@NotNull @Positive private Long productSpuId;
@NotNull @Min(0) @Max(3650) private Integer durationDays;
@Pattern(regexp="[A-Za-z0-9]{0,12}") private String codePrefix;
private String status;
private Integer expectedVersion;
}
@Data @Builder @NoArgsConstructor @AllArgsConstructor public static class BatchResp {
private Long id; private String name; private Long productSpuId; private Integer durationDays;
private String codePrefix; private String status; private Integer totalCount; private Integer redeemedCount;
private Integer version; private LocalDateTime createTime; private LocalDateTime updateTime;
}
@Data public static class GenerateReq { @NotNull @Min(1) @Max(1000) private Integer count; @NotNull private Integer expectedVersion; }
@Data @Builder @NoArgsConstructor @AllArgsConstructor public static class GeneratedCodeResp {
private Long id; private String code; private String codeMasked; private Integer version;
}
@Data @Builder @NoArgsConstructor @AllArgsConstructor public static class GenerateResp {
private BatchResp batch; private List<GeneratedCodeResp> codes;
}
@Data @EqualsAndHashCode(callSuper = true)
public static class CodePageReq extends PageParam { private Long batchId; private String status; }
@Data @Builder @NoArgsConstructor @AllArgsConstructor public static class CodeResp {
private Long id; private Long batchId; private String codeMasked; private String status; private Long redeemedBy;
private LocalDateTime redeemedAt; private Long entitlementId; private Integer version; private LocalDateTime createTime;
}
}

View File

@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.education.controller.admin.appearance;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.controller.admin.appearance.vo.TenantAppearanceVOs.*;
import cn.iocoder.yudao.module.education.service.appearance.TenantAppearanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - 教育租户外观")
@RestController
@RequestMapping("/education/tenant-appearance")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class TenantAppearanceAdminController {
private final TenantAppearanceService service;
public TenantAppearanceAdminController(TenantAppearanceService service) { this.service = service; }
@GetMapping
@Operation(summary = "获取当前租户外观、公开设置与主题草稿")
@PreAuthorize("@ss.hasPermission('education:tenant-appearance:query')")
public CommonResult<AppearanceResp> get() { return success(service.getAppearance()); }
@PutMapping("/branding")
@Operation(summary = "更新品牌外观")
@PreAuthorize("@ss.hasPermission('education:tenant-appearance:branding')")
public CommonResult<AppearanceResp> updateBranding(@Valid @RequestBody BrandingSaveReq req) {
return success(service.updateBranding(req, getLoginUserId()));
}
@PutMapping("/settings")
@Operation(summary = "更新学生端与管理端功能设置")
@PreAuthorize("@ss.hasPermission('education:tenant-appearance:settings')")
public CommonResult<AppearanceResp> updateSettings(@Valid @RequestBody SettingsSaveReq req) {
return success(service.updateSettings(req, getLoginUserId()));
}
@GetMapping("/theme-templates")
@Operation(summary = "获取平台主题模板")
@PreAuthorize("@ss.hasPermission('education:tenant-appearance:query')")
public CommonResult<List<ThemeTemplateResp>> templates() { return success(service.getThemeTemplates()); }
@PostMapping("/theme/preview")
@Operation(summary = "保存主题预览草稿")
@PreAuthorize("@ss.hasPermission('education:tenant-appearance:theme')")
public CommonResult<AppearanceResp> preview(@Valid @RequestBody ThemePreviewReq req) {
return success(service.previewTheme(req, getLoginUserId()));
}
@PostMapping("/theme/publish")
@Operation(summary = "发布主题草稿或模板")
@PreAuthorize("@ss.hasPermission('education:tenant-appearance:theme')")
public CommonResult<AppearanceResp> publish(@Valid @RequestBody ThemePublishReq req) {
return success(service.publishTheme(req, getLoginUserId()));
}
}

View File

@@ -0,0 +1,110 @@
package cn.iocoder.yudao.module.education.controller.admin.appearance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.Map;
public final class TenantAppearanceVOs {
private TenantAppearanceVOs() {}
@Data
public static class BrandingSaveReq {
@NotBlank @Size(max = 120) private String brandName;
@Size(max = 80) private String shortName;
@Size(max = 240) private String slogan;
@Size(max = 160) private String orgName;
@Size(max = 500) private String logoUrl;
@Size(max = 500) private String faviconUrl;
@Size(max = 120) private String serviceWechat;
@Size(max = 120) private String serviceAccountName;
@NotNull @Min(0) private Integer expectedVersion;
}
@Data
public static class SettingsSaveReq {
private Map<String, Object> featureFlags;
private Map<String, Object> adminFeatureFlags;
private Map<String, Object> publicConfig;
@NotNull @Min(0) private Integer expectedVersion;
}
@Data
public static class ThemePreviewReq {
@NotBlank @Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9_-]{1,63}") private String templateCode;
private Map<String, Object> theme;
private Map<String, Object> publicAssets;
@NotNull @Min(0) private Integer expectedVersion;
}
@Data
public static class ThemePublishReq {
private Boolean useDraft;
@Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9_-]{1,63}") private String templateCode;
private Map<String, Object> theme;
private Map<String, Object> publicAssets;
@NotNull @Min(0) private Integer expectedVersion;
}
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public static class AppearanceResp {
private Long tenantId;
private String systemTenantName;
private String brandName;
private String shortName;
private String slogan;
private String orgName;
private String logoUrl;
private String faviconUrl;
private String serviceWechat;
private String serviceAccountName;
private Map<String, Object> featureFlags;
private Map<String, Object> adminFeatureFlags;
private Map<String, Object> publicConfig;
private String activeTemplateCode;
private Map<String, Object> activeTheme;
private Map<String, Object> activePublicAssets;
private String draftTemplateCode;
private Map<String, Object> draftTheme;
private Map<String, Object> draftPublicAssets;
private String themeStatus;
private LocalDateTime publishedTime;
private Long publishedBy;
private String publishedByNickname;
private Long draftUpdatedBy;
private String draftUpdatedByNickname;
private Integer version;
private LocalDateTime updateTime;
}
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public static class ThemeTemplateResp {
private String code;
private String name;
private String description;
private String previewImageUrl;
private Map<String, Object> theme;
private Map<String, Object> publicAssets;
private Integer sortOrder;
}
@Schema(description = "学生端公开租户外观与功能配置")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public static class PublicAppearanceResp {
private Long tenantId;
private String brandName;
private String shortName;
private String slogan;
private String logoUrl;
private String faviconUrl;
private String serviceWechat;
private String serviceAccountName;
private Map<String, Object> theme;
private Map<String, Object> publicAssets;
private Map<String, Object> featureFlags;
private Map<String, Object> publicConfig;
private LocalDateTime publishedTime;
}
}

View File

@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.education.controller.admin.badge;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.*;
import cn.iocoder.yudao.module.education.service.badge.BadgeAdminService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - 学习徽章")
@RestController
@RequestMapping("/education/badge")
@Validated
public class BadgeAdminController {
private final BadgeAdminService service;
public BadgeAdminController(BadgeAdminService service) { this.service = service; }
@GetMapping("/definition/page") @Operation(summary = "分页查询徽章定义")
@PreAuthorize("@ss.hasPermission('education:badge:query')")
public CommonResult<PageResult<DefinitionResp>> definitionPage(@Valid DefinitionPageReq req) {
return success(service.getDefinitionPage(req));
}
@PostMapping("/definition") @Operation(summary = "创建徽章定义")
@PreAuthorize("@ss.hasPermission('education:badge:write')")
public CommonResult<DefinitionResp> create(@Valid @RequestBody DefinitionSaveReq req) {
return success(service.createDefinition(req, getLoginUserId()));
}
@PutMapping("/definition/{id}") @Operation(summary = "修改徽章定义")
@PreAuthorize("@ss.hasPermission('education:badge:write')")
public CommonResult<DefinitionResp> update(@PathVariable Long id, @Valid @RequestBody DefinitionSaveReq req) {
return success(service.updateDefinition(id, req, getLoginUserId()));
}
@GetMapping("/grant/page") @Operation(summary = "分页查询徽章发放记录")
@PreAuthorize("@ss.hasPermission('education:badge:query')")
public CommonResult<PageResult<GrantResp>> grantPage(@Valid GrantPageReq req) {
return success(service.getGrantPage(req));
}
@PostMapping("/grant") @Operation(summary = "向会员手工发放徽章")
@PreAuthorize("@ss.hasPermission('education:badge:grant')")
public CommonResult<GrantResp> grant(@Valid @RequestBody ManualGrantReq req) {
return success(service.grant(req, getLoginUserId()));
}
}

View File

@@ -0,0 +1,111 @@
package cn.iocoder.yudao.module.education.controller.admin.badge.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
public final class BadgeAdminVOs {
private BadgeAdminVOs() {}
@Data @EqualsAndHashCode(callSuper = true)
public static class DefinitionPageReq extends PageParam {
@Size(max = 120) private String keyword;
private String category;
private String triggerType;
private String status;
}
@Data
public static class DefinitionSaveReq {
@NotBlank @Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9_-]{1,63}") private String code;
@NotBlank @Size(max = 120) private String name;
@Size(max = 1000) private String description;
private String category;
@Size(max = 500) private String iconUrl;
@Min(0) @Max(100) private Integer level;
private String triggerType;
private String metric;
private String operator;
@DecimalMin("0") private BigDecimal thresholdValue;
private Map<String, Object> conditionExtra;
@Min(0) @Max(100000) private Integer sortOrder;
private String status;
private Integer expectedVersion;
}
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public static class DefinitionResp {
private Long id;
private String code;
private String name;
private String description;
private String category;
private String iconUrl;
private Integer level;
private String triggerType;
private String metric;
private String operator;
private BigDecimal thresholdValue;
private Map<String, Object> conditionExtra;
private Integer sortOrder;
private String status;
private Integer version;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@Data @EqualsAndHashCode(callSuper = true)
public static class GrantPageReq extends PageParam {
private Long userId;
private Long badgeDefinitionId;
private String awardSource;
}
@Data
public static class ManualGrantReq {
@NotNull private Long badgeDefinitionId;
@NotNull private Long userId;
@Size(max = 1000) private String note;
private Map<String, Object> evidence;
}
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public static class GrantResp {
private Long id;
private Long userId;
private String userNickname;
private Long badgeDefinitionId;
private String badgeCode;
private String badgeName;
private String badgeCategory;
private String awardSource;
private String grantNote;
private Map<String, Object> grantEvidence;
private Long grantedBy;
private String grantedByNickname;
private String notifyStatus;
private String notifyError;
private LocalDateTime createTime;
}
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public static class MemberBadgeResp {
private Long badgeDefinitionId;
private String code;
private String name;
private String description;
private String category;
private String iconUrl;
private Integer level;
private boolean earned;
private String awardSource;
private Map<String, Object> evidence;
private LocalDateTime grantedTime;
@JsonIgnore private Long grantId;
}
}

View File

@@ -1,6 +1,9 @@
package cn.iocoder.yudao.module.education.controller.admin.blueprint;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.education.controller.admin.blueprint.vo.*;
import cn.iocoder.yudao.module.education.service.blueprint.authoring.*;
import jakarta.validation.Valid;
@@ -19,11 +22,20 @@ public class PracticeBlueprintAuthoringController {
private final PracticeBlueprintAuthoringService service;
public PracticeBlueprintAuthoringController(PracticeBlueprintAuthoringService service) { this.service = service; }
@GetMapping("/page") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:query')")
public CommonResult<PageResult<PracticeBlueprintAuthoringRespVO>> getPage(@Valid PageParam pageParam) {
return success(BeanUtils.toBean(service.getPage(pageParam), PracticeBlueprintAuthoringRespVO.class));
}
@GetMapping("/get") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:query')")
public CommonResult<PracticeBlueprintAuthoringRespVO> get(@RequestParam("id") Long id) {
return success(BeanUtils.toBean(service.get(id), PracticeBlueprintAuthoringRespVO.class));
}
@PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:author')")
public CommonResult<Long> create(@Valid @RequestBody PracticeBlueprintDraftReqVO request) {
return success(service.createDraft(toCommand(request, null)));
}
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:author')")
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:update')")
public CommonResult<Integer> revise(@PathVariable Long id, @Valid @RequestBody PracticeBlueprintReviseReqVO request) {
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion())));
}

View File

@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.education.controller.admin.blueprint.vo;
import lombok.Data;
@Data
public class PracticeBlueprintAuthoringRespVO {
private Long id;
private String mode;
private Long entryId;
private Long nodeId;
private Long collectionId;
private Integer questionLimit;
private Integer durationMinutes;
private Integer eligibleCount;
private Integer totalCount;
private String availableTypes;
private String availableDifficulties;
private Integer minQuestions;
private Integer maxQuestions;
private Integer suggestedCount;
private Boolean isActive;
private String publicationStatus;
private Integer authoringVersion;
}

View File

@@ -1,8 +1,12 @@
package cn.iocoder.yudao.module.education.controller.admin.category;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryAuthoringRespVO;
import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryDraftReqVO;
import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryReviseReqVO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringCommand;
import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringService;
import jakarta.validation.Valid;
@@ -23,6 +27,21 @@ public class CategoryAuthoringController {
public CategoryAuthoringController(CategoryAuthoringService service) { this.service = service; }
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('education:category:query')")
public CommonResult<PageResult<CategoryAuthoringRespVO>> page(@Valid PageParam pageParam) {
PageResult<CategoryDO> source = service.getPage(pageParam);
return success(new PageResult<>(source.getList().stream()
.map(CategoryAuthoringRespVO::from)
.toList(), source.getTotal()));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('education:category:query')")
public CommonResult<CategoryAuthoringRespVO> get(@PathVariable("id") Long id) {
return success(CategoryAuthoringRespVO.from(service.get(id)));
}
@PostMapping("/drafts")
@PreAuthorize("@ss.hasPermission('education:category:author')")
public CommonResult<Long> create(@Valid @RequestBody CategoryDraftReqVO request) {

View File

@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.education.controller.admin.category.vo;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
import lombok.Data;
@Data
public class CategoryAuthoringRespVO {
private Long id;
private Long subjectId;
private String legacyNodeId;
private String name;
private Boolean active;
private String status;
private Integer authoringVersion;
private Integer sortOrder;
public static CategoryAuthoringRespVO from(CategoryDO source) {
CategoryAuthoringRespVO target = new CategoryAuthoringRespVO();
target.id = source.getId();
target.subjectId = source.getSubjectId();
target.legacyNodeId = source.getLegacyNodeId();
target.name = source.getName();
target.active = source.getIsActive();
target.status = source.getPublicationStatus();
target.authoringVersion = source.getAuthoringVersion();
target.sortOrder = source.getSortOrder();
return target;
}
}

View File

@@ -1,6 +1,9 @@
package cn.iocoder.yudao.module.education.controller.admin.collection;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.*;
import cn.iocoder.yudao.module.education.service.collection.authoring.*;
import jakarta.validation.Valid;
@@ -19,15 +22,24 @@ public class QuestionCollectionAuthoringController {
private final QuestionCollectionAuthoringService service;
public QuestionCollectionAuthoringController(QuestionCollectionAuthoringService service) { this.service = service; }
@GetMapping("/page") @PreAuthorize("@ss.hasPermission('education:collection:query')")
public CommonResult<PageResult<QuestionCollectionAuthoringRespVO>> getPage(@Valid PageParam pageParam) {
return success(BeanUtils.toBean(service.getPage(pageParam), QuestionCollectionAuthoringRespVO.class));
}
@GetMapping("/get") @PreAuthorize("@ss.hasPermission('education:collection:query')")
public CommonResult<QuestionCollectionAuthoringRespVO> get(@RequestParam("id") Long id) {
return success(BeanUtils.toBean(service.get(id), QuestionCollectionAuthoringRespVO.class));
}
@PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:collection:author')")
public CommonResult<Long> create(@Valid @RequestBody QuestionCollectionDraftReqVO request) {
return success(service.createDraft(toCommand(request, null)));
}
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:collection:author')")
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:collection:update')")
public CommonResult<Integer> revise(@PathVariable Long id, @Valid @RequestBody QuestionCollectionReviseReqVO request) {
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion())));
}
@PutMapping("/{id}/membership") @PreAuthorize("@ss.hasPermission('education:collection:author')")
@PutMapping("/{id}/membership") @PreAuthorize("@ss.hasPermission('education:collection:update')")
public CommonResult<Integer> replaceMembership(@PathVariable Long id, @Valid @RequestBody QuestionCollectionMembershipReqVO request) {
return success(service.replaceMembership(id, request.getQuestionIds(), request.getExpectedAuthoringVersion()));
}

View File

@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.education.controller.admin.collection.vo;
import lombok.Data;
@Data
public class QuestionCollectionAuthoringRespVO {
private Long id;
private Long entryId;
private Long nodeId;
private String name;
private String title;
private String collectionType;
private Integer questionCount;
private Integer durationMinutes;
private String accessRules;
private String accessMode;
private Boolean isHidden;
private Boolean isActive;
private Integer sortOrder;
private String metadata;
private String publicationStatus;
private Integer authoringVersion;
private Integer membershipVersion;
}

View File

@@ -1,6 +1,10 @@
package cn.iocoder.yudao.module.education.controller.admin.contentnode;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeAuthoringRespVO;
import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeDraftReqVO;
import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeReviseReqVO;
import cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand;
@@ -22,6 +26,18 @@ public class ContentNodeAuthoringController {
private final ContentNodeAuthoringService service;
public ContentNodeAuthoringController(ContentNodeAuthoringService service) { this.service = service; }
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('education:content-node:query')")
public CommonResult<PageResult<ContentNodeAuthoringRespVO>> getPage(@Valid PageParam pageParam) {
return success(BeanUtils.toBean(service.getPage(pageParam), ContentNodeAuthoringRespVO.class));
}
@GetMapping("/get")
@PreAuthorize("@ss.hasPermission('education:content-node:query')")
public CommonResult<ContentNodeAuthoringRespVO> get(@RequestParam("id") Long id) {
return success(BeanUtils.toBean(service.get(id), ContentNodeAuthoringRespVO.class));
}
@PostMapping("/drafts")
@PreAuthorize("@ss.hasPermission('education:content-node:author')")
public CommonResult<Long> create(@Valid @RequestBody ContentNodeDraftReqVO request) {
@@ -29,7 +45,7 @@ public class ContentNodeAuthoringController {
}
@PutMapping("/{id}/draft")
@PreAuthorize("@ss.hasPermission('education:content-node:author')")
@PreAuthorize("@ss.hasPermission('education:content-node:update')")
public CommonResult<Integer> revise(@PathVariable("id") Long id,
@Valid @RequestBody ContentNodeReviseReqVO request) {
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion())));

View File

@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.education.controller.admin.contentnode.vo;
import lombok.Data;
@Data
public class ContentNodeAuthoringRespVO {
private Long id;
private Long entryId;
private Long parentId;
private String name;
private String title;
private String nodeType;
private String markerType;
private Integer depth;
private Boolean isLeaf;
private Boolean isSelectable;
private Boolean isHidden;
private Boolean isActive;
private String publicationStatus;
private Integer authoringVersion;
private Integer sortOrder;
private String metadata;
}

View File

@@ -14,13 +14,16 @@ public class QuestionImportJobRespVO {
private Integer previewQuestionCount;
private Integer importedQuestionCount;
private String failureCode;
private String previewPayload;
private String resultSummary;
public static QuestionImportJobRespVO from(QuestionImportJobProjection source) {
QuestionImportJobRespVO target = new QuestionImportJobRespVO();
target.id = source.id(); target.status = source.status(); target.scanStatus = source.scanStatus();
target.parserStatus = source.parserStatus(); target.fileName = source.fileName(); target.fileSize = source.fileSize();
target.previewQuestionCount = source.previewQuestionCount(); target.importedQuestionCount = source.importedQuestionCount();
target.failureCode = source.failureCode();
target.failureCode = source.failureCode(); target.previewPayload = source.previewPayload();
target.resultSummary = source.resultSummary();
return target;
}
}

View File

@@ -0,0 +1,85 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo.*;
import cn.iocoder.yudao.module.education.service.engagement.LearningOperationsAdminService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - 会员学习运营")
@RestController
@RequestMapping("/education/learning-operations")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class LearningOperationsAdminController {
private final LearningOperationsAdminService service;
public LearningOperationsAdminController(LearningOperationsAdminService service) {
this.service = service;
}
@GetMapping("/overview")
@Operation(summary = "获取租户学习运营汇总")
@PreAuthorize("@ss.hasPermission('education:learning-operations:query')")
public CommonResult<LearningOperationsOverviewRespVO> overview() {
return success(service.getOverview());
}
@GetMapping("/feedback/page")
@Operation(summary = "分页查询学生反馈")
@PreAuthorize("@ss.hasPermission('education:learning-operations:query')")
public CommonResult<PageResult<StudentFeedbackAdminRespVO>> feedbackPage(
@Valid StudentFeedbackPageReqVO reqVO) {
return success(service.getFeedbackPage(reqVO));
}
@GetMapping("/feedback/{feedbackId}/events")
@Operation(summary = "查询反馈处理事件")
@PreAuthorize("@ss.hasPermission('education:learning-operations:query')")
public CommonResult<List<StudentFeedbackEventRespVO>> feedbackEvents(@PathVariable Long feedbackId) {
return success(service.getFeedbackEvents(feedbackId));
}
@PutMapping("/feedback/{feedbackId}/handle")
@Operation(summary = "处理学生反馈")
@PreAuthorize("@ss.hasPermission('education:learning-operations:feedback')")
public CommonResult<StudentFeedbackAdminRespVO> handleFeedback(@PathVariable Long feedbackId,
@Valid @RequestBody StudentFeedbackHandleReqVO reqVO) {
return success(service.handleFeedback(feedbackId, reqVO, getLoginUserId()));
}
@PostMapping("/feedback/{feedbackId}/reward")
@Operation(summary = "为已解决反馈发放积分奖励")
@PreAuthorize("@ss.hasPermission('education:learning-operations:reward')")
public CommonResult<StudentFeedbackAdminRespVO> rewardFeedback(@PathVariable Long feedbackId,
@Valid @RequestBody StudentFeedbackRewardReqVO reqVO) {
service.scheduleReward(feedbackId, reqVO);
return success(service.deliverReward(feedbackId));
}
@PostMapping("/feedback/{feedbackId}/reward/retry")
@Operation(summary = "重试反馈积分奖励")
@PreAuthorize("@ss.hasPermission('education:learning-operations:reward')")
public CommonResult<StudentFeedbackAdminRespVO> retryFeedbackReward(@PathVariable Long feedbackId) {
return success(service.deliverReward(feedbackId));
}
@GetMapping("/award/page")
@Operation(summary = "分页查询学习积分与徽章奖励")
@PreAuthorize("@ss.hasPermission('education:learning-operations:query')")
public CommonResult<PageResult<LearningAwardAdminRespVO>> awardPage(@Valid LearningAwardPageReqVO reqVO) {
return success(service.getAwardPage(reqVO));
}
}

View File

@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class LearningAwardAdminRespVO {
private Long id;
private Long userId;
private String userNickname;
private String eventKey;
private String awardType;
private String badgeCode;
private Integer pointAmount;
private String pointStatus;
private String pointError;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class LearningAwardPageReqVO extends PageParam {
@Schema(description = "会员用户 ID")
private Long userId;
@Schema(description = "奖励类型")
private String awardType;
@Schema(description = "积分状态")
private String pointStatus;
@Schema(description = "是否包含徽章")
private Boolean hasBadge;
}

View File

@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class LearningOperationsOverviewRespVO {
private Long feedbackTotal;
private Long feedbackOpen;
private Long feedbackResolved;
private Long feedbackHighPriority;
private Long awardTotal;
private Long activeLearners;
private Long awardedPoints;
private Long failedPointAwards;
private Long badgeAwards;
}

View File

@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StudentFeedbackAdminRespVO {
private Long id;
private Long userId;
private String userNickname;
private String category;
private String content;
private String contact;
private String status;
private String priority;
private String resolution;
private Long handledBy;
private String handledByNickname;
private LocalDateTime handledTime;
private Integer rewardPoints;
private String rewardStatus;
private String rewardError;
private Integer version;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StudentFeedbackEventRespVO {
private Long id;
private String fromStatus;
private String toStatus;
private String note;
private Long actorId;
private String actorNickname;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class StudentFeedbackHandleReqVO {
@NotNull
@Min(0)
private Integer expectedVersion;
@NotBlank
private String status;
@NotBlank
private String priority;
@Size(max = 1000)
private String resolution;
@Size(max = 1000)
private String note;
}

View File

@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class StudentFeedbackPageReqVO extends PageParam {
@Schema(description = "会员用户 ID")
private Long userId;
@Schema(description = "反馈分类", example = "BUG")
private String category;
@Schema(description = "处理状态", example = "OPEN")
private String status;
@Schema(description = "优先级", example = "HIGH")
private String priority;
}

View File

@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class StudentFeedbackRewardReqVO {
@NotNull
@Min(0)
private Integer expectedVersion;
@NotNull
@Min(1)
@Max(100)
private Integer points;
}

View File

@@ -1,7 +1,12 @@
package cn.iocoder.yudao.module.education.controller.admin.question;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionAuthoringRespVO;
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftCreateReqVO;
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftReviseReqVO;
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionPlacementReqVO;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand;
@@ -32,18 +37,31 @@ public class QuestionAuthoringController {
this.lifecycleService = lifecycleService;
}
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('education:question:query')")
public CommonResult<PageResult<QuestionAuthoringRespVO>> getPage(@Valid PageParam pageParam) {
return success(BeanUtils.toBean(lifecycleService.getPage(pageParam), QuestionAuthoringRespVO.class));
}
@GetMapping("/get")
@PreAuthorize("@ss.hasPermission('education:question:query')")
public CommonResult<QuestionAuthoringRespVO> get(@RequestParam("id") Long id) {
return success(BeanUtils.toBean(lifecycleService.get(id), QuestionAuthoringRespVO.class));
}
@PostMapping("/drafts")
@Operation(summary = "创建租户题目草稿")
@PreAuthorize("@ss.hasPermission('education:question:author')")
public CommonResult<Long> createDraft(@Valid @RequestBody QuestionDraftCreateReqVO reqVO) {
List<QuestionDraftCommand.QuestionDraftOption> options = reqVO.getOptions().stream()
.map(option -> new QuestionDraftCommand.QuestionDraftOption(
option.getLabel(), option.getContent(), option.getOrder()))
.toList();
QuestionDraftCommand command = new QuestionDraftCommand(
reqVO.getStem(), reqVO.getType(), reqVO.getDifficulty(), options,
reqVO.getCorrectAnswer(), reqVO.getExplanation(), reqVO.getAnalysis());
return success(lifecycleService.createDraft(command));
return success(lifecycleService.createDraft(toCommand(reqVO)));
}
@PutMapping("/{id}/draft")
@Operation(summary = "修订租户题目草稿")
@PreAuthorize("@ss.hasPermission('education:question:update')")
public CommonResult<Integer> reviseDraft(@PathVariable("id") Long id,
@Valid @RequestBody QuestionDraftReviseReqVO reqVO) {
return success(lifecycleService.reviseDraft(id, toCommand(reqVO), reqVO.getExpectedContentVersion()));
}
@PutMapping("/{id}/placement")
@@ -70,4 +88,13 @@ public class QuestionAuthoringController {
lifecycleService.archive(id, getLoginUserId());
return success(true);
}
private QuestionDraftCommand toCommand(QuestionDraftCreateReqVO reqVO) {
List<QuestionDraftCommand.QuestionDraftOption> options = reqVO.getOptions().stream()
.map(option -> new QuestionDraftCommand.QuestionDraftOption(
option.getLabel(), option.getContent(), option.getOrder()))
.toList();
return new QuestionDraftCommand(reqVO.getStem(), reqVO.getType(), reqVO.getDifficulty(), options,
reqVO.getCorrectAnswer(), reqVO.getExplanation(), reqVO.getAnalysis());
}
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.education.controller.admin.question.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 题目创作 Response VO")
@Data
public class QuestionAuthoringRespVO {
private Long id;
private Integer contentVersion;
private Integer placementVersion;
private String stem;
private String type;
private String typeLabel;
private String difficulty;
private String questionContent;
private String options;
private String correctAnswer;
private String explanation;
private String analysis;
private String status;
private Boolean isPublished;
private Long subjectId;
private Long nodeId;
private String tags;
private Integer sortOrder;
private String metadata;
}

View File

@@ -0,0 +1,14 @@
package cn.iocoder.yudao.module.education.controller.admin.question.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - 修订题目草稿 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class QuestionDraftReviseReqVO extends QuestionDraftCreateReqVO {
@NotNull
private Integer expectedContentVersion;
}

View File

@@ -0,0 +1,76 @@
package cn.iocoder.yudao.module.education.controller.admin.supervision;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.admin.supervision.vo.StudentSupervisionVOs.*;
import cn.iocoder.yudao.module.education.service.supervision.StudentSupervisionAdminService;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@RestController
@RequestMapping("/education/supervision")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class StudentSupervisionAdminController {
private final StudentSupervisionAdminService service;
public StudentSupervisionAdminController(StudentSupervisionAdminService service) {
this.service = service;
}
@GetMapping("/overview")
@PreAuthorize("@ss.hasPermission('education:supervision:query')")
public CommonResult<OverviewResp> overview() {
return success(service.getOverview());
}
@GetMapping("/preview")
@PreAuthorize("@ss.hasPermission('education:supervision:query')")
public CommonResult<PreviewResp> preview(@Valid PreviewReq reqVO) {
return success(service.preview(reqVO));
}
@GetMapping("/rules/page")
@PreAuthorize("@ss.hasPermission('education:supervision:query')")
public CommonResult<PageResult<RuleResp>> rulePage(@Valid RulePageReq reqVO) {
return success(service.getRulePage(reqVO));
}
@PostMapping("/rules")
@PreAuthorize("@ss.hasPermission('education:supervision:rule')")
public CommonResult<RuleResp> createRule(@Valid @RequestBody RuleSaveReq reqVO) {
return success(service.createRule(reqVO, getLoginUserId()));
}
@PutMapping("/rules/{id}")
@PreAuthorize("@ss.hasPermission('education:supervision:rule')")
public CommonResult<RuleResp> updateRule(@PathVariable Long id, @Valid @RequestBody RuleSaveReq reqVO) {
return success(service.updateRule(id, reqVO, getLoginUserId()));
}
@GetMapping("/followups/page")
@PreAuthorize("@ss.hasPermission('education:supervision:query')")
public CommonResult<PageResult<FollowupResp>> followupPage(@Valid FollowupPageReq reqVO) {
return success(service.getFollowupPage(reqVO));
}
@PostMapping("/followups/generate")
@PreAuthorize("@ss.hasPermission('education:supervision:generate')")
public CommonResult<GenerateResp> generate(@Valid @RequestBody GenerateReq reqVO) {
return success(service.generate(reqVO, getLoginUserId()));
}
@PutMapping("/followups/{id}")
@PreAuthorize("@ss.hasPermission('education:supervision:followup')")
public CommonResult<FollowupResp> handle(@PathVariable Long id,
@Valid @RequestBody FollowupHandleReq reqVO) {
return success(service.handleFollowup(id, reqVO, getLoginUserId()));
}
}

View File

@@ -0,0 +1,190 @@
package cn.iocoder.yudao.module.education.controller.admin.supervision.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
public final class StudentSupervisionVOs {
private StudentSupervisionVOs() {}
@Data
public static class RiskRule {
@Min(3) @Max(90) private Integer windowDays;
@Min(1) @Max(90) private Integer inactivityDays;
@Min(1) @Max(500) private Integer minAnswers;
@DecimalMin("0.1") @DecimalMax("0.95") private Double lowAccuracyThreshold;
@Min(1) @Max(200) private Integer wrongQuestionThreshold;
@Min(1) @Max(1000) private Integer vocabularyDueThreshold;
@Min(1) @Max(30) private Integer staleSessionDays;
}
@Data
public static class Schedule {
private String frequency;
@Min(0) @Max(23) private Integer hour;
@Min(0) @Max(59) private Integer minute;
private List<@Min(1) @Max(7) Integer> weekdays;
}
@Data
@EqualsAndHashCode(callSuper = true)
public static class PreviewReq extends PageParam {
private Long classId;
private Long assignedAdminUserId;
@Valid private RiskRule rules;
}
@Data
@Builder
public static class CandidateResp {
private Long studentUserId;
private String studentName;
private String studentMobile;
private Long classId;
private String className;
private Long assignedAdminUserId;
private Integer riskScore;
private String priority;
private String title;
private String description;
private List<Map<String, Object>> reasons;
private Map<String, Object> evidence;
}
@Data
@Builder
public static class PreviewResp {
private RiskRule rules;
private Long classId;
private Long assignedAdminUserId;
private Long totalCandidates;
private List<CandidateResp> candidates;
}
@Data
@EqualsAndHashCode(callSuper = true)
public static class RulePageReq extends PageParam {
private String name;
private String status;
private Long classId;
}
@Data
public static class RuleSaveReq {
@NotBlank @Size(max = 120) private String name;
private String status;
private Long classId;
private Long assignedAdminUserId;
@Valid private RiskRule rules;
@Valid private Schedule schedule;
@Min(1) @Max(100) private Integer limit;
private Integer expectedVersion;
}
@Data
@Builder
public static class RuleResp {
private Long id;
private String name;
private String status;
private Long classId;
private String className;
private Long assignedAdminUserId;
private String assignedAdminName;
private Long deptId;
private RiskRule rules;
private Schedule schedule;
private Integer limit;
private LocalDateTime lastRunTime;
private LocalDateTime nextRunTime;
private Integer lastCandidateCount;
private Integer lastGeneratedCount;
private String lastError;
private Integer version;
private LocalDateTime createTime;
}
@Data
@EqualsAndHashCode(callSuper = true)
public static class FollowupPageReq extends PageParam {
private Long studentUserId;
private Long classId;
private Long assignedAdminUserId;
private String priority;
private String status;
}
@Data
public static class GenerateReq {
private Long ruleId;
private Long classId;
private Long assignedAdminUserId;
@Size(max = 100) private List<@Positive Long> studentUserIds;
@Valid private RiskRule rules;
@Min(1) @Max(100) private Integer limit;
private LocalDateTime dueTime;
@Size(max = 160) private String batchKey;
}
@Data
public static class FollowupHandleReq {
@NotNull @Min(0) private Integer expectedVersion;
@NotBlank private String status;
@Size(max = 1000) private String resultNote;
}
@Data
@Builder
public static class FollowupResp {
private Long id;
private Long studentUserId;
private String studentName;
private String studentMobile;
private Long assignedAdminUserId;
private String assignedAdminName;
private Long classId;
private String className;
private Long ruleId;
private String title;
private String description;
private String followupType;
private String priority;
private String status;
private LocalDateTime dueTime;
private LocalDateTime completedTime;
private Long completedBy;
private String batchKey;
private Integer riskScore;
private List<Map<String, Object>> reasons;
private Map<String, Object> evidence;
private String resultNote;
private Integer version;
private LocalDateTime createTime;
}
@Data
@Builder
public static class GenerateResp {
private String batchKey;
private Integer total;
private Integer successCount;
private Integer errorCount;
private List<FollowupResp> items;
private List<Map<String, Object>> errors;
}
@Data
@Builder
public static class OverviewResp {
private Long openFollowups;
private Long overdueFollowups;
private Long highPriorityFollowups;
private Long doneFollowups;
private Long activeRules;
}
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.education.controller.app.activationcode;
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.module.education.controller.app.activationcode.vo.ActivationCodeAppVOs.*;
import cn.iocoder.yudao.module.education.service.activationcode.ActivationCodeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
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/activation-code") @Validated
public class ActivationCodeAppController {
private final ActivationCodeService service;
public ActivationCodeAppController(ActivationCodeService service){this.service=service;}
@PostMapping("/check") @Operation(summary="检查激活码是否可兑换")
public CommonResult<CheckResp> check(@Valid @RequestBody CodeReq req){student();return success(service.check(req.getCode()));}
@PostMapping("/redeem") @Operation(summary="原子兑换激活码并发放学习权益")
public CommonResult<RedeemResp> redeem(@Valid @RequestBody CodeReq req){return success(service.redeem(req.getCode(),student().getId()));}
private LoginUser student(){LoginUser user=SecurityFrameworkUtils.getLoginUser();if(user==null||!UserTypeEnum.MEMBER.getValue().equals(user.getUserType()))throw exception(UNAUTHORIZED);return user;}
}

View File

@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.education.controller.app.activationcode.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.*;
public final class ActivationCodeAppVOs {
private ActivationCodeAppVOs() {}
@Data public static class CodeReq { @NotBlank @Pattern(regexp="[A-Za-z0-9-]{6,64}") private String code; }
@Data @Builder @NoArgsConstructor @AllArgsConstructor public static class CheckResp {
private boolean valid; private String codeMasked; private String batchName; private Integer durationDays;
}
@Data @Builder @NoArgsConstructor @AllArgsConstructor public static class RedeemResp {
private Long entitlementId; private Long resourceId; private Long productSpuId; private Integer durationDays;
private boolean idempotent;
}
}

View File

@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.education.controller.app.appearance;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.controller.admin.appearance.vo.TenantAppearanceVOs.PublicAppearanceResp;
import cn.iocoder.yudao.module.education.service.appearance.TenantAppearanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.security.PermitAll;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "用户 APP - 教育租户公开外观")
@RestController
@RequestMapping("/education/tenant-appearance")
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class TenantAppearancePublicController {
private final TenantAppearanceService service;
public TenantAppearancePublicController(TenantAppearanceService service) { this.service = service; }
@GetMapping("/public")
@PermitAll
@Operation(summary = "获取当前租户公开外观", description = "使用正常租户请求上下文,不接受响应体中的租户标识。")
public CommonResult<PublicAppearanceResp> getPublicAppearance() {
return success(service.getPublicAppearance());
}
}

View File

@@ -11,6 +11,8 @@ import cn.iocoder.yudao.module.education.controller.app.engagement.vo.LearningAw
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 cn.iocoder.yudao.module.education.service.badge.BadgeGrantService;
import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.MemberBadgeResp;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -41,6 +43,8 @@ public class EducationEngagementController {
@Resource
private EducationEngagementService engagementService;
@Resource
private BadgeGrantService badgeGrantService;
@PostMapping("/feedback")
@Operation(summary = "提交学生反馈")
@@ -71,6 +75,14 @@ public class EducationEngagementController {
return success(engagementService.getLeaderboard(getTenantId(), limit));
}
@GetMapping("/badges")
@Operation(summary = "查询本人已获或可解锁徽章")
public CommonResult<List<MemberBadgeResp>> badges(
@RequestParam(defaultValue = "false") boolean includeLocked) {
LoginUser student = getStudentPrincipal();
return success(badgeGrantService.getMemberBadges(getTenantId(), student.getId(), includeLocked));
}
private LoginUser getStudentPrincipal() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null || loginUser.getId() == null

View File

@@ -2,15 +2,17 @@ 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.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("education_learning_award")
@KeySequence("education_learning_award_seq")
@TableName(value = "education_learning_award", autoResultMap = true)
public class LearningAwardDO extends TenantBaseDO {
@TableId
private Long id;
@@ -21,4 +23,13 @@ public class LearningAwardDO extends TenantBaseDO {
private Integer pointAmount;
private String pointStatus;
private String pointError;
private Long badgeDefinitionId;
private String awardSource;
private String grantNote;
@TableField(typeHandler = JacksonTypeHandler.class)
private java.util.Map<String, Object> grantEvidence;
private Long grantedBy;
private Long notifyMessageId;
private String notifyStatus;
private String notifyError;
}

View File

@@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("education_student_feedback")
@@ -19,4 +21,12 @@ public class StudentFeedbackDO extends TenantBaseDO {
private String content;
private String contact;
private String status;
private String priority;
private String resolution;
private Long handledBy;
private LocalDateTime handledTime;
private Integer rewardPoints;
private String rewardStatus;
private String rewardError;
private Integer version;
}

View File

@@ -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_event")
@KeySequence("education_student_feedback_event_seq")
public class StudentFeedbackEventDO extends TenantBaseDO {
@TableId
private Long id;
private Long feedbackId;
private String fromStatus;
private String toStatus;
private String note;
private Long actorId;
}

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.education.dal.dataobject.activationcode;
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.*;
@TableName("education_activation_code_batch")
@KeySequence("education_activation_code_batch_seq")
@Data @EqualsAndHashCode(callSuper = true) @Builder @NoArgsConstructor @AllArgsConstructor
public class ActivationCodeBatchDO extends TenantBaseDO {
@TableId private Long id;
private String name;
private Long productSpuId;
private Integer durationDays;
private String codePrefix;
private String status;
private Integer totalCount;
private Integer redeemedCount;
private Integer version;
}

View File

@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.education.dal.dataobject.activationcode;
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.*;
import java.time.LocalDateTime;
@TableName("education_activation_code")
@KeySequence("education_activation_code_seq")
@Data @EqualsAndHashCode(callSuper = true) @Builder @NoArgsConstructor @AllArgsConstructor
public class ActivationCodeDO extends TenantBaseDO {
@TableId private Long id;
private Long batchId;
private String codeHash;
private String codeMasked;
private String status;
private Long redeemedBy;
private LocalDateTime redeemedAt;
private Long entitlementId;
private Integer version;
}

View File

@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.education.dal.dataobject.appearance;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.Map;
@TableName(value = "education_tenant_appearance", autoResultMap = true)
@KeySequence("education_tenant_appearance_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class TenantAppearanceDO extends TenantBaseDO {
@TableId
private Long id;
private String brandName;
private String shortName;
private String slogan;
private String orgName;
private String logoUrl;
private String faviconUrl;
private String serviceWechat;
private String serviceAccountName;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> featureFlags;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> adminFeatureFlags;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> publicConfig;
private String activeTemplateCode;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> activeTheme;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> activePublicAssets;
private String draftTemplateCode;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> draftTheme;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> draftPublicAssets;
private String themeStatus;
private LocalDateTime publishedTime;
private Long publishedBy;
private Long draftUpdatedBy;
private Integer version;
}

View File

@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.education.dal.dataobject.appearance;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Map;
@TableName(value = "education_tenant_theme_template", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@TenantIgnore
public class TenantThemeTemplateDO extends BaseDO {
@TableId
private String code;
private String name;
private String description;
private String previewImageUrl;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> theme;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> publicAssets;
private Integer sortOrder;
private String status;
}

View File

@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.education.dal.dataobject.badge;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.util.Map;
@TableName(value = "education_badge_definition", autoResultMap = true)
@KeySequence("education_badge_definition_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class BadgeDefinitionDO extends TenantBaseDO {
@TableId private Long id;
private String code;
private String name;
private String description;
private String category;
private String iconUrl;
private Integer level;
private String triggerType;
private String metric;
private String operator;
private BigDecimal thresholdValue;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> conditionExtra;
private Integer sortOrder;
private String status;
private Integer version;
}

View File

@@ -16,4 +16,8 @@ public class EducationClassDO extends TenantBaseDO {
private String name;
private String description;
private String status;
/** System 部门数据范围;账号、部门与角色仍由 System 模块持有。 */
private Long deptId;
/** 创建班级的 System 管理员,用于“仅本人”数据范围。 */
private Long ownerUserId;
}

View File

@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.education.dal.dataobject.supervision;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@TableName(value = "education_student_followup", autoResultMap = true)
@KeySequence("education_student_followup_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class StudentFollowupDO extends TenantBaseDO {
@TableId private Long id;
private Long studentUserId;
private Long assignedAdminUserId;
private Long classId;
private Long ruleId;
private Long deptId;
private Long ownerUserId;
private String title;
private String description;
private String followupType;
private String priority;
private String status;
private LocalDateTime dueTime;
private LocalDateTime completedTime;
private Long completedBy;
private String batchKey;
private Integer riskScore;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Map<String, Object>> reasons;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> evidence;
private String resultNote;
private Integer version;
}

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.education.dal.dataobject.supervision;
import lombok.Data;
import java.time.LocalDateTime;
/** 聚合学习状态;只保存计算风险所需的 Education 数据,不复制 Member 资料。 */
@Data
public class StudentRiskSnapshot {
private Long studentUserId;
private Long classId;
private String className;
private Integer answerCount;
private Integer correctCount;
private Integer unresolvedWrongQuestions;
private Integer wrongQuestionAttempts;
private Integer staleActiveSessions;
private Integer dueVocabularyWords;
private LocalDateTime latestReportTime;
private LocalDateTime latestSessionTime;
private LocalDateTime latestVocabularyReviewTime;
}

View File

@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.education.dal.dataobject.supervision;
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;
@TableName("education_student_supervision_rule")
@KeySequence("education_student_supervision_rule_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class StudentSupervisionRuleDO extends TenantBaseDO {
@TableId private Long id;
private String name;
private String status;
private Long classId;
private Long assignedAdminUserId;
private Long deptId;
private Long ownerUserId;
private Integer windowDays;
private Integer inactivityDays;
private Integer minAnswers;
private Integer lowAccuracyPermille;
private Integer wrongQuestionThreshold;
private Integer vocabularyDueThreshold;
private Integer staleSessionDays;
private String scheduleFrequency;
private Integer scheduleHour;
private Integer scheduleMinute;
private String scheduleWeekdays;
private Integer limitCount;
private LocalDateTime lastRunTime;
private LocalDateTime nextRunTime;
private Integer lastCandidateCount;
private Integer lastGeneratedCount;
private String lastError;
private Integer version;
}

View File

@@ -1,13 +1,18 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
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 lombok.Data;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -23,12 +28,69 @@ public interface LearningAwardMapper extends BaseMapperX<LearningAwardDO> {
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(LearningAwardDO record);
@Insert("""
INSERT INTO education_learning_award
(tenant_id, user_id, event_key, award_type, badge_code, point_amount, point_status,
badge_definition_id, award_source, grant_note, grant_evidence, granted_by,
notify_status, creator, create_time, updater, update_time, deleted)
VALUES
(#{record.tenantId}, #{record.userId}, #{record.eventKey}, 'BADGE', #{record.badgeCode}, 0,
'NOT_REQUIRED', #{record.badgeDefinitionId}, #{record.awardSource}, #{record.grantNote},
CAST(#{evidenceJson} AS JSONB), #{record.grantedBy}, 'PENDING', #{record.creator},
CURRENT_TIMESTAMP, #{record.updater}, CURRENT_TIMESTAMP, FALSE)
ON CONFLICT (tenant_id, user_id, badge_definition_id)
WHERE badge_definition_id IS NOT NULL AND deleted = false DO NOTHING
""")
@Options(useGeneratedKeys = true, keyProperty = "record.id")
int insertBadgeGrantIgnore(@Param("record") LearningAwardDO record,
@Param("evidenceJson") String evidenceJson);
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 LearningAwardDO selectBadgeGrant(Long tenantId, Long userId, Long badgeDefinitionId) {
return selectOne(new LambdaQueryWrapper<LearningAwardDO>()
.eq(LearningAwardDO::getTenantId, tenantId)
.eq(LearningAwardDO::getUserId, userId)
.eq(LearningAwardDO::getBadgeDefinitionId, badgeDefinitionId)
.eq(LearningAwardDO::getDeleted, false));
}
default PageResult<LearningAwardDO> selectBadgeGrantPage(PageParam pageParam, Long tenantId, Long userId,
Long badgeDefinitionId, String awardSource) {
return selectPage(pageParam, new LambdaQueryWrapper<LearningAwardDO>()
.eq(LearningAwardDO::getTenantId, tenantId)
.eq(LearningAwardDO::getDeleted, false)
.isNotNull(LearningAwardDO::getBadgeDefinitionId)
.eq(userId != null, LearningAwardDO::getUserId, userId)
.eq(badgeDefinitionId != null, LearningAwardDO::getBadgeDefinitionId, badgeDefinitionId)
.eq(awardSource != null, LearningAwardDO::getAwardSource, awardSource)
.orderByDesc(LearningAwardDO::getCreateTime)
.orderByDesc(LearningAwardDO::getId));
}
default List<LearningAwardDO> selectUserBadgeGrants(Long tenantId, Long userId) {
return selectList(new LambdaQueryWrapper<LearningAwardDO>()
.eq(LearningAwardDO::getTenantId, tenantId)
.eq(LearningAwardDO::getUserId, userId)
.eq(LearningAwardDO::getDeleted, false)
.isNotNull(LearningAwardDO::getBadgeDefinitionId)
.orderByDesc(LearningAwardDO::getCreateTime));
}
@Update("""
UPDATE education_learning_award
SET notify_message_id=#{messageId}, notify_status=#{status}, notify_error=#{error},
update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=false
""")
int updateBadgeNotify(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("messageId") Long messageId, @Param("status") String status,
@Param("error") String error);
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)
@@ -63,8 +125,43 @@ public interface LearningAwardMapper extends BaseMapperX<LearningAwardDO> {
""")
List<LeaderboardRow> selectLeaderboard(Long tenantId, int limit);
interface LeaderboardRow {
Long getUserId();
Long getLearningScore();
default PageResult<LearningAwardDO> selectAdminPage(PageParam pageParam, Long tenantId, Long userId,
String awardType, String pointStatus, Boolean hasBadge) {
return selectPage(pageParam, new LambdaQueryWrapper<LearningAwardDO>()
.eq(LearningAwardDO::getTenantId, tenantId)
.eq(LearningAwardDO::getDeleted, false)
.eq(userId != null, LearningAwardDO::getUserId, userId)
.eq(awardType != null, LearningAwardDO::getAwardType, awardType)
.eq(pointStatus != null, LearningAwardDO::getPointStatus, pointStatus)
.isNotNull(Boolean.TRUE.equals(hasBadge), LearningAwardDO::getBadgeCode)
.isNull(Boolean.FALSE.equals(hasBadge), LearningAwardDO::getBadgeCode)
.orderByDesc(LearningAwardDO::getCreateTime)
.orderByDesc(LearningAwardDO::getId));
}
@Select("""
SELECT COUNT(*) AS total,
COUNT(DISTINCT user_id) AS active_learners,
COALESCE(SUM(point_amount) FILTER (WHERE point_status = 'AWARDED'), 0) AS awarded_points,
COUNT(*) FILTER (WHERE point_status = 'FAILED') AS failed_points,
COUNT(*) FILTER (WHERE badge_code IS NOT NULL) AS badge_awards
FROM education_learning_award
WHERE tenant_id = #{tenantId} AND deleted = false
""")
AwardOverviewRow selectOverview(Long tenantId);
@Data
class LeaderboardRow {
private Long userId;
private Long learningScore;
}
@Data
class AwardOverviewRow {
private Long total;
private Long activeLearners;
private Long awardedPoints;
private Long failedPoints;
private Long badgeAwards;
}
}

View File

@@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDO;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Data;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
@@ -64,4 +65,17 @@ public interface PracticeReportMapper extends BaseMapperX<PracticeReportDO> {
""")
Long sumCorrectByOwner(Long tenantId, Long userId);
@Select("""
SELECT COUNT(*) AS practice_count, COALESCE(MAX(score), 0) AS best_score
FROM education_practice_report
WHERE tenant_id=#{tenantId} AND user_id=#{userId} AND deleted=false
""")
BadgePracticeMetrics selectBadgeMetrics(Long tenantId, Long userId);
@Data
class BadgePracticeMetrics {
private Long practiceCount;
private Integer bestScore;
}
}

View File

@@ -0,0 +1,20 @@
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.StudentFeedbackEventDO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface StudentFeedbackEventMapper extends BaseMapperX<StudentFeedbackEventDO> {
default List<StudentFeedbackEventDO> selectOwnedList(Long tenantId, Long feedbackId) {
return selectList(new LambdaQueryWrapper<StudentFeedbackEventDO>()
.eq(StudentFeedbackEventDO::getTenantId, tenantId)
.eq(StudentFeedbackEventDO::getFeedbackId, feedbackId)
.eq(StudentFeedbackEventDO::getDeleted, false)
.orderByAsc(StudentFeedbackEventDO::getCreateTime)
.orderByAsc(StudentFeedbackEventDO::getId));
}
}

View File

@@ -1,9 +1,14 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.dal.dataobject.StudentFeedbackDO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.Data;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -15,4 +20,97 @@ public interface StudentFeedbackMapper extends BaseMapperX<StudentFeedbackDO> {
.eq(StudentFeedbackDO::getDeleted, false).orderByDesc(StudentFeedbackDO::getCreateTime)
.last("LIMIT 100"));
}
default PageResult<StudentFeedbackDO> selectAdminPage(PageParam pageParam, Long tenantId, Long userId,
String category, String status, String priority) {
return selectPage(pageParam, new LambdaQueryWrapper<StudentFeedbackDO>()
.eq(StudentFeedbackDO::getTenantId, tenantId)
.eq(StudentFeedbackDO::getDeleted, false)
.eq(userId != null, StudentFeedbackDO::getUserId, userId)
.eq(category != null, StudentFeedbackDO::getCategory, category)
.eq(status != null, StudentFeedbackDO::getStatus, status)
.eq(priority != null, StudentFeedbackDO::getPriority, priority)
.orderByDesc(StudentFeedbackDO::getCreateTime)
.orderByDesc(StudentFeedbackDO::getId));
}
default StudentFeedbackDO selectOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapper<StudentFeedbackDO>()
.eq(StudentFeedbackDO::getTenantId, tenantId)
.eq(StudentFeedbackDO::getId, id)
.eq(StudentFeedbackDO::getDeleted, false));
}
default int handleCas(Long tenantId, Long id, int expectedVersion, String status, String priority,
String resolution, Long actorId) {
return update(null, new LambdaUpdateWrapper<StudentFeedbackDO>()
.eq(StudentFeedbackDO::getTenantId, tenantId)
.eq(StudentFeedbackDO::getId, id)
.eq(StudentFeedbackDO::getDeleted, false)
.eq(StudentFeedbackDO::getVersion, expectedVersion)
.set(StudentFeedbackDO::getStatus, status)
.set(StudentFeedbackDO::getPriority, priority)
.set(StudentFeedbackDO::getResolution, resolution)
.set(StudentFeedbackDO::getHandledBy, actorId)
.setSql("handled_time = CURRENT_TIMESTAMP")
.set(StudentFeedbackDO::getVersion, expectedVersion + 1));
}
default int scheduleRewardCas(Long tenantId, Long id, int expectedVersion, int rewardPoints) {
return update(null, new LambdaUpdateWrapper<StudentFeedbackDO>()
.eq(StudentFeedbackDO::getTenantId, tenantId)
.eq(StudentFeedbackDO::getId, id)
.eq(StudentFeedbackDO::getDeleted, false)
.eq(StudentFeedbackDO::getVersion, expectedVersion)
.eq(StudentFeedbackDO::getStatus, "RESOLVED")
.ne(StudentFeedbackDO::getRewardStatus, "AWARDED")
.set(StudentFeedbackDO::getRewardPoints, rewardPoints)
.set(StudentFeedbackDO::getRewardStatus, "PENDING")
.set(StudentFeedbackDO::getRewardError, null)
.set(StudentFeedbackDO::getVersion, expectedVersion + 1));
}
default int claimReward(Long tenantId, Long id) {
return update(null, new LambdaUpdateWrapper<StudentFeedbackDO>()
.eq(StudentFeedbackDO::getTenantId, tenantId)
.eq(StudentFeedbackDO::getId, id)
.gt(StudentFeedbackDO::getRewardPoints, 0)
.in(StudentFeedbackDO::getRewardStatus, "PENDING", "FAILED")
.set(StudentFeedbackDO::getRewardStatus, "PROCESSING")
.set(StudentFeedbackDO::getRewardError, null));
}
default int finishReward(Long tenantId, Long id, String status, String error) {
return update(null, new LambdaUpdateWrapper<StudentFeedbackDO>()
.eq(StudentFeedbackDO::getTenantId, tenantId)
.eq(StudentFeedbackDO::getId, id)
.eq(StudentFeedbackDO::getRewardStatus, "PROCESSING")
.set(StudentFeedbackDO::getRewardStatus, status)
.set(StudentFeedbackDO::getRewardError, error));
}
@Select("""
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE status IN ('OPEN', 'ACCEPTED')) AS open_count,
COUNT(*) FILTER (WHERE status = 'RESOLVED') AS resolved_count,
COUNT(*) FILTER (WHERE priority IN ('HIGH', 'URGENT') AND status IN ('OPEN', 'ACCEPTED'))
AS high_priority_count
FROM education_student_feedback
WHERE tenant_id = #{tenantId} AND deleted = false
""")
FeedbackOverviewRow selectOverview(Long tenantId);
@Select("""
SELECT COUNT(*) FROM education_student_feedback
WHERE tenant_id=#{tenantId} AND user_id=#{userId} AND status='RESOLVED' AND deleted=false
""")
Long countResolved(Long tenantId, Long userId);
@Data
class FeedbackOverviewRow {
private Long total;
private Long openCount;
private Long resolvedCount;
private Long highPriorityCount;
}
}

View File

@@ -6,6 +6,7 @@ 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 org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@@ -33,4 +34,10 @@ public interface VocabularyProgressMapper extends BaseMapperX<VocabularyProgress
.le(VocabularyProgressDO::getNextReviewTime, now).orderByAsc(VocabularyProgressDO::getNextReviewTime)
.last("LIMIT " + limit));
}
@Select("""
SELECT COUNT(*) FROM education_vocabulary_progress
WHERE tenant_id=#{tenantId} AND user_id=#{userId} AND mastery_level >= 5 AND deleted=false
""")
Long countMastered(Long tenantId, Long userId);
}

View File

@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.education.dal.mysql.activationcode;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.activationcode.ActivationCodeBatchDO;
import org.apache.ibatis.annotations.*;
@Mapper
public interface ActivationCodeBatchMapper extends BaseMapperX<ActivationCodeBatchDO> {
default PageResult<ActivationCodeBatchDO> selectAdminPage(PageParam page, Long tenantId, String keyword, String status) {
return selectPage(page, new LambdaQueryWrapperX<ActivationCodeBatchDO>()
.eq(ActivationCodeBatchDO::getTenantId, tenantId)
.likeIfPresent(ActivationCodeBatchDO::getName, keyword)
.eqIfPresent(ActivationCodeBatchDO::getStatus, status)
.eq(ActivationCodeBatchDO::getDeleted, false)
.orderByDesc(ActivationCodeBatchDO::getId));
}
default ActivationCodeBatchDO selectOwned(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<ActivationCodeBatchDO>()
.eq(ActivationCodeBatchDO::getTenantId, tenantId).eq(ActivationCodeBatchDO::getId, id)
.eq(ActivationCodeBatchDO::getDeleted, false));
}
@Update("""
UPDATE education_activation_code_batch
SET name=#{record.name}, product_spu_id=#{record.productSpuId}, duration_days=#{record.durationDays},
code_prefix=#{record.codePrefix}, status=#{record.status}, version=version+1,
updater=#{record.updater}, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{record.id} AND version=#{expectedVersion} AND deleted=false
""")
int updateCas(@Param("tenantId") Long tenantId, @Param("record") ActivationCodeBatchDO record,
@Param("expectedVersion") Integer expectedVersion);
@Update("""
UPDATE education_activation_code_batch
SET total_count=total_count+#{count}, version=version+1, updater=#{actor}, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{id} AND version=#{expectedVersion}
AND status='ACTIVE' AND deleted=false
""")
int addGeneratedCas(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("expectedVersion") Integer expectedVersion, @Param("count") Integer count,
@Param("actor") String actor);
@Update("""
UPDATE education_activation_code_batch SET redeemed_count=redeemed_count+1, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=false
""")
int incrementRedeemed(@Param("tenantId") Long tenantId, @Param("id") Long id);
}

View File

@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.education.dal.mysql.activationcode;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.activationcode.ActivationCodeDO;
import org.apache.ibatis.annotations.*;
@Mapper
public interface ActivationCodeMapper extends BaseMapperX<ActivationCodeDO> {
default PageResult<ActivationCodeDO> selectAdminPage(PageParam page, Long tenantId, Long batchId, String status) {
return selectPage(page, new LambdaQueryWrapperX<ActivationCodeDO>()
.eq(ActivationCodeDO::getTenantId, tenantId).eqIfPresent(ActivationCodeDO::getBatchId, batchId)
.eqIfPresent(ActivationCodeDO::getStatus, status).eq(ActivationCodeDO::getDeleted, false)
.orderByDesc(ActivationCodeDO::getId));
}
default ActivationCodeDO selectByHash(Long tenantId, String codeHash) {
return selectOne(new LambdaQueryWrapperX<ActivationCodeDO>()
.eq(ActivationCodeDO::getTenantId, tenantId).eq(ActivationCodeDO::getCodeHash, codeHash)
.eq(ActivationCodeDO::getDeleted, false));
}
@Select("""
SELECT * FROM education_activation_code
WHERE tenant_id=#{tenantId} AND code_hash=#{codeHash} AND deleted=false FOR UPDATE
""")
ActivationCodeDO selectForUpdate(@Param("tenantId") Long tenantId, @Param("codeHash") String codeHash);
@Update("""
UPDATE education_activation_code
SET status='REDEEMED', redeemed_by=#{userId}, redeemed_at=CURRENT_TIMESTAMP,
entitlement_id=#{entitlementId}, version=version+1, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{id} AND status='AVAILABLE' AND deleted=false
""")
int markRedeemed(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("userId") Long userId, @Param("entitlementId") Long entitlementId);
@Update("""
UPDATE education_activation_code SET status='DISABLED', version=version+1, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{id} AND status='AVAILABLE' AND version=#{expectedVersion} AND deleted=false
""")
int disable(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("expectedVersion") Integer expectedVersion);
}

View File

@@ -0,0 +1,81 @@
package cn.iocoder.yudao.module.education.dal.mysql.appearance;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.appearance.TenantAppearanceDO;
import org.apache.ibatis.annotations.*;
@Mapper
public interface TenantAppearanceMapper extends BaseMapperX<TenantAppearanceDO> {
default TenantAppearanceDO selectOwned(Long tenantId) {
return selectOne(new LambdaQueryWrapperX<TenantAppearanceDO>()
.eq(TenantAppearanceDO::getTenantId, tenantId)
.eq(TenantAppearanceDO::getDeleted, false));
}
@Insert("""
INSERT INTO education_tenant_appearance
(tenant_id, brand_name, creator, updater, deleted)
VALUES (#{tenantId}, #{brandName}, #{actor}, #{actor}, false)
ON CONFLICT (tenant_id) WHERE deleted = false DO NOTHING
""")
int insertDefaultIgnore(@Param("tenantId") Long tenantId, @Param("brandName") String brandName,
@Param("actor") String actor);
@Update("""
UPDATE education_tenant_appearance
SET brand_name=#{brandName}, short_name=#{shortName}, slogan=#{slogan}, org_name=#{orgName},
logo_url=#{logoUrl}, favicon_url=#{faviconUrl}, service_wechat=#{serviceWechat},
service_account_name=#{serviceAccountName}, version=version + 1,
updater=#{actor}, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND version=#{expectedVersion} AND deleted=false
""")
int updateBranding(@Param("tenantId") Long tenantId, @Param("brandName") String brandName,
@Param("shortName") String shortName, @Param("slogan") String slogan,
@Param("orgName") String orgName, @Param("logoUrl") String logoUrl,
@Param("faviconUrl") String faviconUrl, @Param("serviceWechat") String serviceWechat,
@Param("serviceAccountName") String serviceAccountName,
@Param("expectedVersion") Integer expectedVersion, @Param("actor") String actor);
@Update("""
UPDATE education_tenant_appearance
SET feature_flags=CAST(#{featureFlags} AS JSONB),
admin_feature_flags=CAST(#{adminFeatureFlags} AS JSONB),
public_config=CAST(#{publicConfig} AS JSONB), version=version + 1,
updater=#{actor}, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND version=#{expectedVersion} AND deleted=false
""")
int updateSettings(@Param("tenantId") Long tenantId, @Param("featureFlags") String featureFlags,
@Param("adminFeatureFlags") String adminFeatureFlags,
@Param("publicConfig") String publicConfig,
@Param("expectedVersion") Integer expectedVersion, @Param("actor") String actor);
@Update("""
UPDATE education_tenant_appearance
SET draft_template_code=#{templateCode}, draft_theme=CAST(#{theme} AS JSONB),
draft_public_assets=CAST(#{publicAssets} AS JSONB), theme_status='DRAFT',
draft_updated_by=#{actorId}, version=version + 1,
updater=#{actor}, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND version=#{expectedVersion} AND deleted=false
""")
int updateDraft(@Param("tenantId") Long tenantId, @Param("templateCode") String templateCode,
@Param("theme") String theme, @Param("publicAssets") String publicAssets,
@Param("actorId") Long actorId, @Param("expectedVersion") Integer expectedVersion,
@Param("actor") String actor);
@Update("""
UPDATE education_tenant_appearance
SET active_template_code=#{templateCode}, active_theme=CAST(#{theme} AS JSONB),
active_public_assets=CAST(#{publicAssets} AS JSONB),
draft_template_code=NULL, draft_theme='{}'::JSONB, draft_public_assets='{}'::JSONB,
theme_status='PUBLISHED', published_time=CURRENT_TIMESTAMP, published_by=#{actorId},
draft_updated_by=#{actorId}, version=version + 1,
updater=#{actor}, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND version=#{expectedVersion} AND deleted=false
""")
int publish(@Param("tenantId") Long tenantId, @Param("templateCode") String templateCode,
@Param("theme") String theme, @Param("publicAssets") String publicAssets,
@Param("actorId") Long actorId, @Param("expectedVersion") Integer expectedVersion,
@Param("actor") String actor);
}

View File

@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.education.dal.mysql.appearance;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.appearance.TenantThemeTemplateDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface TenantThemeTemplateMapper extends BaseMapperX<TenantThemeTemplateDO> {
default List<TenantThemeTemplateDO> selectActiveList() {
return selectList(new LambdaQueryWrapperX<TenantThemeTemplateDO>()
.eq(TenantThemeTemplateDO::getStatus, "ACTIVE")
.eq(TenantThemeTemplateDO::getDeleted, false)
.orderByAsc(TenantThemeTemplateDO::getSortOrder)
.orderByAsc(TenantThemeTemplateDO::getCode));
}
default TenantThemeTemplateDO selectActive(String code) {
return selectOne(new LambdaQueryWrapperX<TenantThemeTemplateDO>()
.eq(TenantThemeTemplateDO::getCode, code)
.eq(TenantThemeTemplateDO::getStatus, "ACTIVE")
.eq(TenantThemeTemplateDO::getDeleted, false));
}
}

View File

@@ -0,0 +1,76 @@
package cn.iocoder.yudao.module.education.dal.mysql.badge;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.badge.BadgeDefinitionDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface BadgeDefinitionMapper extends BaseMapperX<BadgeDefinitionDO> {
default PageResult<BadgeDefinitionDO> selectAdminPage(PageParam page, Long tenantId, String keyword,
String category, String triggerType, String status) {
return selectPage(page, new LambdaQueryWrapperX<BadgeDefinitionDO>()
.eq(BadgeDefinitionDO::getTenantId, tenantId)
.likeIfPresent(BadgeDefinitionDO::getName, keyword)
.eqIfPresent(BadgeDefinitionDO::getCategory, category)
.eqIfPresent(BadgeDefinitionDO::getTriggerType, triggerType)
.eqIfPresent(BadgeDefinitionDO::getStatus, status)
.eq(BadgeDefinitionDO::getDeleted, false)
.orderByAsc(BadgeDefinitionDO::getSortOrder)
.orderByDesc(BadgeDefinitionDO::getId));
}
default BadgeDefinitionDO selectOwned(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<BadgeDefinitionDO>()
.eq(BadgeDefinitionDO::getTenantId, tenantId)
.eq(BadgeDefinitionDO::getId, id)
.eq(BadgeDefinitionDO::getDeleted, false));
}
default BadgeDefinitionDO selectOwnedCode(Long tenantId, String code) {
return selectOne(new LambdaQueryWrapperX<BadgeDefinitionDO>()
.eq(BadgeDefinitionDO::getTenantId, tenantId)
.eq(BadgeDefinitionDO::getCode, code)
.eq(BadgeDefinitionDO::getDeleted, false));
}
default List<BadgeDefinitionDO> selectActiveByTrigger(Long tenantId, String triggerType) {
return selectList(new LambdaQueryWrapperX<BadgeDefinitionDO>()
.eq(BadgeDefinitionDO::getTenantId, tenantId)
.eq(BadgeDefinitionDO::getTriggerType, triggerType)
.eq(BadgeDefinitionDO::getStatus, "ACTIVE")
.eq(BadgeDefinitionDO::getDeleted, false)
.orderByAsc(BadgeDefinitionDO::getSortOrder)
.orderByAsc(BadgeDefinitionDO::getId));
}
default List<BadgeDefinitionDO> selectMemberList(Long tenantId, boolean includeDisabled) {
return selectList(new LambdaQueryWrapperX<BadgeDefinitionDO>()
.eq(BadgeDefinitionDO::getTenantId, tenantId)
.eqIfPresent(BadgeDefinitionDO::getStatus, includeDisabled ? null : "ACTIVE")
.eq(BadgeDefinitionDO::getDeleted, false)
.orderByAsc(BadgeDefinitionDO::getSortOrder)
.orderByAsc(BadgeDefinitionDO::getId));
}
@Update("""
UPDATE education_badge_definition
SET name=#{record.name}, description=#{record.description}, category=#{record.category},
icon_url=#{record.iconUrl}, level=#{record.level}, trigger_type=#{record.triggerType},
metric=#{record.metric}, operator=#{record.operator}, threshold_value=#{record.thresholdValue},
condition_extra=CAST(#{conditionExtraJson} AS JSONB), sort_order=#{record.sortOrder},
status=#{record.status}, version=version + 1,
updater=#{record.updater}, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{record.id} AND version=#{expectedVersion} AND deleted=false
""")
int updateCas(@Param("tenantId") Long tenantId, @Param("record") BadgeDefinitionDO record,
@Param("conditionExtraJson") String conditionExtraJson,
@Param("expectedVersion") Integer expectedVersion);
}

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
@@ -8,6 +10,15 @@ import java.util.List;
@Mapper
public interface CategoryMapper extends BaseMapperX<CategoryDO> {
default PageResult<CategoryDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
return selectPage(pageParam, new LambdaQueryWrapperX<CategoryDO>()
.eq(CategoryDO::getTenantId, tenantId)
.eq(CategoryDO::getScope, "TENANT_OWNED")
.eq(CategoryDO::getDeleted, false)
.orderByAsc(CategoryDO::getSortOrder)
.orderByDesc(CategoryDO::getId));
}
default CategoryDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<CategoryDO>().eq(CategoryDO::getId, id)
.eq(CategoryDO::getTenantId, tenantId).eq(CategoryDO::getScope, "TENANT_OWNED")

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO;
@@ -57,6 +59,12 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
ContentNodeDO selectAvailableParentForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
@Param("parentId") Long parentId);
default PageResult<ContentNodeDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
return selectPage(pageParam, new LambdaQueryWrapperX<ContentNodeDO>()
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")
.eq(ContentNodeDO::getDeleted, false).orderByDesc(ContentNodeDO::getId));
}
default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<ContentNodeDO>().eq(ContentNodeDO::getId, id)
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintDO;
@@ -41,6 +43,11 @@ public interface PracticeBlueprintMapper extends BaseMapperX<PracticeBlueprintDO
if (collectionId == null && nodeId == null) return null;
return selectAvailableForStudent(tenantId, collectionId, nodeId);
}
default PageResult<PracticeBlueprintDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
return selectPage(pageParam, new LambdaQueryWrapperX<PracticeBlueprintDO>()
.eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED")
.eq(PracticeBlueprintDO::getDeleted, false).orderByDesc(PracticeBlueprintDO::getId));
}
default PracticeBlueprintDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<PracticeBlueprintDO>().eq(PracticeBlueprintDO::getId, id)
.eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED")

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO;
@@ -64,6 +66,11 @@ public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollection
default List<QuestionCollectionDO> selectActiveList(Long tenantId, Long entryId, Long nodeId, String collectionType, Integer limit) {
return selectAvailableList(tenantId, entryId, nodeId, collectionType, limit != null && limit > 0 ? Math.min(limit, 200) : 100);
}
default PageResult<QuestionCollectionDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
return selectPage(pageParam, new LambdaQueryWrapperX<QuestionCollectionDO>()
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")
.eq(QuestionCollectionDO::getDeleted, false).orderByDesc(QuestionCollectionDO::getId));
}
default QuestionCollectionDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id)
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
@@ -172,6 +174,14 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
return selectOne(visible(tenantId).eq(QuestionDO::getId, id));
}
default PageResult<QuestionDO> selectTenantOwnedPage(PageParam pageParam, Long tenantId) {
return selectPage(pageParam, new LambdaQueryWrapperX<QuestionDO>()
.eq(QuestionDO::getTenantId, tenantId)
.eq(QuestionDO::getScope, "TENANT_OWNED")
.eq(QuestionDO::getDeleted, false)
.orderByDesc(QuestionDO::getId));
}
default QuestionDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<QuestionDO>()
.eq(QuestionDO::getId, id)
@@ -179,6 +189,35 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
.eq(QuestionDO::getScope, "TENANT_OWNED"));
}
default long countImportedDrafts(Long tenantId, List<Long> ids) {
if (ids == null || ids.isEmpty()) return 0;
return selectCount(new LambdaQueryWrapperX<QuestionDO>()
.eq(QuestionDO::getTenantId, tenantId)
.eq(QuestionDO::getScope, "TENANT_OWNED")
.eq(QuestionDO::getStatus, "DRAFT")
.eq(QuestionDO::getIsPublished, false)
.eq(QuestionDO::getContentVersion, 1)
.in(QuestionDO::getId, ids));
}
default int updateDraftContentCas(Long tenantId, Long id, QuestionDO content,
int expectedContentVersion) {
return update(null, new LambdaUpdateWrapper<QuestionDO>()
.eq(QuestionDO::getId, id)
.eq(QuestionDO::getTenantId, tenantId)
.eq(QuestionDO::getScope, "TENANT_OWNED")
.eq(QuestionDO::getStatus, "DRAFT")
.eq(QuestionDO::getContentVersion, expectedContentVersion)
.set(QuestionDO::getStem, content.getStem())
.set(QuestionDO::getType, content.getType())
.set(QuestionDO::getDifficulty, content.getDifficulty())
.set(QuestionDO::getOptions, content.getOptions())
.set(QuestionDO::getCorrectAnswer, content.getCorrectAnswer())
.set(QuestionDO::getExplanation, content.getExplanation())
.set(QuestionDO::getAnalysis, content.getAnalysis())
.set(QuestionDO::getContentVersion, expectedContentVersion + 1));
}
default int updateLifecycle(Long tenantId, Long id, String expectedStatus,
String targetStatus, boolean published, Integer expectedPlacementVersion) {
return update(null, new LambdaUpdateWrapper<QuestionDO>()

View File

@@ -1,9 +1,20 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface QuestionVersionMapper extends BaseMapperX<QuestionVersionDO> {
default long countFirstVersions(Long tenantId, List<Long> questionIds) {
if (questionIds == null || questionIds.isEmpty()) return 0;
return selectCount(new LambdaQueryWrapperX<QuestionVersionDO>()
.eq(QuestionVersionDO::getTenantId, tenantId)
.eq(QuestionVersionDO::getScope, "TENANT_OWNED")
.eq(QuestionVersionDO::getVersionNumber, 1)
.in(QuestionVersionDO::getQuestionId, questionIds));
}
}

View File

@@ -14,4 +14,11 @@ public interface EducationResourceProductBindingMapper extends BaseMapperX<Educa
.eq(EducationResourceProductBindingDO::getResourceId, resourceId)
.eq(EducationResourceProductBindingDO::getDeleted, false));
}
default EducationResourceProductBindingDO selectByProduct(Long tenantId, Long productSpuId) {
return selectOne(new LambdaQueryWrapperX<EducationResourceProductBindingDO>()
.eq(EducationResourceProductBindingDO::getTenantId, tenantId)
.eq(EducationResourceProductBindingDO::getProductSpuId, productSpuId)
.eq(EducationResourceProductBindingDO::getDeleted, false));
}
}

View File

@@ -48,6 +48,7 @@ public interface ContentImportJobMapper extends BaseMapperX<ContentImportJobDO>
next_attempt_at=CURRENT_TIMESTAMP, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{id} AND status='PREVIEW_READY'
AND scan_status='CLEAN' AND parser_status='PARSED' AND deleted=false
AND COALESCE((preview_payload->>'executable')::BOOLEAN,FALSE)=TRUE
""")
int requestExecute(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("executeKey") String executeKey, @Param("executeRequestHash") String executeRequestHash);

View File

@@ -0,0 +1,165 @@
package cn.iocoder.yudao.module.education.dal.mysql.supervision;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentFollowupDO;
import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentRiskSnapshot;
import lombok.Data;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface StudentFollowupMapper extends BaseMapperX<StudentFollowupDO> {
default PageResult<StudentFollowupDO> selectAdminPage(PageParam pageParam, Long tenantId, Long studentUserId,
Long classId, Long assignedAdminUserId,
String priority, String status) {
return selectPage(pageParam, new LambdaQueryWrapperX<StudentFollowupDO>()
.eq(StudentFollowupDO::getTenantId, tenantId)
.eqIfPresent(StudentFollowupDO::getStudentUserId, studentUserId)
.eqIfPresent(StudentFollowupDO::getClassId, classId)
.eqIfPresent(StudentFollowupDO::getAssignedAdminUserId, assignedAdminUserId)
.eqIfPresent(StudentFollowupDO::getPriority, priority)
.eqIfPresent(StudentFollowupDO::getStatus, status)
.eq(StudentFollowupDO::getDeleted, false)
.orderByAsc(StudentFollowupDO::getStatus)
.orderByAsc(StudentFollowupDO::getDueTime)
.orderByDesc(StudentFollowupDO::getId));
}
default StudentFollowupDO selectOwned(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<StudentFollowupDO>()
.eq(StudentFollowupDO::getTenantId, tenantId)
.eq(StudentFollowupDO::getId, id)
.eq(StudentFollowupDO::getDeleted, false));
}
default StudentFollowupDO selectByBatchKey(Long tenantId, String batchKey, Long studentUserId) {
return selectOne(new LambdaQueryWrapperX<StudentFollowupDO>()
.eq(StudentFollowupDO::getTenantId, tenantId)
.eq(StudentFollowupDO::getBatchKey, batchKey)
.eq(StudentFollowupDO::getStudentUserId, studentUserId)
.eq(StudentFollowupDO::getDeleted, false));
}
/**
* Tries to create a follow-up without turning the surrounding PostgreSQL transaction into an aborted transaction
* when another request has already won the same idempotency key.
*/
@Insert("""
INSERT INTO education_student_followup
(tenant_id, student_user_id, assigned_admin_user_id, class_id, rule_id, dept_id, owner_user_id,
title, description, followup_type, priority, status, due_time, completed_time, completed_by,
batch_key, risk_score, reasons, evidence, result_note, version,
creator, create_time, updater, update_time, deleted)
VALUES
(#{record.tenantId}, #{record.studentUserId}, #{record.assignedAdminUserId}, #{record.classId},
#{record.ruleId}, #{record.deptId}, #{record.ownerUserId}, #{record.title}, #{record.description},
#{record.followupType}, #{record.priority}, #{record.status}, #{record.dueTime},
#{record.completedTime}, #{record.completedBy}, #{record.batchKey}, #{record.riskScore},
CAST(#{reasonsJson} AS JSONB), CAST(#{evidenceJson} AS JSONB), #{record.resultNote}, #{record.version},
#{record.creator}, CURRENT_TIMESTAMP, #{record.updater}, CURRENT_TIMESTAMP, FALSE)
ON CONFLICT (tenant_id, batch_key, student_user_id) WHERE deleted = false DO NOTHING
""")
@Options(useGeneratedKeys = true, keyProperty = "record.id")
int insertIgnore(@Param("record") StudentFollowupDO record,
@Param("reasonsJson") String reasonsJson,
@Param("evidenceJson") String evidenceJson);
@Select("""
WITH reports AS (
SELECT r.user_id,
COALESCE(SUM(r.answered_count), 0)::int AS answer_count,
COALESCE(SUM(r.correct_count), 0)::int AS correct_count,
MAX(r.create_time) AS latest_report_time
FROM education_practice_report r
WHERE r.tenant_id = #{tenantId} AND r.deleted = false
AND r.create_time >= CURRENT_TIMESTAMP - (#{windowDays} * INTERVAL '1 day')
GROUP BY r.user_id
), wrongs AS (
SELECT w.user_id, COUNT(*)::int AS unresolved_wrong_questions,
COALESCE(SUM(w.wrong_count), 0)::int AS wrong_question_attempts
FROM education_wrong_question w
WHERE w.tenant_id = #{tenantId} AND w.deleted = false AND w.master_status <> 'MASTERED'
GROUP BY w.user_id
), sessions AS (
SELECT p.user_id,
COUNT(*) FILTER (WHERE p.status = 'ACTIVE' AND p.create_time <= CURRENT_TIMESTAMP - (#{staleSessionDays} * INTERVAL '1 day'))::int AS stale_active_sessions,
MAX(p.update_time) AS latest_session_time
FROM education_practice_session p
WHERE p.tenant_id = #{tenantId} AND p.deleted = false
GROUP BY p.user_id
), vocabulary AS (
SELECT v.user_id,
COUNT(*) FILTER (WHERE v.mastery_level < 5 AND (v.next_review_time IS NULL OR v.next_review_time <= CURRENT_TIMESTAMP))::int AS due_vocabulary_words,
MAX(v.update_time) AS latest_vocabulary_review_time
FROM education_vocabulary_progress v
WHERE v.tenant_id = #{tenantId} AND v.deleted = false
GROUP BY v.user_id
)
SELECT cm.member_user_id AS student_user_id,
MIN(c.id) AS class_id, (ARRAY_AGG(c.name ORDER BY c.id))[1] AS class_name,
MAX(COALESCE(r.answer_count, 0))::int AS answer_count,
MAX(COALESCE(r.correct_count, 0))::int AS correct_count,
MAX(COALESCE(w.unresolved_wrong_questions, 0))::int AS unresolved_wrong_questions,
MAX(COALESCE(w.wrong_question_attempts, 0))::int AS wrong_question_attempts,
MAX(COALESCE(p.stale_active_sessions, 0))::int AS stale_active_sessions,
MAX(COALESCE(v.due_vocabulary_words, 0))::int AS due_vocabulary_words,
MAX(r.latest_report_time) AS latest_report_time,
MAX(p.latest_session_time) AS latest_session_time,
MAX(v.latest_vocabulary_review_time) AS latest_vocabulary_review_time
FROM education_class_member cm
JOIN education_class c ON c.id = cm.class_id AND c.tenant_id = cm.tenant_id
LEFT JOIN reports r ON r.user_id = cm.member_user_id
LEFT JOIN wrongs w ON w.user_id = cm.member_user_id
LEFT JOIN sessions p ON p.user_id = cm.member_user_id
LEFT JOIN vocabulary v ON v.user_id = cm.member_user_id
WHERE cm.tenant_id = #{tenantId} AND cm.role = 'STUDENT' AND cm.deleted = false
AND c.status = 'ACTIVE' AND c.deleted = false
AND (CAST(#{classId} AS BIGINT) IS NULL OR c.id = #{classId})
GROUP BY cm.member_user_id
ORDER BY GREATEST(MAX(r.latest_report_time), MAX(p.latest_session_time),
MAX(v.latest_vocabulary_review_time)) ASC NULLS FIRST,
cm.member_user_id
LIMIT #{scanLimit}
""")
List<StudentRiskSnapshot> selectRiskSnapshots(@Param("tenantId") Long tenantId,
@Param("classId") Long classId,
@Param("windowDays") Integer windowDays,
@Param("staleSessionDays") Integer staleSessionDays,
@Param("scanLimit") Integer scanLimit);
@Update("""
UPDATE education_student_followup
SET status = #{status}, result_note = #{resultNote},
completed_time = CASE WHEN #{status} = 'DONE' THEN CURRENT_TIMESTAMP ELSE NULL END,
completed_by = CASE WHEN #{status} = 'DONE' THEN #{actorId} ELSE NULL END,
version = version + 1, updater = CAST(#{actorId} AS VARCHAR), update_time = CURRENT_TIMESTAMP
WHERE tenant_id = #{tenantId} AND id = #{id} AND version = #{expectedVersion}
AND deleted = false
""")
int updateStatusCas(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("expectedVersion") Integer expectedVersion, @Param("status") String status,
@Param("resultNote") String resultNote, @Param("actorId") Long actorId);
@Select("""
SELECT COUNT(*) FILTER (WHERE status IN ('OPEN', 'IN_PROGRESS')) AS open_count,
COUNT(*) FILTER (WHERE status IN ('OPEN', 'IN_PROGRESS') AND due_time < CURRENT_TIMESTAMP) AS overdue_count,
COUNT(*) FILTER (WHERE priority IN ('HIGH', 'URGENT') AND status IN ('OPEN', 'IN_PROGRESS')) AS high_priority_count,
COUNT(*) FILTER (WHERE status = 'DONE') AS done_count
FROM education_student_followup
WHERE tenant_id = #{tenantId} AND deleted = false
""")
FollowupOverviewRow selectOverview(@Param("tenantId") Long tenantId);
@Data
class FollowupOverviewRow {
private Long openCount;
private Long overdueCount;
private Long highPriorityCount;
private Long doneCount;
}
}

View File

@@ -0,0 +1,64 @@
package cn.iocoder.yudao.module.education.dal.mysql.supervision;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentSupervisionRuleDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface StudentSupervisionRuleMapper extends BaseMapperX<StudentSupervisionRuleDO> {
default PageResult<StudentSupervisionRuleDO> selectAdminPage(PageParam pageParam, Long tenantId,
String name, String status, Long classId) {
return selectPage(pageParam, new LambdaQueryWrapperX<StudentSupervisionRuleDO>()
.eq(StudentSupervisionRuleDO::getTenantId, tenantId)
.likeIfPresent(StudentSupervisionRuleDO::getName, name)
.eqIfPresent(StudentSupervisionRuleDO::getStatus, status)
.eqIfPresent(StudentSupervisionRuleDO::getClassId, classId)
.eq(StudentSupervisionRuleDO::getDeleted, false)
.orderByAsc(StudentSupervisionRuleDO::getStatus)
.orderByAsc(StudentSupervisionRuleDO::getNextRunTime)
.orderByDesc(StudentSupervisionRuleDO::getId));
}
default StudentSupervisionRuleDO selectOwned(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<StudentSupervisionRuleDO>()
.eq(StudentSupervisionRuleDO::getTenantId, tenantId)
.eq(StudentSupervisionRuleDO::getId, id)
.eq(StudentSupervisionRuleDO::getDeleted, false));
}
@Update("""
UPDATE education_student_supervision_rule
SET name = #{rule.name}, status = #{rule.status}, class_id = #{rule.classId},
assigned_admin_user_id = #{rule.assignedAdminUserId}, dept_id = #{rule.deptId},
window_days = #{rule.windowDays}, inactivity_days = #{rule.inactivityDays},
min_answers = #{rule.minAnswers}, low_accuracy_permille = #{rule.lowAccuracyPermille},
wrong_question_threshold = #{rule.wrongQuestionThreshold},
vocabulary_due_threshold = #{rule.vocabularyDueThreshold},
stale_session_days = #{rule.staleSessionDays}, schedule_frequency = #{rule.scheduleFrequency},
schedule_hour = #{rule.scheduleHour}, schedule_minute = #{rule.scheduleMinute},
schedule_weekdays = #{rule.scheduleWeekdays}, limit_count = #{rule.limitCount},
next_run_time = #{rule.nextRunTime}, version = version + 1,
updater = #{rule.updater}, update_time = CURRENT_TIMESTAMP
WHERE tenant_id = #{tenantId} AND id = #{rule.id} AND version = #{expectedVersion}
AND deleted = false
""")
int updateCas(@Param("tenantId") Long tenantId, @Param("rule") StudentSupervisionRuleDO rule,
@Param("expectedVersion") Integer expectedVersion);
@Update("""
UPDATE education_student_supervision_rule
SET last_run_time = CURRENT_TIMESTAMP, last_candidate_count = #{candidateCount},
last_generated_count = #{generatedCount}, last_error = #{lastError},
version = version + 1, update_time = CURRENT_TIMESTAMP
WHERE tenant_id = #{tenantId} AND id = #{id} AND deleted = false
""")
int recordRun(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("candidateCount") Integer candidateCount,
@Param("generatedCount") Integer generatedCount, @Param("lastError") String lastError);
}

View File

@@ -162,6 +162,41 @@ public interface ErrorCodeConstants {
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, "不支持的反馈类型:{}");
ErrorCode STUDENT_FEEDBACK_NOT_FOUND = new ErrorCode(1_005_004_043, "反馈不存在或无权管理");
ErrorCode STUDENT_FEEDBACK_STATUS_INVALID = new ErrorCode(1_005_004_044, "不支持的反馈状态:{}");
ErrorCode STUDENT_FEEDBACK_PRIORITY_INVALID = new ErrorCode(1_005_004_045, "不支持的反馈优先级:{}");
ErrorCode STUDENT_FEEDBACK_CONFLICT = new ErrorCode(1_005_004_046, "反馈已被其他管理员更新,请刷新后重试");
ErrorCode STUDENT_FEEDBACK_REWARD_FAILED = new ErrorCode(1_005_004_047, "反馈积分奖励发放失败,可稍后重试");
ErrorCode SUPERVISION_RULE_NOT_FOUND = new ErrorCode(1_005_004_048, "督导规则不存在或超出数据范围");
ErrorCode SUPERVISION_RULE_CONFLICT = new ErrorCode(1_005_004_049, "督导规则已被其他管理员更新,请刷新后重试");
ErrorCode SUPERVISION_RULE_INVALID = new ErrorCode(1_005_004_050, "督导规则配置无效:{}");
ErrorCode STUDENT_FOLLOWUP_NOT_FOUND = new ErrorCode(1_005_004_051, "学生跟进任务不存在或超出数据范围");
ErrorCode STUDENT_FOLLOWUP_CONFLICT = new ErrorCode(1_005_004_052, "学生跟进任务已被其他管理员更新,请刷新后重试");
ErrorCode STUDENT_FOLLOWUP_STATUS_INVALID = new ErrorCode(1_005_004_053, "学生跟进状态无效:{}");
ErrorCode SUPERVISION_ASSIGNEE_INVALID = new ErrorCode(1_005_004_054, "督导负责人不是有效的后台管理员");
ErrorCode BADGE_DEFINITION_NOT_FOUND = new ErrorCode(1_005_004_055, "徽章定义不存在或无权管理");
ErrorCode BADGE_DEFINITION_CONFLICT = new ErrorCode(1_005_004_056, "徽章定义已被其他管理员更新,请刷新后重试");
ErrorCode BADGE_DEFINITION_CODE_CONFLICT = new ErrorCode(1_005_004_057, "徽章编码已存在或不允许修改");
ErrorCode BADGE_RULE_INVALID = new ErrorCode(1_005_004_058, "徽章自动授予规则无效");
ErrorCode BADGE_DEFINITION_DISABLED = new ErrorCode(1_005_004_059, "停用徽章不能发放");
ErrorCode BADGE_TARGET_MEMBER_INVALID = new ErrorCode(1_005_004_080, "徽章目标不是有效会员用户");
ErrorCode BADGE_GRANT_CONFLICT = new ErrorCode(1_005_004_081, "徽章发放冲突,请刷新后重试");
// ========== 租户外观、公开设置与主题 1-005-004-090 ~ 1-005-004-099 ==========
ErrorCode TENANT_APPEARANCE_CONFIG_INVALID = new ErrorCode(1_005_004_090, "租户外观配置无效:{}");
ErrorCode TENANT_APPEARANCE_SECRET_REJECTED = new ErrorCode(1_005_004_091,
"公开配置不能包含密钥、密码或令牌,请仅保存 secretRef");
ErrorCode TENANT_THEME_TEMPLATE_NOT_FOUND = new ErrorCode(1_005_004_092, "主题模板不存在或已停用");
ErrorCode TENANT_THEME_DRAFT_NOT_FOUND = new ErrorCode(1_005_004_093, "没有可发布的主题草稿");
ErrorCode TENANT_APPEARANCE_CONFLICT = new ErrorCode(1_005_004_094, "租户外观配置已更新,请刷新后重试");
// ========== 学习激活码 1-005-004-100 ~ 1-005-004-109 ==========
ErrorCode ACTIVATION_CODE_BATCH_NOT_FOUND = new ErrorCode(1_005_004_100, "激活码批次不存在或无权管理");
ErrorCode ACTIVATION_CODE_BATCH_CONFLICT = new ErrorCode(1_005_004_101, "激活码批次已更新,请刷新后重试");
ErrorCode ACTIVATION_CODE_BATCH_INVALID = new ErrorCode(1_005_004_102, "激活码批次配置无效");
ErrorCode ACTIVATION_CODE_NOT_FOUND = new ErrorCode(1_005_004_103, "激活码无效或不可兑换");
ErrorCode ACTIVATION_CODE_USED = new ErrorCode(1_005_004_104, "激活码已被其他会员兑换");
ErrorCode ACTIVATION_CODE_CONFLICT = new ErrorCode(1_005_004_105, "激活码状态已变化,请重试");
// ========== 题目导入任务 1-005-003-080 ~ 1-005-003-089 ==========
ErrorCode QUESTION_IMPORT_NOT_FOUND = new ErrorCode(1_005_003_080, "题目导入任务不存在或无权访问");
@@ -170,6 +205,7 @@ public interface ErrorCodeConstants {
ErrorCode QUESTION_IMPORT_SCAN_NOT_CLEAN = new ErrorCode(1_005_003_083, "导入文件未通过安全扫描");
ErrorCode QUESTION_IMPORT_PARSER_UNAVAILABLE = new ErrorCode(1_005_003_084, "导入解析器不可用");
ErrorCode QUESTION_IMPORT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_003_085, "当前题库数据源模式不支持执行导入:{}");
ErrorCode QUESTION_IMPORT_INVALID_FILE = new ErrorCode(1_005_003_086, "题目导入文件格式或内容不安全:{}");
// ========== 内容导入任务 1-005-004-060 ~ 1-005-004-069 ==========
ErrorCode CONTENT_IMPORT_JOB_NOT_FOUND = new ErrorCode(1_005_004_060, "内容导入任务不存在或无权访问");

View File

@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.education.framework.datapermission;
import cn.iocoder.yudao.framework.datapermission.core.rule.dept.DeptDataPermissionRuleCustomizer;
import cn.iocoder.yudao.module.education.dal.dataobject.classroom.EducationClassDO;
import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentFollowupDO;
import cn.iocoder.yudao.module.education.dal.dataobject.supervision.StudentSupervisionRuleDO;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 将教育运营对象接入 RuoYi 的部门/本人数据权限规则。
*
* <p>Education 只冗余 System 的部门与管理员编号作为授权投影,不复制账号、角色或部门模型。</p>
*/
@Configuration(proxyBeanMethods = false)
public class EducationDataPermissionConfiguration {
@Bean
public DeptDataPermissionRuleCustomizer educationDeptDataPermissionRuleCustomizer() {
return rule -> {
rule.addDeptColumn(EducationClassDO.class);
rule.addUserColumn(EducationClassDO.class, "owner_user_id");
rule.addDeptColumn(StudentSupervisionRuleDO.class);
rule.addUserColumn(StudentSupervisionRuleDO.class, "owner_user_id");
rule.addDeptColumn(StudentFollowupDO.class);
rule.addUserColumn(StudentFollowupDO.class, "owner_user_id");
};
}
}

View File

@@ -17,8 +17,13 @@ public class MemberPointAwardPort {
}
public void addLearningPoints(Long userId, Integer points, Long awardId) {
memberPointApi.addPoint(userId, points, MemberPointBizType.EDUCATION_LEARNING,
memberPointApi.addPointOnce(userId, points, MemberPointBizType.EDUCATION_LEARNING,
"education-learning-award:" + awardId);
}
public void addFeedbackReward(Long userId, Integer points, Long feedbackId) {
memberPointApi.addPointOnce(userId, points, MemberPointBizType.EDUCATION_LEARNING,
"education-feedback-reward:" + feedbackId);
}
}

View File

@@ -3,13 +3,10 @@ package cn.iocoder.yudao.module.education.runtime;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
@Component
public class ScalarLegacyDependencyTelemetry implements EducationRuntimeDependency {
private final EducationProperties educationProperties;

View File

@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.education.service.activationcode;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.admin.activationcode.vo.ActivationCodeAdminVOs.*;
import cn.iocoder.yudao.module.education.controller.app.activationcode.vo.ActivationCodeAppVOs.*;
public interface ActivationCodeService {
PageResult<BatchResp> batchPage(BatchPageReq req);
BatchResp createBatch(BatchSaveReq req, Long actorId);
BatchResp updateBatch(Long id, BatchSaveReq req, Long actorId);
GenerateResp generate(Long id, GenerateReq req, Long actorId);
PageResult<CodeResp> codePage(CodePageReq req);
CodeResp disable(Long id, Integer expectedVersion);
CheckResp check(String code);
RedeemResp redeem(String code, Long userId);
}

View File

@@ -0,0 +1,166 @@
package cn.iocoder.yudao.module.education.service.activationcode;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.admin.activationcode.vo.ActivationCodeAdminVOs.*;
import cn.iocoder.yudao.module.education.controller.app.activationcode.vo.ActivationCodeAppVOs.*;
import cn.iocoder.yudao.module.education.dal.dataobject.activationcode.*;
import cn.iocoder.yudao.module.education.dal.dataobject.commercialization.EducationResourceProductBindingDO;
import cn.iocoder.yudao.module.education.dal.mysql.activationcode.*;
import cn.iocoder.yudao.module.education.dal.mysql.commercialization.EducationResourceProductBindingMapper;
import cn.iocoder.yudao.module.education.service.commercialization.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.ObjectProvider;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.LocalDateTime;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
public class ActivationCodeServiceImpl implements ActivationCodeService {
private static final Set<String> STATUSES = Set.of("ACTIVE", "DISABLED");
private static final Set<String> CODE_STATUSES = Set.of("AVAILABLE", "REDEEMED", "DISABLED");
private static final char[] ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ".toCharArray();
private static final SecureRandom RANDOM = new SecureRandom();
private final ActivationCodeBatchMapper batchMapper;
private final ActivationCodeMapper codeMapper;
private final EducationResourceProductBindingMapper bindingMapper;
private final ObjectProvider<EducationProductCatalogPort> productCatalogPorts;
private final EducationEntitlementService entitlementService;
public ActivationCodeServiceImpl(ActivationCodeBatchMapper batchMapper, ActivationCodeMapper codeMapper,
EducationResourceProductBindingMapper bindingMapper,
ObjectProvider<EducationProductCatalogPort> productCatalogPorts,
EducationEntitlementService entitlementService) {
this.batchMapper=batchMapper; this.codeMapper=codeMapper; this.bindingMapper=bindingMapper;
this.productCatalogPorts=productCatalogPorts; this.entitlementService=entitlementService;
}
@Override public PageResult<BatchResp> batchPage(BatchPageReq req) {
PageResult<ActivationCodeBatchDO> page=batchMapper.selectAdminPage(req,tenantId(),trim(req.getKeyword()),optional(req.getStatus(),STATUSES));
return new PageResult<>(page.getList().stream().map(this::batchResp).toList(),page.getTotal());
}
@Override @Transactional(rollbackFor=Exception.class)
public BatchResp createBatch(BatchSaveReq req,Long actorId) {
validateTarget(req.getProductSpuId());
ActivationCodeBatchDO item=build(req,actorId); item.setTotalCount(0); item.setRedeemedCount(0); item.setVersion(0);
batchMapper.insert(item); return batchResp(requireBatch(item.getId()));
}
@Override @Transactional(rollbackFor=Exception.class)
public BatchResp updateBatch(Long id,BatchSaveReq req,Long actorId) {
ActivationCodeBatchDO current=requireBatch(id);
if(req.getExpectedVersion()==null||!req.getExpectedVersion().equals(current.getVersion())) throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
if(current.getTotalCount()>0&&(!Objects.equals(current.getProductSpuId(),req.getProductSpuId())
||!Objects.equals(current.getDurationDays(),req.getDurationDays())
||!Objects.equals(current.getCodePrefix(),prefix(req.getCodePrefix())))) throw exception(ACTIVATION_CODE_BATCH_INVALID);
validateTarget(req.getProductSpuId()); ActivationCodeBatchDO update=build(req,actorId); update.setId(id);
if(batchMapper.updateCas(tenantId(),update,req.getExpectedVersion())!=1) throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
return batchResp(requireBatch(id));
}
@Override @Transactional(rollbackFor=Exception.class)
public GenerateResp generate(Long id,GenerateReq req,Long actorId) {
ActivationCodeBatchDO batch=requireBatch(id);
if(!"ACTIVE".equals(batch.getStatus())||!Objects.equals(batch.getVersion(),req.getExpectedVersion())) throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
List<GeneratedCodeResp> generated=new ArrayList<>(req.getCount());
for(int i=0;i<req.getCount();i++){
String plain=newCode(batch.getCodePrefix());
ActivationCodeDO item=ActivationCodeDO.builder().batchId(id).codeHash(hash(plain)).codeMasked(mask(plain))
.status("AVAILABLE").version(0).build(); item.setTenantId(tenantId());
item.setCreator(String.valueOf(actorId)); item.setUpdater(String.valueOf(actorId)); codeMapper.insert(item);
generated.add(GeneratedCodeResp.builder().id(item.getId()).code(plain).codeMasked(item.getCodeMasked()).version(0).build());
}
if(batchMapper.addGeneratedCas(tenantId(),id,req.getExpectedVersion(),req.getCount(),String.valueOf(actorId))!=1)
throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
return GenerateResp.builder().batch(batchResp(requireBatch(id))).codes(generated).build();
}
@Override public PageResult<CodeResp> codePage(CodePageReq req) {
PageResult<ActivationCodeDO> page=codeMapper.selectAdminPage(req,tenantId(),req.getBatchId(),optional(req.getStatus(),CODE_STATUSES));
return new PageResult<>(page.getList().stream().map(this::codeResp).toList(),page.getTotal());
}
@Override @Transactional(rollbackFor=Exception.class)
public CodeResp disable(Long id,Integer expectedVersion) {
if(expectedVersion==null||codeMapper.disable(tenantId(),id,expectedVersion)!=1) throw exception(ACTIVATION_CODE_CONFLICT);
ActivationCodeDO item=codeMapper.selectById(id);
if(item==null||!Objects.equals(item.getTenantId(),tenantId())) throw exception(ACTIVATION_CODE_NOT_FOUND);
return codeResp(item);
}
@Override public CheckResp check(String raw) {
ActivationCodeDO code=codeMapper.selectByHash(tenantId(),hash(normalize(raw)));
if(code==null||!"AVAILABLE".equals(code.getStatus())) return CheckResp.builder().valid(false).build();
ActivationCodeBatchDO batch=batchMapper.selectOwned(tenantId(),code.getBatchId());
if(batch==null||!"ACTIVE".equals(batch.getStatus())) return CheckResp.builder().valid(false).build();
EducationResourceProductBindingDO binding=bindingMapper.selectByProduct(tenantId(),batch.getProductSpuId());
if(binding==null||!"ACTIVE".equals(binding.getStatus())) return CheckResp.builder().valid(false).build();
return CheckResp.builder().valid(true).codeMasked(code.getCodeMasked()).batchName(batch.getName())
.durationDays(batch.getDurationDays()).build();
}
@Override @Transactional(rollbackFor=Exception.class)
public RedeemResp redeem(String raw,Long userId) {
ActivationCodeDO code=codeMapper.selectForUpdate(tenantId(),hash(normalize(raw)));
if(code==null) throw exception(ACTIVATION_CODE_NOT_FOUND);
ActivationCodeBatchDO batch=requireBatch(code.getBatchId());
EducationResourceProductBindingDO binding=bindingMapper.selectByProduct(tenantId(),batch.getProductSpuId());
if(binding==null) throw exception(ENTITLEMENT_RESOURCE_NOT_FOUND);
if("REDEEMED".equals(code.getStatus())){
if(!Objects.equals(code.getRedeemedBy(),userId)) throw exception(ACTIVATION_CODE_USED);
return result(code.getEntitlementId(),binding,batch,true);
}
if(!"AVAILABLE".equals(code.getStatus())||!"ACTIVE".equals(batch.getStatus())||!"ACTIVE".equals(binding.getStatus()))
throw exception(ACTIVATION_CODE_NOT_FOUND);
LocalDateTime now=LocalDateTime.now(); LocalDateTime expires=batch.getDurationDays()==0?null:now.plusDays(batch.getDurationDays());
Long entitlementId=entitlementService.apply(new EntitlementCommand("ACTIVATION_CODE",String.valueOf(code.getId()),"GRANT",
userId,binding.getResourceType(),binding.getResourceId(),binding.getProductSpuId(),now,expires,now));
if(codeMapper.markRedeemed(tenantId(),code.getId(),userId,entitlementId)!=1) throw exception(ACTIVATION_CODE_CONFLICT);
if(batchMapper.incrementRedeemed(tenantId(),batch.getId())!=1) throw exception(ACTIVATION_CODE_BATCH_CONFLICT);
return result(entitlementId,binding,batch,false);
}
private RedeemResp result(Long entitlementId,EducationResourceProductBindingDO binding,ActivationCodeBatchDO batch,boolean replay){
return RedeemResp.builder().entitlementId(entitlementId).resourceId(binding.getResourceId())
.productSpuId(binding.getProductSpuId()).durationDays(batch.getDurationDays()).idempotent(replay).build();
}
private ActivationCodeBatchDO build(BatchSaveReq req,Long actorId){
String status=choice(req.getStatus(),STATUSES,"ACTIVE");
ActivationCodeBatchDO out=ActivationCodeBatchDO.builder().name(req.getName().trim()).productSpuId(req.getProductSpuId())
.durationDays(req.getDurationDays()).codePrefix(prefix(req.getCodePrefix())).status(status).build();
out.setTenantId(tenantId()); out.setCreator(String.valueOf(actorId)); out.setUpdater(String.valueOf(actorId)); return out;
}
private void validateTarget(Long productSpuId){
EducationProductCatalogPort productCatalog=productCatalogPorts.getIfAvailable();
if(productCatalog!=null&&!productCatalog.isProductAvailable(productSpuId)) throw exception(PRODUCT_NOT_AVAILABLE);
activeBinding(productSpuId);
}
private EducationResourceProductBindingDO activeBinding(Long productSpuId){
EducationResourceProductBindingDO binding=bindingMapper.selectByProduct(tenantId(),productSpuId);
if(binding==null||!"ACTIVE".equals(binding.getStatus())) throw exception(ENTITLEMENT_RESOURCE_NOT_FOUND); return binding;
}
private ActivationCodeBatchDO requireBatch(Long id){ ActivationCodeBatchDO out=batchMapper.selectOwned(tenantId(),id); if(out==null)throw exception(ACTIVATION_CODE_BATCH_NOT_FOUND); return out; }
private BatchResp batchResp(ActivationCodeBatchDO i){return BatchResp.builder().id(i.getId()).name(i.getName()).productSpuId(i.getProductSpuId())
.durationDays(i.getDurationDays()).codePrefix(i.getCodePrefix()).status(i.getStatus()).totalCount(i.getTotalCount())
.redeemedCount(i.getRedeemedCount()).version(i.getVersion()).createTime(i.getCreateTime()).updateTime(i.getUpdateTime()).build();}
private CodeResp codeResp(ActivationCodeDO i){return CodeResp.builder().id(i.getId()).batchId(i.getBatchId()).codeMasked(i.getCodeMasked())
.status(i.getStatus()).redeemedBy(i.getRedeemedBy()).redeemedAt(i.getRedeemedAt()).entitlementId(i.getEntitlementId())
.version(i.getVersion()).createTime(i.getCreateTime()).build();}
private static String newCode(String prefix){StringBuilder out=new StringBuilder(prefix);if(!prefix.isEmpty())out.append('-');for(int i=0;i<16;i++)out.append(ALPHABET[RANDOM.nextInt(ALPHABET.length)]);return out.toString();}
private static String normalize(String value){return value==null?"":value.trim().toUpperCase(Locale.ROOT);}
private static String prefix(String value){return value==null?"":value.trim().toUpperCase(Locale.ROOT);}
private static String mask(String value){return value.substring(0,Math.min(4,value.length()))+"****"+value.substring(Math.max(0,value.length()-4));}
private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
private static String optional(String value,Set<String> allowed){if(value==null||value.isBlank())return null;return choice(value,allowed,null);}
private static String choice(String value,Set<String> allowed,String fallback){String out=value==null||value.isBlank()?fallback:value.trim().toUpperCase(Locale.ROOT);if(out==null||!allowed.contains(out))throw exception(ACTIVATION_CODE_BATCH_INVALID);return out;}
private Long tenantId(){return TenantContextHolder.getRequiredTenantId();}
}

View File

@@ -0,0 +1,268 @@
package cn.iocoder.yudao.module.education.service.appearance;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.util.*;
import java.util.regex.Pattern;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.TENANT_APPEARANCE_CONFIG_INVALID;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.TENANT_APPEARANCE_SECRET_REJECTED;
/**
* Public appearance policy. It fails closed because all accepted values are eventually rendered by a client.
*/
@Component
public class TenantAppearancePolicy {
private static final Set<String> THEME_MODES = Set.of("light", "dark", "auto");
private static final Set<String> THEME_DENSITIES = Set.of("compact", "comfortable", "dense");
private static final Set<String> THEME_KEYS = Set.of("mode", "primaryColor", "accentColor",
"backgroundColor", "surfaceColor", "textColor", "mutedColor", "borderColor", "successColor",
"warningColor", "dangerColor", "borderRadius", "buttonRadius", "fontFamily", "layoutDensity",
"customCssVars", "icons");
private static final Set<String> ASSET_KEYS = Set.of("logoUrl", "faviconUrl", "appIconUrl", "shareImageUrl",
"loginPosterUrl", "splashImageUrl", "iconSet", "shareCardStyle");
private static final Set<String> URL_ASSET_KEYS = Set.of("logoUrl", "faviconUrl", "appIconUrl", "shareImageUrl",
"loginPosterUrl", "splashImageUrl");
private static final Pattern SAFE_TOKEN = Pattern.compile("^[a-z0-9][a-z0-9_-]{0,31}$", Pattern.CASE_INSENSITIVE);
private static final Pattern SAFE_CSS_KEY = Pattern.compile("^--tiku-[a-z0-9-]{1,48}$", Pattern.CASE_INSENSITIVE);
private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9a-f]{6}$", Pattern.CASE_INSENSITIVE);
private static final Pattern SAFE_FONT = Pattern.compile("^[\\u4e00-\\u9fa5a-zA-Z0-9\\s,\"'\\-]{1,80}$");
private static final Pattern UNSAFE_PUBLIC_STRING = Pattern.compile(
"(app_private\\.tenant_secrets|-----BEGIN|<script|javascript:|data:text/html|expression\\s*\\(|@import|url\\s*\\()",
Pattern.CASE_INSENSITIVE);
public void assertPublicConfigHasNoSecrets(Object value) {
assertPublicConfigHasNoSecrets(value, "publicConfig");
}
public Map<String, Object> sanitizeThemeTokens(Map<String, Object> value) {
Map<String, Object> source = objectValue(value);
assertPublicConfigHasNoSecrets(source, "theme");
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
String key = entry.getKey();
Object raw = entry.getValue();
if (!THEME_KEYS.contains(key)) {
throw invalid("不支持的主题字段:" + key);
}
if (key.endsWith("Color")) {
String color = stringOrEmpty(raw);
if (!color.isEmpty() && !HEX_COLOR.matcher(color).matches()) {
throw invalid(key + " 必须是 #RRGGBB 颜色");
}
result.put(key, raw instanceof String ? color.toLowerCase(Locale.ROOT) : raw);
} else if ("mode".equals(key)) {
result.put(key, choice(raw, THEME_MODES, "light", key));
} else if ("layoutDensity".equals(key)) {
result.put(key, choice(raw, THEME_DENSITIES, "comfortable", key));
} else if ("borderRadius".equals(key) || "buttonRadius".equals(key)) {
Integer number = radius(raw, key);
if (number != null) {
result.put(key, number);
}
} else if ("fontFamily".equals(key)) {
result.put(key, fontFamily(raw));
} else if ("customCssVars".equals(key)) {
result.put(key, cssVars(raw));
} else if ("icons".equals(key)) {
result.put(key, icons(raw));
}
}
return result;
}
public Map<String, Object> sanitizePublicAssets(Map<String, Object> value) {
Map<String, Object> source = objectValue(value);
assertPublicConfigHasNoSecrets(source, "publicAssets");
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
if (!ASSET_KEYS.contains(entry.getKey())) {
throw invalid("不支持的主题资源字段:" + entry.getKey());
}
result.put(entry.getKey(), URL_ASSET_KEYS.contains(entry.getKey())
? publicUrl(entry.getValue(), "publicAssets." + entry.getKey())
: token(entry.getValue(), "publicAssets." + entry.getKey()));
}
return result;
}
public Map<String, Object> mergeTheme(Map<String, Object> template, Map<String, Object> overrides) {
Map<String, Object> merged = new LinkedHashMap<>(objectValue(template));
merged.putAll(sanitizeThemeTokens(overrides));
return merged;
}
public Map<String, Object> mergePublicAssets(Map<String, Object> template, Map<String, Object> overrides) {
Map<String, Object> merged = new LinkedHashMap<>(objectValue(template));
merged.putAll(sanitizePublicAssets(overrides));
return merged;
}
private void assertPublicConfigHasNoSecrets(Object value, String path) {
if (value instanceof Map<?, ?> map) {
for (Map.Entry<?, ?> entry : map.entrySet()) {
String key = String.valueOf(entry.getKey());
String normalized = key.toLowerCase(Locale.ROOT).replaceAll("[-_\\s]", "");
boolean allowedRef = normalized.equals("secretref") || normalized.endsWith("secretref");
boolean sensitive = normalized.contains("secret") || normalized.contains("password")
|| normalized.contains("token") || normalized.contains("privatekey")
|| normalized.contains("apikey") || normalized.contains("apiv3key")
|| normalized.contains("mchkey") || normalized.contains("signkey")
|| normalized.contains("aeskey") || normalized.contains("partnerkey");
if (sensitive && !allowedRef) {
throw exception(TENANT_APPEARANCE_SECRET_REJECTED);
}
assertPublicConfigHasNoSecrets(entry.getValue(), path + "." + key);
}
} else if (value instanceof Iterable<?> iterable) {
int index = 0;
for (Object item : iterable) {
assertPublicConfigHasNoSecrets(item, path + "[" + index++ + "]");
}
} else if (value != null && value.getClass().isArray()) {
for (int i = 0; i < java.lang.reflect.Array.getLength(value); i++) {
assertPublicConfigHasNoSecrets(java.lang.reflect.Array.get(value, i), path + "[" + i + "]");
}
}
}
private Map<String, String> cssVars(Object value) {
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : objectValue(value).entrySet()) {
if (!SAFE_CSS_KEY.matcher(entry.getKey()).matches()) {
throw invalid("CSS 变量必须以 --tiku- 开头:" + entry.getKey());
}
String text = publicString(entry.getValue(), "theme.customCssVars." + entry.getKey());
if (text.length() > 96 || text.contains("{") || text.contains("}") || text.contains(";")) {
throw invalid("CSS 变量值无效:" + entry.getKey());
}
result.put(entry.getKey(), text);
}
return result;
}
private Map<String, String> icons(Object value) {
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : objectValue(value).entrySet()) {
if (!SAFE_TOKEN.matcher(entry.getKey()).matches()) {
throw invalid("图标键无效:" + entry.getKey());
}
String text = publicString(entry.getValue(), "theme.icons." + entry.getKey());
if (text.startsWith("/") || text.matches("(?i)^https?://.*")) {
result.put(entry.getKey(), publicUrl(text, "theme.icons." + entry.getKey()));
} else if (!SAFE_TOKEN.matcher(text).matches()) {
throw invalid("图标值无效:" + entry.getKey());
} else {
result.put(entry.getKey(), text);
}
}
return result;
}
private String publicUrl(Object value, String path) {
String text = publicString(value, path);
if (text.isEmpty()) {
return "";
}
if (text.startsWith("/")) {
if (text.startsWith("//") || text.contains("\\")) {
throw invalid(path + " 必须是 HTTPS 地址或站内绝对路径");
}
return text;
}
try {
URI uri = URI.create(text);
String host = uri.getHost();
boolean localHttp = "http".equalsIgnoreCase(uri.getScheme()) && host != null
&& Set.of("localhost", "127.0.0.1", "::1").contains(host.toLowerCase(Locale.ROOT));
if (!"https".equalsIgnoreCase(uri.getScheme()) && !localHttp) {
throw invalid(path + " 必须使用 HTTPS");
}
if (host == null || uri.getUserInfo() != null) {
throw invalid(path + " 必须是有效公开地址");
}
return uri.toString();
} catch (IllegalArgumentException ex) {
throw invalid(path + " 必须是 HTTPS 地址或站内绝对路径");
}
}
private String token(Object value, String path) {
String text = publicString(value, path);
if (!text.isEmpty() && !SAFE_TOKEN.matcher(text).matches()) {
throw invalid(path + " 必须是安全 token");
}
return text;
}
private String fontFamily(Object value) {
String text = stringOrEmpty(value);
if (text.isEmpty()) {
return "system";
}
publicString(text, "theme.fontFamily");
if (!SAFE_FONT.matcher(text).matches()) {
throw invalid("fontFamily 含有不支持的字符");
}
return text;
}
private Integer radius(Object value, String key) {
if (value == null || "".equals(value)) {
return null;
}
try {
double parsed = value instanceof Number number ? number.doubleValue() : Double.parseDouble(String.valueOf(value));
if (!Double.isFinite(parsed) || parsed < 0 || parsed > 32) {
throw invalid(key + " 必须在 0 到 32 之间");
}
return (int) parsed;
} catch (NumberFormatException ex) {
throw invalid(key + " 必须在 0 到 32 之间");
}
}
private String choice(Object value, Set<String> allowed, String fallback, String key) {
String text = stringOrEmpty(value);
text = text.isEmpty() ? fallback : text;
if (!allowed.contains(text)) {
throw invalid(key + " 的值无效:" + text);
}
return text;
}
private String publicString(Object value, String path) {
if (value == null || "".equals(value)) {
return "";
}
if (!(value instanceof String text)) {
throw invalid(path + " 必须是字符串");
}
text = text.trim();
if (UNSAFE_PUBLIC_STRING.matcher(text).find()) {
throw exception(TENANT_APPEARANCE_SECRET_REJECTED);
}
return text;
}
private String stringOrEmpty(Object value) {
return value instanceof String text ? text.trim() : value == null ? "" : String.valueOf(value).trim();
}
@SuppressWarnings("unchecked")
private Map<String, Object> objectValue(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return Collections.emptyMap();
}
Map<String, Object> result = new LinkedHashMap<>();
map.forEach((key, item) -> result.put(String.valueOf(key), item));
return result;
}
private RuntimeException invalid(String reason) {
return exception(TENANT_APPEARANCE_CONFIG_INVALID, reason);
}
}

View File

@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.education.service.appearance;
import cn.iocoder.yudao.module.education.controller.admin.appearance.vo.TenantAppearanceVOs.*;
import java.util.List;
public interface TenantAppearanceService {
AppearanceResp getAppearance();
AppearanceResp updateBranding(BrandingSaveReq req, Long actorId);
AppearanceResp updateSettings(SettingsSaveReq req, Long actorId);
List<ThemeTemplateResp> getThemeTemplates();
AppearanceResp previewTheme(ThemePreviewReq req, Long actorId);
AppearanceResp publishTheme(ThemePublishReq req, Long actorId);
PublicAppearanceResp getPublicAppearance();
}

View File

@@ -0,0 +1,214 @@
package cn.iocoder.yudao.module.education.service.appearance;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.admin.appearance.vo.TenantAppearanceVOs.*;
import cn.iocoder.yudao.module.education.dal.dataobject.appearance.TenantAppearanceDO;
import cn.iocoder.yudao.module.education.dal.dataobject.appearance.TenantThemeTemplateDO;
import cn.iocoder.yudao.module.education.dal.mysql.appearance.TenantAppearanceMapper;
import cn.iocoder.yudao.module.education.dal.mysql.appearance.TenantThemeTemplateMapper;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
public class TenantAppearanceServiceImpl implements TenantAppearanceService {
private final TenantAppearanceMapper appearanceMapper;
private final TenantThemeTemplateMapper templateMapper;
private final TenantAppearancePolicy policy;
private final TenantCommonApi tenantCommonApi;
private final AdminUserApi adminUserApi;
public TenantAppearanceServiceImpl(TenantAppearanceMapper appearanceMapper,
TenantThemeTemplateMapper templateMapper,
TenantAppearancePolicy policy,
TenantCommonApi tenantCommonApi,
AdminUserApi adminUserApi) {
this.appearanceMapper = appearanceMapper;
this.templateMapper = templateMapper;
this.policy = policy;
this.tenantCommonApi = tenantCommonApi;
this.adminUserApi = adminUserApi;
}
@Override
@Transactional(rollbackFor = Exception.class)
public AppearanceResp getAppearance() {
TenantState state = currentState("0");
return toAppearance(state.row(), state.systemTenant());
}
@Override
@Transactional(rollbackFor = Exception.class)
public AppearanceResp updateBranding(BrandingSaveReq req, Long actorId) {
TenantState state = currentState(actor(actorId));
if (appearanceMapper.updateBranding(state.tenantId(), req.getBrandName().trim(), trim(req.getShortName()),
trim(req.getSlogan()), trim(req.getOrgName()), trim(req.getLogoUrl()), trim(req.getFaviconUrl()),
trim(req.getServiceWechat()), trim(req.getServiceAccountName()), req.getExpectedVersion(), actor(actorId)) == 0) {
throw exception(TENANT_APPEARANCE_CONFLICT);
}
return toAppearance(requireCurrent(state.tenantId()), state.systemTenant());
}
@Override
@Transactional(rollbackFor = Exception.class)
public AppearanceResp updateSettings(SettingsSaveReq req, Long actorId) {
TenantState state = currentState(actor(actorId));
Map<String, Object> featureFlags = map(req.getFeatureFlags());
Map<String, Object> adminFeatureFlags = map(req.getAdminFeatureFlags());
Map<String, Object> publicConfig = map(req.getPublicConfig());
policy.assertPublicConfigHasNoSecrets(publicConfig);
if (appearanceMapper.updateSettings(state.tenantId(), json(featureFlags), json(adminFeatureFlags),
json(publicConfig), req.getExpectedVersion(), actor(actorId)) == 0) {
throw exception(TENANT_APPEARANCE_CONFLICT);
}
return toAppearance(requireCurrent(state.tenantId()), state.systemTenant());
}
@Override
public List<ThemeTemplateResp> getThemeTemplates() {
return templateMapper.selectActiveList().stream().map(this::toTemplate).toList();
}
@Override
@Transactional(rollbackFor = Exception.class)
public AppearanceResp previewTheme(ThemePreviewReq req, Long actorId) {
TenantState state = currentState(actor(actorId));
TenantThemeTemplateDO template = requireTemplate(req.getTemplateCode());
Map<String, Object> theme = policy.mergeTheme(template.getTheme(), req.getTheme());
Map<String, Object> publicAssets = policy.mergePublicAssets(template.getPublicAssets(), req.getPublicAssets());
if (appearanceMapper.updateDraft(state.tenantId(), template.getCode(), json(theme), json(publicAssets), actorId,
req.getExpectedVersion(), actor(actorId)) == 0) {
throw exception(TENANT_APPEARANCE_CONFLICT);
}
return toAppearance(requireCurrent(state.tenantId()), state.systemTenant());
}
@Override
@Transactional(rollbackFor = Exception.class)
public AppearanceResp publishTheme(ThemePublishReq req, Long actorId) {
TenantState state = currentState(actor(actorId));
boolean useDraft = req.getUseDraft() == null || req.getUseDraft();
String templateCode;
Map<String, Object> theme;
Map<String, Object> publicAssets;
if (useDraft) {
if (StrUtil.isBlank(state.row().getDraftTemplateCode())) {
throw exception(TENANT_THEME_DRAFT_NOT_FOUND);
}
templateCode = state.row().getDraftTemplateCode();
// Re-validate persisted drafts at the publication boundary.
theme = policy.sanitizeThemeTokens(state.row().getDraftTheme());
publicAssets = policy.sanitizePublicAssets(state.row().getDraftPublicAssets());
} else {
if (StrUtil.isBlank(req.getTemplateCode())) {
throw exception(TENANT_APPEARANCE_CONFIG_INVALID, "直接发布时必须选择主题模板");
}
TenantThemeTemplateDO template = requireTemplate(req.getTemplateCode());
templateCode = template.getCode();
theme = policy.mergeTheme(template.getTheme(), req.getTheme());
publicAssets = policy.mergePublicAssets(template.getPublicAssets(), req.getPublicAssets());
}
if (appearanceMapper.publish(state.tenantId(), templateCode, json(theme), json(publicAssets), actorId,
req.getExpectedVersion(), actor(actorId)) == 0) {
throw exception(TENANT_APPEARANCE_CONFLICT);
}
return toAppearance(requireCurrent(state.tenantId()), state.systemTenant());
}
@Override
@Transactional(rollbackFor = Exception.class)
public PublicAppearanceResp getPublicAppearance() {
TenantState state = currentState("0");
TenantAppearanceDO row = state.row();
return PublicAppearanceResp.builder()
.tenantId(state.tenantId())
.brandName(StrUtil.blankToDefault(row.getBrandName(), state.systemTenant().getName()))
.shortName(row.getShortName()).slogan(row.getSlogan()).logoUrl(row.getLogoUrl())
.faviconUrl(row.getFaviconUrl()).serviceWechat(row.getServiceWechat())
.serviceAccountName(row.getServiceAccountName())
.theme(map(row.getActiveTheme())).publicAssets(map(row.getActivePublicAssets()))
.featureFlags(map(row.getFeatureFlags())).publicConfig(map(row.getPublicConfig()))
.publishedTime(row.getPublishedTime()).build();
}
private TenantState currentState(String actor) {
Long tenantId = TenantContextHolder.getRequiredTenantId();
TenantRespDTO systemTenant = tenantCommonApi.getTenant(tenantId);
if (systemTenant == null) {
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
}
appearanceMapper.insertDefaultIgnore(tenantId, systemTenant.getName(), actor);
return new TenantState(tenantId, systemTenant, requireCurrent(tenantId));
}
private TenantAppearanceDO requireCurrent(Long tenantId) {
TenantAppearanceDO row = appearanceMapper.selectOwned(tenantId);
if (row == null) {
throw exception(TENANT_APPEARANCE_CONFLICT);
}
return row;
}
private TenantThemeTemplateDO requireTemplate(String code) {
TenantThemeTemplateDO template = templateMapper.selectActive(code);
if (template == null) {
throw exception(TENANT_THEME_TEMPLATE_NOT_FOUND);
}
return template;
}
private AppearanceResp toAppearance(TenantAppearanceDO row, TenantRespDTO systemTenant) {
Set<Long> userIds = new LinkedHashSet<>();
if (row.getPublishedBy() != null) userIds.add(row.getPublishedBy());
if (row.getDraftUpdatedBy() != null) userIds.add(row.getDraftUpdatedBy());
Map<Long, AdminUserRespDTO> users = userIds.isEmpty() ? Map.of() : adminUserApi.getUserMap(userIds);
return AppearanceResp.builder()
.tenantId(row.getTenantId()).systemTenantName(systemTenant.getName())
.brandName(StrUtil.blankToDefault(row.getBrandName(), systemTenant.getName()))
.shortName(row.getShortName()).slogan(row.getSlogan()).orgName(row.getOrgName())
.logoUrl(row.getLogoUrl()).faviconUrl(row.getFaviconUrl()).serviceWechat(row.getServiceWechat())
.serviceAccountName(row.getServiceAccountName())
.featureFlags(map(row.getFeatureFlags())).adminFeatureFlags(map(row.getAdminFeatureFlags()))
.publicConfig(map(row.getPublicConfig()))
.activeTemplateCode(row.getActiveTemplateCode()).activeTheme(map(row.getActiveTheme()))
.activePublicAssets(map(row.getActivePublicAssets()))
.draftTemplateCode(row.getDraftTemplateCode()).draftTheme(map(row.getDraftTheme()))
.draftPublicAssets(map(row.getDraftPublicAssets())).themeStatus(row.getThemeStatus())
.publishedTime(row.getPublishedTime()).publishedBy(row.getPublishedBy())
.publishedByNickname(nickname(users, row.getPublishedBy()))
.draftUpdatedBy(row.getDraftUpdatedBy()).draftUpdatedByNickname(nickname(users, row.getDraftUpdatedBy()))
.version(row.getVersion()).updateTime(row.getUpdateTime()).build();
}
private ThemeTemplateResp toTemplate(TenantThemeTemplateDO template) {
return ThemeTemplateResp.builder().code(template.getCode()).name(template.getName())
.description(template.getDescription()).previewImageUrl(template.getPreviewImageUrl())
.theme(map(template.getTheme())).publicAssets(map(template.getPublicAssets()))
.sortOrder(template.getSortOrder()).build();
}
private String nickname(Map<Long, AdminUserRespDTO> users, Long userId) {
AdminUserRespDTO user = userId == null ? null : users.get(userId);
return user == null ? null : user.getNickname();
}
private static String actor(Long actorId) { return actorId == null ? "0" : String.valueOf(actorId); }
private static String trim(String value) { return StrUtil.isBlank(value) ? null : value.trim(); }
private static String json(Map<String, Object> value) { return JsonUtils.toJsonString(map(value)); }
private static Map<String, Object> map(Map<String, Object> value) {
return value == null ? new LinkedHashMap<>() : new LinkedHashMap<>(value);
}
private record TenantState(Long tenantId, TenantRespDTO systemTenant, TenantAppearanceDO row) {}
}

View File

@@ -55,7 +55,9 @@ public class EducationAssetAdmissionServiceImpl implements EducationAssetAdmissi
FileDescriptor descriptor = fileApi.createFile(new FileContent(bytes, name,
"education/import-assets/" + tenantId, contentType));
FileScanStatus scanStatus = fileApi.scan(descriptor);
if (scanStatus == null) scanStatus = FileScanStatus.UNAVAILABLE;
if (scanStatus != FileScanStatus.CLEAN) {
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
}
EducationImportAssetDO asset = new EducationImportAssetDO();
asset.setTenantId(tenantId);
@@ -63,7 +65,7 @@ public class EducationAssetAdmissionServiceImpl implements EducationAssetAdmissi
asset.setOriginalName(name);
asset.setContentType(contentType);
asset.setSize((long) bytes.length);
asset.setSha256(DigestUtil.sha256Hex(bytes));
asset.setSha256(descriptor.checksumSha256() != null ? descriptor.checksumSha256() : DigestUtil.sha256Hex(bytes));
asset.setFileReference(descriptor.reference());
asset.setScanStatus(scanStatus.name());
assetMapper.insert(asset);

View File

@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.education.service.badge;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.*;
public interface BadgeAdminService {
PageResult<DefinitionResp> getDefinitionPage(DefinitionPageReq req);
DefinitionResp createDefinition(DefinitionSaveReq req, Long actorId);
DefinitionResp updateDefinition(Long id, DefinitionSaveReq req, Long actorId);
PageResult<GrantResp> getGrantPage(GrantPageReq req);
GrantResp grant(ManualGrantReq req, Long actorId);
}

View File

@@ -0,0 +1,167 @@
package cn.iocoder.yudao.module.education.service.badge;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.*;
import cn.iocoder.yudao.module.education.dal.dataobject.LearningAwardDO;
import cn.iocoder.yudao.module.education.dal.dataobject.badge.BadgeDefinitionDO;
import cn.iocoder.yudao.module.education.dal.mysql.LearningAwardMapper;
import cn.iocoder.yudao.module.education.dal.mysql.badge.BadgeDefinitionMapper;
import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
public class BadgeAdminServiceImpl implements BadgeAdminService {
private static final Set<String> CATEGORIES = Set.of("LEARNING", "PRACTICE", "VOCABULARY", "FEEDBACK", "CUSTOM");
private static final Set<String> TRIGGERS = Set.of("MANUAL", "PRACTICE_SUBMIT", "VOCABULARY_REVIEW", "FEEDBACK_RESOLVED");
private static final Set<String> METRICS = Set.of("PRACTICE_COUNT", "PRACTICE_SCORE", "VOCABULARY_MASTERED_COUNT",
"FEEDBACK_RESOLVED_COUNT", "FEEDBACK_REWARD_POINTS");
private static final Set<String> OPERATORS = Set.of("GTE", "LTE", "EQ", "GT", "LT");
private static final Set<String> STATUSES = Set.of("ACTIVE", "DISABLED");
private static final Set<String> SOURCES = Set.of("MANUAL", "AUTO", "MIGRATION");
private final BadgeDefinitionMapper definitionMapper;
private final LearningAwardMapper awardMapper;
private final BadgeGrantService grantService;
private final MemberUserApi memberUserApi;
private final AdminUserApi adminUserApi;
public BadgeAdminServiceImpl(BadgeDefinitionMapper definitionMapper, LearningAwardMapper awardMapper,
BadgeGrantService grantService, MemberUserApi memberUserApi,
AdminUserApi adminUserApi) {
this.definitionMapper = definitionMapper; this.awardMapper = awardMapper; this.grantService = grantService;
this.memberUserApi = memberUserApi; this.adminUserApi = adminUserApi;
}
@Override
public PageResult<DefinitionResp> getDefinitionPage(DefinitionPageReq req) {
PageResult<BadgeDefinitionDO> page = definitionMapper.selectAdminPage(req, tenantId(), trim(req.getKeyword()),
optional(req.getCategory(), CATEGORIES), optional(req.getTriggerType(), TRIGGERS),
optional(req.getStatus(), STATUSES));
return new PageResult<>(page.getList().stream().map(this::definitionResp).toList(), page.getTotal());
}
@Override
@Transactional(rollbackFor = Exception.class)
public DefinitionResp createDefinition(DefinitionSaveReq req, Long actorId) {
String code = req.getCode().trim().toUpperCase(Locale.ROOT);
if (definitionMapper.selectOwnedCode(tenantId(), code) != null) throw exception(BADGE_DEFINITION_CODE_CONFLICT);
BadgeDefinitionDO definition = build(req, actorId); definition.setCode(code); definition.setVersion(0);
definitionMapper.insert(definition);
return definitionResp(require(definition.getId()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public DefinitionResp updateDefinition(Long id, DefinitionSaveReq req, Long actorId) {
BadgeDefinitionDO current = require(id);
if (!current.getCode().equals(req.getCode().trim().toUpperCase(Locale.ROOT)))
throw exception(BADGE_DEFINITION_CODE_CONFLICT);
if (req.getExpectedVersion() == null || !req.getExpectedVersion().equals(current.getVersion()))
throw exception(BADGE_DEFINITION_CONFLICT);
BadgeDefinitionDO update = build(req, actorId); update.setId(id);
if (definitionMapper.updateCas(tenantId(), update, JsonUtils.toJsonString(update.getConditionExtra()),
req.getExpectedVersion()) != 1) throw exception(BADGE_DEFINITION_CONFLICT);
return definitionResp(require(id));
}
@Override
public PageResult<GrantResp> getGrantPage(GrantPageReq req) {
String source = optional(req.getAwardSource(), SOURCES);
PageResult<LearningAwardDO> page = awardMapper.selectBadgeGrantPage(req, tenantId(), req.getUserId(),
req.getBadgeDefinitionId(), source);
return grantPage(page);
}
@Override
public GrantResp grant(ManualGrantReq req, Long actorId) {
LearningAwardDO grant = grantService.grantManual(req.getBadgeDefinitionId(), req.getUserId(), req.getNote(),
req.getEvidence(), actorId, tenantId());
return grantPage(new PageResult<>(List.of(grant), 1L)).getList().getFirst();
}
private BadgeDefinitionDO build(DefinitionSaveReq req, Long actorId) {
BadgeDefinitionDO out = new BadgeDefinitionDO(); out.setTenantId(tenantId());
out.setName(req.getName().trim()); out.setDescription(trim(req.getDescription()));
out.setCategory(choice(req.getCategory(), CATEGORIES, "CUSTOM")); out.setIconUrl(trim(req.getIconUrl()));
out.setLevel(req.getLevel()); out.setTriggerType(choice(req.getTriggerType(), TRIGGERS, "MANUAL"));
out.setStatus(choice(req.getStatus(), STATUSES, "ACTIVE")); out.setSortOrder(req.getSortOrder() == null ? 0 : req.getSortOrder());
out.setConditionExtra(req.getConditionExtra() == null ? Map.of() : req.getConditionExtra());
if ("MANUAL".equals(out.getTriggerType())) {
out.setMetric(null); out.setOperator(null); out.setThresholdValue(null);
} else {
out.setMetric(choice(req.getMetric(), METRICS, null));
out.setOperator(choice(req.getOperator(), OPERATORS, "GTE")); out.setThresholdValue(req.getThresholdValue());
if (out.getMetric() == null || out.getThresholdValue() == null || !metricMatches(out.getTriggerType(), out.getMetric()))
throw exception(BADGE_RULE_INVALID);
}
String actor = String.valueOf(actorId); out.setCreator(actor); out.setUpdater(actor);
out.setCreateTime(LocalDateTime.now()); out.setUpdateTime(LocalDateTime.now()); return out;
}
private boolean metricMatches(String trigger, String metric) {
return switch (trigger) {
case "PRACTICE_SUBMIT" -> Set.of("PRACTICE_COUNT", "PRACTICE_SCORE").contains(metric);
case "VOCABULARY_REVIEW" -> "VOCABULARY_MASTERED_COUNT".equals(metric);
case "FEEDBACK_RESOLVED" -> Set.of("FEEDBACK_RESOLVED_COUNT", "FEEDBACK_REWARD_POINTS").contains(metric);
default -> false;
};
}
private PageResult<GrantResp> grantPage(PageResult<LearningAwardDO> page) {
Set<Long> userIds = page.getList().stream().map(LearningAwardDO::getUserId).collect(Collectors.toSet());
Set<Long> actorIds = page.getList().stream().map(LearningAwardDO::getGrantedBy).filter(Objects::nonNull).collect(Collectors.toSet());
Set<Long> badgeIds = page.getList().stream().map(LearningAwardDO::getBadgeDefinitionId).collect(Collectors.toSet());
Map<Long, MemberUserRespDTO> users = memberUserApi.getUserMap(userIds);
Map<Long, AdminUserRespDTO> admins = adminUserApi.getUserMap(actorIds);
Map<Long, BadgeDefinitionDO> badges = definitionMapper.selectBatchIds(badgeIds).stream()
.filter(item -> Objects.equals(item.getTenantId(), tenantId()))
.collect(Collectors.toMap(BadgeDefinitionDO::getId, Function.identity()));
List<GrantResp> items = page.getList().stream().map(item -> {
MemberUserRespDTO user = users.get(item.getUserId()); AdminUserRespDTO admin = admins.get(item.getGrantedBy());
BadgeDefinitionDO badge = badges.get(item.getBadgeDefinitionId());
return GrantResp.builder().id(item.getId()).userId(item.getUserId())
.userNickname(user == null ? null : user.getNickname()).badgeDefinitionId(item.getBadgeDefinitionId())
.badgeCode(item.getBadgeCode()).badgeName(badge == null ? null : badge.getName())
.badgeCategory(badge == null ? null : badge.getCategory()).awardSource(item.getAwardSource())
.grantNote(item.getGrantNote()).grantEvidence(item.getGrantEvidence()).grantedBy(item.getGrantedBy())
.grantedByNickname(admin == null ? null : admin.getNickname()).notifyStatus(item.getNotifyStatus())
.notifyError(item.getNotifyError()).createTime(item.getCreateTime()).build();
}).toList();
return new PageResult<>(items, page.getTotal());
}
private DefinitionResp definitionResp(BadgeDefinitionDO item) {
return DefinitionResp.builder().id(item.getId()).code(item.getCode()).name(item.getName())
.description(item.getDescription()).category(item.getCategory()).iconUrl(item.getIconUrl())
.level(item.getLevel()).triggerType(item.getTriggerType()).metric(item.getMetric())
.operator(item.getOperator()).thresholdValue(item.getThresholdValue())
.conditionExtra(item.getConditionExtra()).sortOrder(item.getSortOrder()).status(item.getStatus())
.version(item.getVersion()).createTime(item.getCreateTime()).updateTime(item.getUpdateTime()).build();
}
private BadgeDefinitionDO require(Long id) {
BadgeDefinitionDO item = definitionMapper.selectOwned(tenantId(), id);
if (item == null) throw exception(BADGE_DEFINITION_NOT_FOUND); return item;
}
private Long tenantId() { return TenantContextHolder.getRequiredTenantId(); }
private String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private String optional(String value, Set<String> allowed) { return trim(value) == null ? null : choice(value, allowed, null); }
private String choice(String value, Set<String> allowed, String fallback) {
String normalized = trim(value) == null ? fallback : value.trim().toUpperCase(Locale.ROOT);
if (normalized == null || !allowed.contains(normalized)) throw exception(BADGE_RULE_INVALID); return normalized;
}
}

View File

@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.education.service.badge;
import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.MemberBadgeResp;
import cn.iocoder.yudao.module.education.dal.dataobject.LearningAwardDO;
import java.util.List;
import java.util.Map;
public interface BadgeGrantService {
LearningAwardDO grantManual(Long badgeDefinitionId, Long userId, String note,
Map<String, Object> evidence, Long actorId, Long tenantId);
void evaluatePractice(Long tenantId, Long userId, Long reportId);
void evaluateVocabulary(Long tenantId, Long userId, String vocabularyKey);
void evaluateFeedback(Long tenantId, Long userId, Long feedbackId, Integer rewardPoints);
List<MemberBadgeResp> getMemberBadges(Long tenantId, Long userId, boolean includeLocked);
}

View File

@@ -0,0 +1,187 @@
package cn.iocoder.yudao.module.education.service.badge;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.education.controller.admin.badge.vo.BadgeAdminVOs.MemberBadgeResp;
import cn.iocoder.yudao.module.education.dal.dataobject.LearningAwardDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDO;
import cn.iocoder.yudao.module.education.dal.dataobject.badge.BadgeDefinitionDO;
import cn.iocoder.yudao.module.education.dal.mysql.*;
import cn.iocoder.yudao.module.education.dal.mysql.badge.BadgeDefinitionMapper;
import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
@Slf4j
public class BadgeGrantServiceImpl implements BadgeGrantService {
private final BadgeDefinitionMapper definitionMapper;
private final LearningAwardMapper awardMapper;
private final PracticeReportMapper practiceReportMapper;
private final VocabularyProgressMapper vocabularyMapper;
private final StudentFeedbackMapper feedbackMapper;
private final MemberUserApi memberUserApi;
private final NotifyMessageSendApi notifyApi;
public BadgeGrantServiceImpl(BadgeDefinitionMapper definitionMapper, LearningAwardMapper awardMapper,
PracticeReportMapper practiceReportMapper, VocabularyProgressMapper vocabularyMapper,
StudentFeedbackMapper feedbackMapper, MemberUserApi memberUserApi,
ObjectProvider<NotifyMessageSendApi> notifyApiProvider) {
this.definitionMapper = definitionMapper;
this.awardMapper = awardMapper;
this.practiceReportMapper = practiceReportMapper;
this.vocabularyMapper = vocabularyMapper;
this.feedbackMapper = feedbackMapper;
this.memberUserApi = memberUserApi;
this.notifyApi = notifyApiProvider.getIfAvailable();
}
@Override
@Transactional(rollbackFor = Exception.class)
public LearningAwardDO grantManual(Long badgeDefinitionId, Long userId, String note,
Map<String, Object> evidence, Long actorId, Long tenantId) {
BadgeDefinitionDO definition = requireActive(tenantId, badgeDefinitionId);
try {
memberUserApi.validateUser(userId);
} catch (RuntimeException ex) {
throw exception(BADGE_TARGET_MEMBER_INVALID);
}
Map<String, Object> merged = new LinkedHashMap<>();
merged.put("source", "MANUAL");
if (evidence != null) merged.putAll(evidence);
return grant(definition, userId, "MANUAL", trim(note), merged, actorId, tenantId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void evaluatePractice(Long tenantId, Long userId, Long reportId) {
PracticeReportDO report = practiceReportMapper.selectById(reportId);
if (report == null || !Objects.equals(report.getTenantId(), tenantId)
|| !Objects.equals(report.getUserId(), userId)) return;
PracticeReportMapper.BadgePracticeMetrics aggregate = practiceReportMapper.selectBadgeMetrics(tenantId, userId);
Map<String, BigDecimal> metrics = new LinkedHashMap<>();
metrics.put("PRACTICE_COUNT", decimal(aggregate == null ? null : aggregate.getPracticeCount()));
metrics.put("PRACTICE_SCORE", decimal(report.getScore()));
evaluate("PRACTICE_SUBMIT", tenantId, userId, "practice-report:" + reportId, metrics);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void evaluateVocabulary(Long tenantId, Long userId, String vocabularyKey) {
Map<String, BigDecimal> metrics = Map.of("VOCABULARY_MASTERED_COUNT",
decimal(vocabularyMapper.countMastered(tenantId, userId)));
evaluate("VOCABULARY_REVIEW", tenantId, userId, "vocabulary:" + vocabularyKey, metrics);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void evaluateFeedback(Long tenantId, Long userId, Long feedbackId, Integer rewardPoints) {
Map<String, BigDecimal> metrics = new LinkedHashMap<>();
metrics.put("FEEDBACK_RESOLVED_COUNT", decimal(feedbackMapper.countResolved(tenantId, userId)));
metrics.put("FEEDBACK_REWARD_POINTS", decimal(rewardPoints));
evaluate("FEEDBACK_RESOLVED", tenantId, userId, "feedback:" + feedbackId, metrics);
}
private void evaluate(String trigger, Long tenantId, Long userId, String eventKey,
Map<String, BigDecimal> metrics) {
for (BadgeDefinitionDO definition : definitionMapper.selectActiveByTrigger(tenantId, trigger)) {
BigDecimal current = metrics.get(definition.getMetric());
if (current == null || !matches(current, definition.getThresholdValue(), definition.getOperator())) continue;
Map<String, Object> evidence = new LinkedHashMap<>();
evidence.put("source", "AUTO"); evidence.put("trigger", trigger); evidence.put("eventKey", eventKey);
evidence.put("metric", definition.getMetric()); evidence.put("matchedValue", current);
evidence.put("thresholdValue", definition.getThresholdValue()); evidence.put("metrics", metrics);
grant(definition, userId, "AUTO", "自动发放:" + definition.getName(), evidence, null, tenantId);
}
}
private LearningAwardDO grant(BadgeDefinitionDO definition, Long userId, String source, String note,
Map<String, Object> evidence, Long actorId, Long tenantId) {
LearningAwardDO record = new LearningAwardDO();
record.setTenantId(tenantId); record.setUserId(userId);
record.setEventKey("badge:" + definition.getId()); record.setAwardType("BADGE");
record.setBadgeCode(definition.getCode()); record.setPointAmount(0); record.setPointStatus("NOT_REQUIRED");
record.setBadgeDefinitionId(definition.getId()); record.setAwardSource(source);
record.setGrantNote(note); record.setGrantEvidence(evidence); record.setGrantedBy(actorId);
record.setNotifyStatus("PENDING");
String auditActor = String.valueOf(actorId == null ? userId : actorId);
record.setCreator(auditActor); record.setUpdater(auditActor);
int inserted = awardMapper.insertBadgeGrantIgnore(record, JsonUtils.toJsonString(evidence));
LearningAwardDO saved = awardMapper.selectBadgeGrant(tenantId, userId, definition.getId());
if (saved == null) throw exception(BADGE_GRANT_CONFLICT);
if (inserted == 1) sendNotification(saved, definition);
return awardMapper.selectBadgeGrant(tenantId, userId, definition.getId());
}
private void sendNotification(LearningAwardDO grant, BadgeDefinitionDO definition) {
if (notifyApi == null) {
awardMapper.updateBadgeNotify(grant.getTenantId(), grant.getId(), null, "FAILED",
"System Notify API unavailable");
return;
}
try {
NotifySendSingleToUserReqDTO request = new NotifySendSingleToUserReqDTO();
request.setUserId(grant.getUserId()); request.setTemplateCode("education_badge_granted");
request.setTemplateParams(Map.of("badgeName", definition.getName()));
Long messageId = notifyApi.sendSingleMessageToMember(request);
awardMapper.updateBadgeNotify(grant.getTenantId(), grant.getId(), messageId, "SENT", null);
} catch (RuntimeException ex) {
String detail = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
awardMapper.updateBadgeNotify(grant.getTenantId(), grant.getId(), null, "FAILED", abbreviate(detail));
log.warn("[sendNotification][badge grant {} notification failed]", grant.getId(), ex);
}
}
@Override
public List<MemberBadgeResp> getMemberBadges(Long tenantId, Long userId, boolean includeLocked) {
Map<Long, LearningAwardDO> grants = awardMapper.selectUserBadgeGrants(tenantId, userId).stream()
.collect(Collectors.toMap(LearningAwardDO::getBadgeDefinitionId, Function.identity(), (a, b) -> a));
return definitionMapper.selectMemberList(tenantId, false).stream()
.filter(definition -> includeLocked || grants.containsKey(definition.getId()))
.map(definition -> {
LearningAwardDO grant = grants.get(definition.getId());
return MemberBadgeResp.builder().badgeDefinitionId(definition.getId()).code(definition.getCode())
.name(definition.getName()).description(definition.getDescription())
.category(definition.getCategory()).iconUrl(definition.getIconUrl()).level(definition.getLevel())
.earned(grant != null).awardSource(grant == null ? null : grant.getAwardSource())
.evidence(grant == null ? null : grant.getGrantEvidence())
.grantedTime(grant == null ? null : grant.getCreateTime()).build();
}).toList();
}
private BadgeDefinitionDO requireActive(Long tenantId, Long id) {
BadgeDefinitionDO definition = definitionMapper.selectOwned(tenantId, id);
if (definition == null) throw exception(BADGE_DEFINITION_NOT_FOUND);
if (!"ACTIVE".equals(definition.getStatus())) throw exception(BADGE_DEFINITION_DISABLED);
return definition;
}
private boolean matches(BigDecimal current, BigDecimal threshold, String operator) {
if (current == null || threshold == null) return false;
int comparison = current.compareTo(threshold);
return switch (operator) {
case "GTE" -> comparison >= 0;
case "GT" -> comparison > 0;
case "LTE" -> comparison <= 0;
case "LT" -> comparison < 0;
case "EQ" -> comparison == 0;
default -> false;
};
}
private BigDecimal decimal(Number value) { return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString()); }
private String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private String abbreviate(String value) { return value.substring(0, Math.min(512, value.length())); }
}

View File

@@ -1,6 +1,12 @@
package cn.iocoder.yudao.module.education.service.blueprint.authoring;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintDO;
public interface PracticeBlueprintAuthoringService {
default PageResult<PracticeBlueprintDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default PracticeBlueprintDO get(Long id) { throw new UnsupportedOperationException(); }
Long createDraft(PracticeBlueprintAuthoringCommand command);
int reviseDraft(Long id, PracticeBlueprintAuthoringCommand command);
int activate(Long id, int expectedVersion, Long actorId);

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.service.blueprint.authoring;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties;
@@ -32,6 +34,16 @@ public class PracticeBlueprintAuthoringServiceImpl implements PracticeBlueprintA
this.nodeMapper = nodeMapper; this.collectionMapper = collectionMapper; this.questionMapper = questionMapper;
}
@Override
public PageResult<PracticeBlueprintDO> getPage(PageParam pageParam) {
assertMode(); return blueprintMapper.selectTenantOwnedPage(pageParam, TenantContextHolder.getRequiredTenantId());
}
@Override
public PracticeBlueprintDO get(Long id) {
assertMode(); return requireOwned(TenantContextHolder.getRequiredTenantId(), id);
}
@Override @Transactional(rollbackFor = Exception.class)
public Long createDraft(PracticeBlueprintAuthoringCommand command) {
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); Target target = validate(tenantId, command);

View File

@@ -1,6 +1,12 @@
package cn.iocoder.yudao.module.education.service.category.authoring;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
public interface CategoryAuthoringService {
default PageResult<CategoryDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default CategoryDO get(Long categoryId) { throw new UnsupportedOperationException(); }
Long createDraft(CategoryAuthoringCommand command);
int reviseDraft(Long categoryId, CategoryAuthoringCommand command);
int activate(Long categoryId, int expectedAuthoringVersion, Long actorId);

View File

@@ -2,6 +2,8 @@ package cn.iocoder.yudao.module.education.service.category.authoring;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryLifecycleAuditDO;
@@ -34,6 +36,18 @@ public class CategoryAuthoringServiceImpl implements CategoryAuthoringService {
this.subjectMapper = subjectMapper;
}
@Override
public PageResult<CategoryDO> getPage(PageParam pageParam) {
assertMode();
return categoryMapper.selectTenantOwnedPage(pageParam, TenantContextHolder.getRequiredTenantId());
}
@Override
public CategoryDO get(Long categoryId) {
assertMode();
return requireOwned(TenantContextHolder.getRequiredTenantId(), categoryId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long createDraft(CategoryAuthoringCommand command) {

View File

@@ -18,6 +18,8 @@ import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserDeptId;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
@@ -46,6 +48,8 @@ public class EducationClassServiceImpl implements EducationClassService {
classroom.setName(name);
classroom.setDescription(description);
classroom.setStatus("ACTIVE");
classroom.setDeptId(getLoginUserDeptId());
classroom.setOwnerUserId(getLoginUserId());
classMapper.insert(classroom);
return classroom.getId();
}

View File

@@ -1,8 +1,14 @@
package cn.iocoder.yudao.module.education.service.collection.authoring;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO;
import java.util.List;
public interface QuestionCollectionAuthoringService {
default PageResult<QuestionCollectionDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default QuestionCollectionDO get(Long collectionId) { throw new UnsupportedOperationException(); }
Long createDraft(QuestionCollectionAuthoringCommand command);
int reviseDraft(Long collectionId, QuestionCollectionAuthoringCommand command);
int replaceMembership(Long collectionId, List<Long> questionIds, int expectedAuthoringVersion);

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.service.collection.authoring;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties;
@@ -30,6 +32,16 @@ public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectio
this.auditMapper = auditMapper; this.nodeMapper = nodeMapper; this.questionMapper = questionMapper;
}
@Override
public PageResult<QuestionCollectionDO> getPage(PageParam pageParam) {
assertMode(); return collectionMapper.selectTenantOwnedPage(pageParam, TenantContextHolder.getRequiredTenantId());
}
@Override
public QuestionCollectionDO get(Long collectionId) {
assertMode(); return requireOwned(TenantContextHolder.getRequiredTenantId(), collectionId);
}
@Override @Transactional(rollbackFor = Exception.class)
public Long createDraft(QuestionCollectionAuthoringCommand command) {
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId();

View File

@@ -1,6 +1,12 @@
package cn.iocoder.yudao.module.education.service.contentnode.authoring;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
public interface ContentNodeAuthoringService {
default PageResult<ContentNodeDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default ContentNodeDO get(Long nodeId) { throw new UnsupportedOperationException(); }
Long createDraft(ContentNodeAuthoringCommand command);
int reviseDraft(Long nodeId, ContentNodeAuthoringCommand command);
int activate(Long nodeId, int expectedAuthoringVersion, Long actorId);

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.service.contentnode.authoring;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties;
@@ -30,6 +32,18 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ
this.auditMapper = auditMapper;
}
@Override
public PageResult<ContentNodeDO> getPage(PageParam pageParam) {
assertMode();
return nodeMapper.selectTenantOwnedPage(pageParam, TenantContextHolder.getRequiredTenantId());
}
@Override
public ContentNodeDO get(Long nodeId) {
assertMode();
return requireOwned(TenantContextHolder.getRequiredTenantId(), nodeId);
}
@Override @Transactional(rollbackFor = Exception.class)
public Long createDraft(ContentNodeAuthoringCommand command) {
assertMode();

View File

@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.education.service.engagement;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo.*;
import java.util.List;
public interface LearningOperationsAdminService {
LearningOperationsOverviewRespVO getOverview();
PageResult<StudentFeedbackAdminRespVO> getFeedbackPage(StudentFeedbackPageReqVO reqVO);
PageResult<LearningAwardAdminRespVO> getAwardPage(LearningAwardPageReqVO reqVO);
List<StudentFeedbackEventRespVO> getFeedbackEvents(Long feedbackId);
StudentFeedbackAdminRespVO handleFeedback(Long feedbackId, StudentFeedbackHandleReqVO reqVO, Long actorId);
StudentFeedbackAdminRespVO scheduleReward(Long feedbackId, StudentFeedbackRewardReqVO reqVO);
StudentFeedbackAdminRespVO deliverReward(Long feedbackId);
}

View File

@@ -0,0 +1,305 @@
package cn.iocoder.yudao.module.education.service.engagement;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.admin.learningoperations.vo.*;
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.dataobject.StudentFeedbackEventDO;
import cn.iocoder.yudao.module.education.dal.mysql.LearningAwardMapper;
import cn.iocoder.yudao.module.education.dal.mysql.StudentFeedbackEventMapper;
import cn.iocoder.yudao.module.education.dal.mysql.StudentFeedbackMapper;
import cn.iocoder.yudao.module.education.integration.member.MemberPointAwardPort;
import cn.iocoder.yudao.module.education.service.badge.BadgeGrantService;
import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
public class LearningOperationsAdminServiceImpl implements LearningOperationsAdminService {
private static final Set<String> FEEDBACK_CATEGORIES = Set.of("CONTENT", "PRODUCT", "BUG", "OTHER");
private static final Set<String> FEEDBACK_STATUSES = Set.of("OPEN", "ACCEPTED", "REJECTED", "RESOLVED", "CLOSED");
private static final Set<String> FEEDBACK_PRIORITIES = Set.of("LOW", "NORMAL", "HIGH", "URGENT");
private static final Set<String> AWARD_TYPES = Set.of("PRACTICE_COMPLETE", "VOCABULARY_REVIEW", "BADGE");
private static final Set<String> POINT_STATUSES = Set.of("NOT_REQUIRED", "PENDING", "PROCESSING", "AWARDED", "FAILED");
private final StudentFeedbackMapper feedbackMapper;
private final StudentFeedbackEventMapper feedbackEventMapper;
private final LearningAwardMapper awardMapper;
private final MemberUserApi memberUserApi;
private final AdminUserApi adminUserApi;
private final MemberPointAwardPort memberPointAwardPort;
private final BadgeGrantService badgeGrantService;
public LearningOperationsAdminServiceImpl(StudentFeedbackMapper feedbackMapper,
StudentFeedbackEventMapper feedbackEventMapper,
LearningAwardMapper awardMapper,
MemberUserApi memberUserApi,
AdminUserApi adminUserApi,
MemberPointAwardPort memberPointAwardPort) {
this(feedbackMapper, feedbackEventMapper, awardMapper, memberUserApi, adminUserApi,
memberPointAwardPort, null);
}
@Autowired
public LearningOperationsAdminServiceImpl(StudentFeedbackMapper feedbackMapper,
StudentFeedbackEventMapper feedbackEventMapper,
LearningAwardMapper awardMapper,
MemberUserApi memberUserApi,
AdminUserApi adminUserApi,
MemberPointAwardPort memberPointAwardPort,
ObjectProvider<BadgeGrantService> badgeGrantServiceProvider) {
this.feedbackMapper = feedbackMapper;
this.feedbackEventMapper = feedbackEventMapper;
this.awardMapper = awardMapper;
this.memberUserApi = memberUserApi;
this.adminUserApi = adminUserApi;
this.memberPointAwardPort = memberPointAwardPort;
this.badgeGrantService = badgeGrantServiceProvider == null ? null : badgeGrantServiceProvider.getIfAvailable();
}
@Override
public LearningOperationsOverviewRespVO getOverview() {
Long tenantId = tenantId();
StudentFeedbackMapper.FeedbackOverviewRow feedback = feedbackMapper.selectOverview(tenantId);
LearningAwardMapper.AwardOverviewRow award = awardMapper.selectOverview(tenantId);
return LearningOperationsOverviewRespVO.builder()
.feedbackTotal(value(feedback == null ? null : feedback.getTotal()))
.feedbackOpen(value(feedback == null ? null : feedback.getOpenCount()))
.feedbackResolved(value(feedback == null ? null : feedback.getResolvedCount()))
.feedbackHighPriority(value(feedback == null ? null : feedback.getHighPriorityCount()))
.awardTotal(value(award == null ? null : award.getTotal()))
.activeLearners(value(award == null ? null : award.getActiveLearners()))
.awardedPoints(value(award == null ? null : award.getAwardedPoints()))
.failedPointAwards(value(award == null ? null : award.getFailedPoints()))
.badgeAwards(value(award == null ? null : award.getBadgeAwards()))
.build();
}
@Override
public PageResult<StudentFeedbackAdminRespVO> getFeedbackPage(StudentFeedbackPageReqVO reqVO) {
String category = optional(reqVO.getCategory(), FEEDBACK_CATEGORIES, STUDENT_FEEDBACK_CATEGORY_INVALID);
String status = optional(reqVO.getStatus(), FEEDBACK_STATUSES, STUDENT_FEEDBACK_STATUS_INVALID);
String priority = optional(reqVO.getPriority(), FEEDBACK_PRIORITIES, STUDENT_FEEDBACK_PRIORITY_INVALID);
PageResult<StudentFeedbackDO> page = feedbackMapper.selectAdminPage(reqVO, tenantId(), reqVO.getUserId(),
category, status, priority);
return feedbackPage(page);
}
@Override
public PageResult<LearningAwardAdminRespVO> getAwardPage(LearningAwardPageReqVO reqVO) {
String awardType = optional(reqVO.getAwardType(), AWARD_TYPES, LEARNING_AWARD_TYPE_INVALID);
String pointStatus = optional(reqVO.getPointStatus(), POINT_STATUSES, LEARNING_AWARD_TYPE_INVALID);
PageResult<LearningAwardDO> page = awardMapper.selectAdminPage(reqVO, tenantId(), reqVO.getUserId(),
awardType, pointStatus, reqVO.getHasBadge());
Set<Long> userIds = page.getList().stream().map(LearningAwardDO::getUserId).collect(Collectors.toSet());
Map<Long, MemberUserRespDTO> users = memberUserApi.getUserMap(userIds);
List<LearningAwardAdminRespVO> items = page.getList().stream().map(item -> awardResp(item, users)).toList();
return new PageResult<>(items, page.getTotal());
}
@Override
public List<StudentFeedbackEventRespVO> getFeedbackEvents(Long feedbackId) {
requireFeedback(feedbackId);
List<StudentFeedbackEventDO> events = feedbackEventMapper.selectOwnedList(tenantId(), feedbackId);
Map<Long, AdminUserRespDTO> actors = adminUserApi.getUserMap(events.stream()
.map(StudentFeedbackEventDO::getActorId).collect(Collectors.toSet()));
return events.stream().map(item -> eventResp(item, actors)).toList();
}
@Override
@Transactional(rollbackFor = Exception.class)
public StudentFeedbackAdminRespVO handleFeedback(Long feedbackId, StudentFeedbackHandleReqVO reqVO,
Long actorId) {
Long tenantId = tenantId();
StudentFeedbackDO current = requireFeedback(feedbackId);
String status = required(reqVO.getStatus(), FEEDBACK_STATUSES, STUDENT_FEEDBACK_STATUS_INVALID);
String priority = required(reqVO.getPriority(), FEEDBACK_PRIORITIES, STUDENT_FEEDBACK_PRIORITY_INVALID);
if (!current.getVersion().equals(reqVO.getExpectedVersion())) {
throw exception(STUDENT_FEEDBACK_CONFLICT);
}
int updated = feedbackMapper.handleCas(tenantId, feedbackId, reqVO.getExpectedVersion(), status, priority,
reqVO.getResolution(), actorId);
if (updated != 1) {
throw exception(STUDENT_FEEDBACK_CONFLICT);
}
StudentFeedbackEventDO event = new StudentFeedbackEventDO();
event.setTenantId(tenantId);
event.setFeedbackId(feedbackId);
event.setFromStatus(current.getStatus());
event.setToStatus(status);
event.setNote(reqVO.getNote());
event.setActorId(actorId);
feedbackEventMapper.insert(event);
if ("RESOLVED".equals(status) && badgeGrantService != null) {
StudentFeedbackDO resolved = requireFeedback(feedbackId);
badgeGrantService.evaluateFeedback(tenantId, resolved.getUserId(), resolved.getId(), resolved.getRewardPoints());
}
return feedbackResp(requireFeedback(feedbackId), memberUserApi.getUserMap(Set.of(current.getUserId())),
adminUserApi.getUserMap(Set.of(actorId)));
}
@Override
@Transactional(rollbackFor = Exception.class)
public StudentFeedbackAdminRespVO scheduleReward(Long feedbackId, StudentFeedbackRewardReqVO reqVO) {
Long tenantId = tenantId();
StudentFeedbackDO current = requireFeedback(feedbackId);
if (!"RESOLVED".equals(current.getStatus()) || "AWARDED".equals(current.getRewardStatus())) {
throw exception(STUDENT_FEEDBACK_CONFLICT);
}
int updated = feedbackMapper.scheduleRewardCas(tenantId, feedbackId, reqVO.getExpectedVersion(), reqVO.getPoints());
if (updated != 1) {
throw exception(STUDENT_FEEDBACK_CONFLICT);
}
return singleFeedback(feedbackId);
}
@Override
public StudentFeedbackAdminRespVO deliverReward(Long feedbackId) {
Long tenantId = tenantId();
StudentFeedbackDO current = requireFeedback(feedbackId);
if ("AWARDED".equals(current.getRewardStatus())) {
return singleFeedback(feedbackId);
}
if (feedbackMapper.claimReward(tenantId, feedbackId) != 1) {
throw exception(STUDENT_FEEDBACK_CONFLICT);
}
current = requireFeedback(feedbackId);
try {
memberPointAwardPort.addFeedbackReward(current.getUserId(), current.getRewardPoints(), current.getId());
feedbackMapper.finishReward(tenantId, feedbackId, "AWARDED", null);
if (badgeGrantService != null) {
badgeGrantService.evaluateFeedback(tenantId, current.getUserId(), current.getId(), current.getRewardPoints());
}
} catch (RuntimeException ex) {
String detail = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
feedbackMapper.finishReward(tenantId, feedbackId, "FAILED", detail.substring(0, Math.min(512, detail.length())));
throw exception(STUDENT_FEEDBACK_REWARD_FAILED);
}
return singleFeedback(feedbackId);
}
private PageResult<StudentFeedbackAdminRespVO> feedbackPage(PageResult<StudentFeedbackDO> page) {
Set<Long> userIds = page.getList().stream().map(StudentFeedbackDO::getUserId).collect(Collectors.toSet());
Set<Long> actorIds = page.getList().stream().map(StudentFeedbackDO::getHandledBy)
.filter(java.util.Objects::nonNull).collect(Collectors.toSet());
Map<Long, MemberUserRespDTO> users = memberUserApi.getUserMap(userIds);
Map<Long, AdminUserRespDTO> actors = adminUserApi.getUserMap(actorIds);
return new PageResult<>(page.getList().stream().map(item -> feedbackResp(item, users, actors)).toList(),
page.getTotal());
}
private StudentFeedbackAdminRespVO singleFeedback(Long feedbackId) {
StudentFeedbackDO feedback = requireFeedback(feedbackId);
Map<Long, MemberUserRespDTO> users = memberUserApi.getUserMap(Set.of(feedback.getUserId()));
Map<Long, AdminUserRespDTO> actors = feedback.getHandledBy() == null ? Map.of()
: adminUserApi.getUserMap(Set.of(feedback.getHandledBy()));
return feedbackResp(feedback, users, actors);
}
private StudentFeedbackDO requireFeedback(Long feedbackId) {
StudentFeedbackDO feedback = feedbackMapper.selectOwnedById(tenantId(), feedbackId);
if (feedback == null) {
throw exception(STUDENT_FEEDBACK_NOT_FOUND);
}
return feedback;
}
private static StudentFeedbackAdminRespVO feedbackResp(StudentFeedbackDO source,
Map<Long, MemberUserRespDTO> users,
Map<Long, AdminUserRespDTO> actors) {
StudentFeedbackAdminRespVO target = new StudentFeedbackAdminRespVO();
target.setId(source.getId());
target.setUserId(source.getUserId());
MemberUserRespDTO user = users.get(source.getUserId());
target.setUserNickname(user == null ? null : user.getNickname());
target.setCategory(source.getCategory());
target.setContent(source.getContent());
target.setContact(source.getContact());
target.setStatus(source.getStatus());
target.setPriority(source.getPriority());
target.setResolution(source.getResolution());
target.setHandledBy(source.getHandledBy());
AdminUserRespDTO actor = source.getHandledBy() == null ? null : actors.get(source.getHandledBy());
target.setHandledByNickname(actor == null ? null : actor.getNickname());
target.setHandledTime(source.getHandledTime());
target.setRewardPoints(source.getRewardPoints());
target.setRewardStatus(source.getRewardStatus());
target.setRewardError(source.getRewardError());
target.setVersion(source.getVersion());
target.setCreateTime(source.getCreateTime());
target.setUpdateTime(source.getUpdateTime());
return target;
}
private static LearningAwardAdminRespVO awardResp(LearningAwardDO source,
Map<Long, MemberUserRespDTO> users) {
LearningAwardAdminRespVO target = new LearningAwardAdminRespVO();
target.setId(source.getId());
target.setUserId(source.getUserId());
MemberUserRespDTO user = users.get(source.getUserId());
target.setUserNickname(user == null ? null : user.getNickname());
target.setEventKey(source.getEventKey());
target.setAwardType(source.getAwardType());
target.setBadgeCode(source.getBadgeCode());
target.setPointAmount(source.getPointAmount());
target.setPointStatus(source.getPointStatus());
target.setPointError(source.getPointError());
target.setCreateTime(source.getCreateTime());
return target;
}
private static StudentFeedbackEventRespVO eventResp(StudentFeedbackEventDO source,
Map<Long, AdminUserRespDTO> actors) {
StudentFeedbackEventRespVO target = new StudentFeedbackEventRespVO();
target.setId(source.getId());
target.setFromStatus(source.getFromStatus());
target.setToStatus(source.getToStatus());
target.setNote(source.getNote());
target.setActorId(source.getActorId());
AdminUserRespDTO actor = actors.get(source.getActorId());
target.setActorNickname(actor == null ? null : actor.getNickname());
target.setCreateTime(source.getCreateTime());
return target;
}
private static String optional(String value, Set<String> allowed,
cn.iocoder.yudao.framework.common.exception.ErrorCode errorCode) {
if (value == null || value.isBlank()) {
return null;
}
return required(value, allowed, errorCode);
}
private static String required(String value, Set<String> allowed,
cn.iocoder.yudao.framework.common.exception.ErrorCode errorCode) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) {
throw exception(errorCode, value);
}
return normalized;
}
private static long value(Long value) {
return value == null ? 0L : value;
}
private static Long tenantId() {
return TenantContextHolder.getRequiredTenantId();
}
}

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.education.service.importjob;
public interface ImportObjectScanGateway {
enum ScanResult { CLEAN, INFECTED, ERROR }
ScanResult scan(String objectKey);
enum ScanResult { CLEAN, INFECTED, ERROR, UNAVAILABLE }
ScanResult scan(String reference, String name, String mimeType, long size, String checksumSha256);
}

View File

@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.education.service.importjob;
import cn.iocoder.yudao.module.infra.api.file.FileApi;
import cn.iocoder.yudao.module.infra.api.file.FileDescriptor;
import cn.iocoder.yudao.module.infra.api.file.FileScanStatus;
import org.springframework.stereotype.Component;
@Component
public class InfraFileImportObjectScanGateway implements ImportObjectScanGateway {
private final FileApi fileApi;
public InfraFileImportObjectScanGateway(FileApi fileApi) { this.fileApi = fileApi; }
@Override
public ScanResult scan(String reference, String name, String mimeType, long size, String checksumSha256) {
FileScanStatus status = fileApi.scan(new FileDescriptor(reference, name, mimeType, size, checksumSha256));
return status == null ? ScanResult.UNAVAILABLE : ScanResult.valueOf(status.name());
}
}

View File

@@ -2,5 +2,6 @@ package cn.iocoder.yudao.module.education.service.importjob;
public record QuestionImportJobProjection(Long id, String status, String scanStatus, String parserStatus,
String fileName, Long fileSize, Integer previewQuestionCount,
Integer importedQuestionCount, String failureCode) {
Integer importedQuestionCount, String failureCode,
String previewPayload, String resultSummary) {
}

View File

@@ -6,6 +6,8 @@ import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportAssetDO;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportJobDO;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportAssetMapper;
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportJobMapper;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
@@ -13,11 +15,14 @@ import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraf
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -28,7 +33,8 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private static final int MAX_ATTEMPTS = 5;
private static final long LEASE_SECONDS = 60;
private static final long PREVIEW_LEASE_SECONDS = 60;
private static final long EXECUTE_LEASE_SECONDS = 600;
private final EducationProperties properties;
private final ContentImportAssetMapper assetMapper;
@@ -36,18 +42,26 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private final Optional<ImportObjectScanGateway> scanGateway;
private final Optional<QuestionImportParser> parser;
private final TenantQuestionLifecycleService lifecycleService;
private final QuestionMapper questionMapper;
private final QuestionVersionMapper versionMapper;
private final TransactionTemplate transactionTemplate;
public QuestionImportJobServiceImpl(EducationProperties properties, ContentImportAssetMapper assetMapper,
ContentImportJobMapper jobMapper,
Optional<ImportObjectScanGateway> scanGateway,
Optional<QuestionImportParser> parser,
TenantQuestionLifecycleService lifecycleService) {
TenantQuestionLifecycleService lifecycleService,
QuestionMapper questionMapper, QuestionVersionMapper versionMapper,
TransactionTemplate transactionTemplate) {
this.properties = properties;
this.assetMapper = assetMapper;
this.jobMapper = jobMapper;
this.scanGateway = scanGateway;
this.parser = parser;
this.lifecycleService = lifecycleService;
this.questionMapper = questionMapper;
this.versionMapper = versionMapper;
this.transactionTemplate = transactionTemplate;
}
@Override
@@ -117,7 +131,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private void processClaimed(Long jobId, String expectedStatus) {
jobMapper.failExhausted();
String token = UUID.randomUUID().toString();
ContentImportJobDO job = jobMapper.claimById(jobId, expectedStatus, "education-import", token, LEASE_SECONDS);
long leaseSeconds = "EXECUTE_PENDING".equals(expectedStatus) ? EXECUTE_LEASE_SECONDS : PREVIEW_LEASE_SECONDS;
ContentImportJobDO job = jobMapper.claimById(jobId, expectedStatus, "education-import", token, leaseSeconds);
if (job != null) TenantUtils.execute(job.getTenantId(), () -> process(job, token));
}
@@ -133,7 +148,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
finishFailed(job, token, "SCAN_UNAVAILABLE");
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
}
ImportObjectScanGateway.ScanResult scanResult = gateway.scan(asset.getObjectKey());
ImportObjectScanGateway.ScanResult scanResult = gateway.scan(asset.getObjectKey(), asset.getFileName(),
asset.getMimeType(), asset.getFileSizeBytes(), asset.getChecksumSha256());
if (scanResult != ImportObjectScanGateway.ScanResult.CLEAN) {
finishFailed(job, token, "SCAN_" + scanResult.name());
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
@@ -147,8 +163,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
QuestionImportParser.ParsedImport parsed = availableParser.parse(asset.getObjectKey());
parserStatus = "PARSED";
parsedPayload = parsed.payload();
questionCount = parsed.questions().size();
previewPayload = "{\"mode\":\"PARSED\",\"executable\":true}";
questionCount = parsed.questionCount();
previewPayload = parsed.previewPayload();
}
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "PREVIEW_PENDING", "PREVIEW_READY",
token, "CLEAN", parserStatus, previewPayload, parsedPayload, questionCount, null, null, null, null);
@@ -158,12 +174,24 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private void processExecute(ContentImportJobDO job, String token) {
if (!"CLEAN".equals(job.getScanStatus())) throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
QuestionImportParser availableParser = parser.orElseThrow(() -> exception(QUESTION_IMPORT_PARSER_UNAVAILABLE));
List<QuestionDraftCommand> questions = availableParser.restore(job.getParsedPayload());
for (QuestionDraftCommand question : questions) lifecycleService.createDraft(question);
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "EXECUTE_PENDING", "COMPLETED",
token, null, null, null, job.getParsedPayload(), job.getPreviewQuestionCount(),
"{\"result\":\"COMPLETED\"}", questions.size(), null, null);
if (updated != 1) throw exception(CONTENT_IMPORT_LEASE_LOST);
transactionTemplate.executeWithoutResult(status -> {
List<QuestionDraftCommand> questions = availableParser.restore(job.getParsedPayload());
if (job.getPreviewQuestionCount() == null || questions.size() != job.getPreviewQuestionCount()) {
throw exception(QUESTION_IMPORT_STATE_CONFLICT);
}
List<Long> ids = new ArrayList<>(questions.size());
for (QuestionDraftCommand question : questions) ids.add(lifecycleService.createDraft(question));
if (ids.stream().anyMatch(Objects::isNull)
|| questionMapper.countImportedDrafts(job.getTenantId(), ids) != ids.size()
|| versionMapper.countFirstVersions(job.getTenantId(), ids) != ids.size()) {
throw exception(QUESTION_IMPORT_STATE_CONFLICT);
}
String result = JsonUtils.toJsonString(new ImportResult(ids.size(), ids));
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "EXECUTE_PENDING", "COMPLETED",
token, null, null, null, job.getParsedPayload(), job.getPreviewQuestionCount(),
result, ids.size(), null, null);
if (updated != 1) throw exception(CONTENT_IMPORT_LEASE_LOST);
});
}
private void finishFailed(ContentImportJobDO job, String token, String failureCode) {
@@ -207,6 +235,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private QuestionImportJobProjection project(ContentImportJobDO job, ContentImportAssetDO asset) {
return new QuestionImportJobProjection(job.getId(), job.getStatus(), job.getScanStatus(),
job.getParserStatus(), asset.getFileName(), asset.getFileSizeBytes(), job.getPreviewQuestionCount(),
job.getImportedQuestionCount(), job.getFailureCode());
job.getImportedQuestionCount(), job.getFailureCode(), job.getPreviewPayload(), job.getResultSummary());
}
private record ImportResult(int count, List<Long> questionIds) {}
}

View File

@@ -1,10 +1,12 @@
package cn.iocoder.yudao.module.education.service.importjob;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import java.util.List;
public interface QuestionImportParser {
ParsedImport parse(String objectKey);
List<QuestionDraftCommand> restore(String payload);
record ParsedImport(String payload, List<QuestionDraftCommand> questions) {}
record ParsedImport(String payload, String previewPayload, List<QuestionDraftCommand> questions,
int questionCount, boolean executable) {}
}

View File

@@ -0,0 +1,384 @@
package cn.iocoder.yudao.module.education.service.importjob;
import cn.hutool.http.HttpUtil;
import cn.idev.excel.FastExcel;
import cn.idev.excel.context.AnalysisContext;
import cn.idev.excel.enums.CellDataTypeEnum;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.read.listener.ReadListener;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import cn.iocoder.yudao.module.infra.api.file.FileApi;
import tools.jackson.core.type.TypeReference;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.QUESTION_IMPORT_INVALID_FILE;
@Component
public class StandardQuestionImportParser implements QuestionImportParser {
static final int MAX_FILE_BYTES = 8 * 1024 * 1024;
static final int MAX_ROWS = 5_000;
static final int MAX_COLUMNS = 160;
static final int MAX_CELLS = 100_000;
static final int MAX_CHARACTERS = 10_000_000;
private static final long MAX_XLSX_UNCOMPRESSED_BYTES = 64L * 1024 * 1024;
private static final int MAX_ISSUES = 200;
private static final int MAX_SAMPLE = 20;
private static final Map<String, String> HEADERS = headers();
private static final Set<String> UNSUPPORTED_HEADERS = Set.of(
"subject", "subjectid", "node", "nodeid", "collection", "collectionid", "tags", "status",
"publish", "published", "科目", "知识点", "节点", "题集", "标签", "状态", "发布");
private static final Set<String> SINGLE_TYPES = Set.of("choice", "single", "single_choice", "单选", "单选题");
private static final Set<String> MULTI_TYPES = Set.of("multi", "multi_choice", "多选", "多选题");
private static final Map<String, String> OPTIONLESS_TYPES = Map.ofEntries(
Map.entry("fill", "fill"), Map.entry("填空", "fill"), Map.entry("填空题", "fill"),
Map.entry("text", "text"), Map.entry("简答", "text"), Map.entry("简答题", "text"),
Map.entry("short_answer", "short_answer"), Map.entry("composition", "composition"),
Map.entry("作文", "composition"), Map.entry("translation", "translation"),
Map.entry("翻译", "translation"), Map.entry("calculation", "calculation"),
Map.entry("计算", "calculation"), Map.entry("solution", "solution"), Map.entry("解答", "solution"));
private final FileApi fileApi;
public StandardQuestionImportParser(FileApi fileApi) {
this.fileApi = fileApi;
}
@Override
public ParsedImport parse(String objectKey) {
byte[] bytes = read(objectKey);
List<List<String>> table;
String lower = path(objectKey).toLowerCase(Locale.ROOT);
if (lower.endsWith(".csv")) {
table = parseCsv(bytes);
} else if (lower.endsWith(".xlsx")) {
inspectXlsx(bytes);
table = parseXlsx(bytes);
} else {
throw invalid("FILE_TYPE_NOT_SUPPORTED");
}
CanonicalPayload payload = canonicalize(table);
List<QuestionDraftCommand> questions = payload.rows().stream()
.filter(row -> "VALID".equals(row.status()))
.map(CanonicalRow::question)
.toList();
return new ParsedImport(JsonUtils.toJsonString(payload), JsonUtils.toJsonString(preview(payload)), questions,
payload.counts().total(), payload.executable());
}
@Override
public List<QuestionDraftCommand> restore(String payload) {
CanonicalPayload restored;
try {
restored = JsonUtils.parseObject(payload, CanonicalPayload.class);
} catch (Exception ex) {
throw invalid("INVALID_PREVIEW_SCHEMA");
}
if (restored == null || restored.schemaVersion() != 1 || restored.rows() == null
|| restored.rows().isEmpty() || restored.rows().size() > MAX_ROWS) {
throw invalid("INVALID_PREVIEW_SCHEMA");
}
List<QuestionDraftCommand> questions = new ArrayList<>(restored.rows().size());
for (int i = 0; i < restored.rows().size(); i++) {
CanonicalRow row = restored.rows().get(i);
if (row == null || row.rowNumber() != i + 2 || !"VALID".equals(row.status())
|| row.issues() == null || !row.issues().isEmpty() || row.question() == null) {
throw invalid("INVALID_PREVIEW_SCHEMA");
}
if (!validate(row.question()).isEmpty()) throw invalid("INVALID_PREVIEW_SCHEMA");
questions.add(row.question());
}
return List.copyOf(questions);
}
private byte[] read(String objectKey) {
try {
String url = objectKey.startsWith("http://") || objectKey.startsWith("https://")
? objectKey : fileApi.presignGetUrl(objectKey, 60);
byte[] bytes = HttpUtil.downloadBytes(url);
if (bytes == null || bytes.length == 0 || bytes.length > MAX_FILE_BYTES) throw invalid("FILE_SIZE_LIMIT");
return bytes;
} catch (RuntimeException ex) {
if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException) throw ex;
throw invalid("FILE_READ_FAILED");
}
}
private List<List<String>> parseCsv(byte[] bytes) {
String text;
try {
text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException ex) {
throw invalid("CSV_NOT_UTF8");
}
if (text.startsWith("")) text = text.substring(1);
if (text.length() > MAX_CHARACTERS) throw invalid("CHARACTER_LIMIT");
List<List<String>> rows = new ArrayList<>();
List<String> row = new ArrayList<>();
StringBuilder cell = new StringBuilder();
boolean quoted = false;
boolean afterQuote = false;
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
if (quoted) {
if (ch == '"') {
if (i + 1 < text.length() && text.charAt(i + 1) == '"') { cell.append('"'); i++; }
else { quoted = false; afterQuote = true; }
} else cell.append(ch);
} else if (afterQuote) {
if (ch == ',') { addCell(row, cell); afterQuote = false; }
else if (ch == '\r' || ch == '\n') { addCell(row, cell); addRow(rows, row); row = new ArrayList<>(); afterQuote = false; if (ch == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') i++; }
else throw invalid("CSV_INVALID_QUOTE");
} else if (ch == '"') {
if (!cell.isEmpty()) throw invalid("CSV_INVALID_QUOTE");
quoted = true;
} else if (ch == ',') addCell(row, cell);
else if (ch == '\r' || ch == '\n') {
addCell(row, cell); addRow(rows, row); row = new ArrayList<>();
if (ch == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') i++;
} else cell.append(ch);
}
if (quoted) throw invalid("CSV_UNCLOSED_QUOTE");
if (!row.isEmpty() || !cell.isEmpty() || afterQuote) { addCell(row, cell); addRow(rows, row); }
enforceTableLimits(rows);
return rows;
}
private List<List<String>> parseXlsx(byte[] bytes) {
List<List<String>> rows = new ArrayList<>();
int[] characters = {0};
try {
FastExcel.read(new ByteArrayInputStream(bytes), new ReadListener<Map<Integer, ReadCellData<?>>>() {
@Override public void invoke(Map<Integer, ReadCellData<?>> data, AnalysisContext context) {
if (rows.size() >= MAX_ROWS + 1) throw invalid("ROW_LIMIT");
int width = data.isEmpty() ? 0 : Collections.max(data.keySet()) + 1;
if (width > MAX_COLUMNS) throw invalid("COLUMN_LIMIT");
List<String> row = new ArrayList<>(Collections.nCopies(width, ""));
for (Map.Entry<Integer, ReadCellData<?>> entry : data.entrySet()) {
ReadCellData<?> cell = entry.getValue();
if (cell.getFormulaData() != null || cell.getType() == CellDataTypeEnum.ERROR) {
throw invalid("XLSX_FORMULA_NOT_ALLOWED");
}
String value = cellValue(cell);
characters[0] += value.length();
if (characters[0] > MAX_CHARACTERS) throw invalid("CHARACTER_LIMIT");
row.set(entry.getKey(), value);
}
rows.add(row);
}
@Override public void doAfterAllAnalysed(AnalysisContext context) {}
}).ignoreHiddenSheet(false).headRowNumber(0).sheet(0).doRead();
} catch (RuntimeException ex) {
if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException) throw ex;
throw invalid("XLSX_INVALID");
}
enforceTableLimits(rows);
return rows;
}
private String cellValue(ReadCellData<?> cell) {
return switch (cell.getType()) {
case EMPTY -> "";
case STRING, DIRECT_STRING, RICH_TEXT_STRING -> Objects.toString(cell.getStringValue(), "");
case NUMBER, DATE -> cell.getNumberValue() == null ? "" : cell.getNumberValue().stripTrailingZeros().toPlainString();
case BOOLEAN -> Objects.toString(cell.getBooleanValue(), "");
default -> throw invalid("XLSX_CELL_TYPE_NOT_SUPPORTED");
};
}
private void inspectXlsx(byte[] bytes) {
long total = 0;
int entries = 0;
byte[] buffer = new byte[8192];
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(bytes))) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (++entries > 10_000) throw invalid("XLSX_ZIP_BOMB");
String name = entry.getName().replace('\\', '/').toLowerCase(Locale.ROOT);
if (name.startsWith("/") || name.contains("../") || name.equals("..")) throw invalid("XLSX_ZIP_TRAVERSAL");
if (name.startsWith("xl/externallinks/") || name.startsWith("xl/embeddings/")
|| name.contains("/oleobject") || name.endsWith("vbaproject.bin")) {
throw invalid("XLSX_ACTIVE_CONTENT_NOT_ALLOWED");
}
int read;
while ((read = zip.read(buffer)) != -1) {
total += read;
if (total > MAX_XLSX_UNCOMPRESSED_BYTES) throw invalid("XLSX_ZIP_BOMB");
}
}
} catch (IOException ex) {
throw invalid("XLSX_INVALID_ZIP");
}
if (entries == 0) throw invalid("XLSX_INVALID_ZIP");
}
private CanonicalPayload canonicalize(List<List<String>> table) {
if (table.size() < 2) throw invalid("EMPTY_IMPORT");
List<String> header = table.getFirst();
Map<String, Integer> columns = new HashMap<>();
Map<Integer, String> unsupported = new HashMap<>();
for (int i = 0; i < header.size(); i++) {
String raw = header.get(i).trim();
if (raw.isEmpty()) continue;
if (dangerousHeader(raw)) throw invalid("DANGEROUS_HEADER");
String normalized = normalizeHeader(raw);
String field = HEADERS.get(normalized);
if (field != null) {
if (columns.putIfAbsent(field, i) != null) throw invalid("DUPLICATE_HEADER");
} else if (UNSUPPORTED_HEADERS.contains(normalized)) unsupported.put(i, raw);
else throw invalid("UNKNOWN_HEADER");
}
if (!columns.keySet().containsAll(Set.of("stem", "type", "answer"))) throw invalid("REQUIRED_HEADER_MISSING");
List<CanonicalRow> rows = new ArrayList<>();
for (int i = 1; i < table.size(); i++) {
List<String> raw = table.get(i);
if (raw.stream().allMatch(String::isBlank)) continue;
List<Issue> issues = new ArrayList<>();
for (Map.Entry<Integer, String> entry : unsupported.entrySet()) {
if (!value(raw, entry.getKey()).isBlank()) issues.add(new Issue("FIELD_NOT_SUPPORTED", entry.getValue()));
}
QuestionDraftCommand question = toQuestion(raw, columns, issues);
issues.addAll(validate(question));
rows.add(new CanonicalRow(i + 1, issues.isEmpty() ? "VALID" : "INVALID", question, List.copyOf(issues)));
if (rows.size() > MAX_ROWS) throw invalid("ROW_LIMIT");
}
if (rows.isEmpty()) throw invalid("EMPTY_IMPORT");
int valid = (int) rows.stream().filter(row -> "VALID".equals(row.status())).count();
return new CanonicalPayload(1, List.copyOf(rows), new Counts(rows.size(), valid, rows.size() - valid),
valid == rows.size());
}
private QuestionDraftCommand toQuestion(List<String> row, Map<String, Integer> columns, List<Issue> issues) {
String rawType = field(row, columns, "type").trim().toLowerCase(Locale.ROOT);
String type;
if (SINGLE_TYPES.contains(rawType)) type = "choice";
else if (MULTI_TYPES.contains(rawType)) type = "multi_choice";
else type = OPTIONLESS_TYPES.getOrDefault(rawType, rawType);
List<QuestionDraftCommand.QuestionDraftOption> options = new ArrayList<>();
for (char label = 'A'; label <= 'H'; label++) {
String content = field(row, columns, "option" + label).trim();
if (!content.isEmpty()) options.add(new QuestionDraftCommand.QuestionDraftOption(
String.valueOf(label), content, (double) (label - 'A' + 1)));
}
String answer = field(row, columns, "answer").trim();
if ("multi_choice".equals(type) && !answer.startsWith("[")) {
List<String> labels = Arrays.stream(answer.split("[,;\\s]+"))
.map(String::trim).filter(value -> !value.isEmpty()).map(String::toUpperCase).toList();
answer = JsonUtils.toJsonString(labels);
} else if ("choice".equals(type)) answer = answer.toUpperCase(Locale.ROOT);
return new QuestionDraftCommand(field(row, columns, "stem").trim(), type,
emptyToNull(field(row, columns, "difficulty").trim()), List.copyOf(options), answer,
emptyToNull(field(row, columns, "explanation").trim()),
emptyToNull(field(row, columns, "analysis").trim()));
}
private List<Issue> validate(QuestionDraftCommand question) {
List<Issue> issues = new ArrayList<>();
if (question.stem() == null || question.stem().isBlank() || question.stem().length() > 20_000) issue(issues, "INVALID_STEM", "stem");
if (question.type() == null || question.type().isBlank() || question.type().length() > 32) issue(issues, "INVALID_TYPE", "type");
if (question.difficulty() != null && question.difficulty().length() > 32) issue(issues, "FIELD_TOO_LONG", "difficulty");
if (question.correctAnswer() == null || question.correctAnswer().isBlank() || question.correctAnswer().length() > 500) issue(issues, "INVALID_ANSWER", "answer");
if (question.explanation() != null && question.explanation().length() > 50_000) issue(issues, "FIELD_TOO_LONG", "explanation");
if (question.analysis() != null && question.analysis().length() > 50_000) issue(issues, "FIELD_TOO_LONG", "analysis");
try {
List<QuestionContentSafety.SafeOption> safe = question.options().stream()
.map(option -> new QuestionContentSafety.SafeOption(option.label(), option.content(), option.order())).toList();
safe = QuestionContentSafety.restoreSnapshotOptions(question.type(), JsonUtils.toJsonString(safe));
Set<String> labels = new HashSet<>();
safe.forEach(option -> labels.add(option.label()));
if ("choice".equals(question.type()) && !labels.contains(question.correctAnswer())) throw new IllegalArgumentException();
if ("multi_choice".equals(question.type())) {
List<String> answers = JsonUtils.parseObject(question.correctAnswer(), new TypeReference<>() {});
if (answers == null || answers.size() < 2 || new HashSet<>(answers).size() != answers.size()
|| !labels.containsAll(answers)) throw new IllegalArgumentException();
}
} catch (Exception ex) {
issue(issues, "OPTIONS_ANSWER_MISMATCH", "options");
}
return issues;
}
private PreviewPayload preview(CanonicalPayload payload) {
List<Sample> sample = payload.rows().stream().limit(MAX_SAMPLE)
.map(row -> new Sample(row.rowNumber(), row.status(), row.question() == null ? null : row.question().stem(), row.issues())).toList();
List<RowIssue> issues = payload.rows().stream().flatMap(row -> row.issues().stream()
.map(issue -> new RowIssue(row.rowNumber(), issue.code(), issue.field())))
.limit(MAX_ISSUES).toList();
return new PreviewPayload(1, payload.counts(), payload.executable(), sample, issues);
}
private static void enforceTableLimits(List<List<String>> rows) {
if (rows.size() > MAX_ROWS + 1) throw invalid("ROW_LIMIT");
long cells = 0;
for (List<String> row : rows) {
if (row.size() > MAX_COLUMNS) throw invalid("COLUMN_LIMIT");
cells += row.size();
if (cells > MAX_CELLS) throw invalid("CELL_LIMIT");
}
}
private static void addCell(List<String> row, StringBuilder cell) {
if (row.size() >= MAX_COLUMNS) throw invalid("COLUMN_LIMIT");
row.add(cell.toString()); cell.setLength(0);
}
private static void addRow(List<List<String>> rows, List<String> row) {
rows.add(List.copyOf(row));
if (rows.size() > MAX_ROWS + 1) throw invalid("ROW_LIMIT");
}
private static String field(List<String> row, Map<String, Integer> columns, String field) {
Integer index = columns.get(field);
return index == null ? "" : value(row, index);
}
private static String value(List<String> row, int index) { return index < row.size() ? row.get(index) : ""; }
private static String emptyToNull(String value) { return value.isEmpty() ? null : value; }
private static String normalizeHeader(String value) { return value.trim().toLowerCase(Locale.ROOT).replace("_", "").replace(" ", ""); }
private static boolean dangerousHeader(String value) { return value.length() > 100 || value.chars().anyMatch(ch -> ch < 32 || ch == 127) || "=+-@".indexOf(value.charAt(0)) >= 0; }
private static String path(String objectKey) { try { return URI.create(objectKey).getPath(); } catch (Exception ex) { return objectKey; } }
private static void issue(List<Issue> issues, String code, String field) { issues.add(new Issue(code, field)); }
private static cn.iocoder.yudao.framework.common.exception.ServiceException invalid(String code) { return exception(QUESTION_IMPORT_INVALID_FILE, code); }
private static Map<String, String> headers() {
Map<String, String> result = new HashMap<>();
aliases(result, "stem", "stem", "question", "题干", "题目");
aliases(result, "type", "type", "questiontype", "题型", "类型");
aliases(result, "difficulty", "difficulty", "难度");
aliases(result, "answer", "answer", "correctanswer", "答案", "正确答案");
aliases(result, "explanation", "explanation", "解析", "答案解析");
aliases(result, "analysis", "analysis", "深度解析", "分析");
for (char label = 'A'; label <= 'H'; label++) aliases(result, "option" + label,
"option" + label, "选项" + label, String.valueOf(label));
return Map.copyOf(result);
}
private static void aliases(Map<String, String> result, String field, String... aliases) {
for (String alias : aliases) result.put(normalizeHeader(alias), field);
}
public record CanonicalPayload(int schemaVersion, List<CanonicalRow> rows, Counts counts, boolean executable) {}
public record CanonicalRow(int rowNumber, String status, QuestionDraftCommand question, List<Issue> issues) {}
public record Counts(int total, int valid, int invalid) {}
public record Issue(String code, String field) {}
public record PreviewPayload(int schemaVersion, Counts counts, boolean executable, List<Sample> sample, List<RowIssue> issues) {}
public record Sample(int rowNumber, String status, String stem, List<Issue> issues) {}
public record RowIssue(int rowNumber, String code, String field) {}
}

View File

@@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.dal.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*;
import cn.iocoder.yudao.module.education.service.commercialization.EducationEntitlementService;
import cn.iocoder.yudao.module.education.service.badge.BadgeGrantService;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
@@ -48,6 +49,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
private final WrongQuestionService wrongQuestionService;
private final ScoringService scoringService;
private final EducationEntitlementService entitlementService;
private final BadgeGrantService badgeGrantService;
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
PracticeQuestionMapper questionMapper,
@@ -57,7 +59,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
QuestionCatalogProvider questionCatalogProvider,
WrongQuestionService wrongQuestionService,
ScoringService scoringService,
ObjectProvider<EducationEntitlementService> entitlementServiceProvider) {
ObjectProvider<EducationEntitlementService> entitlementServiceProvider,
ObjectProvider<BadgeGrantService> badgeGrantServiceProvider) {
this.sessionMapper = sessionMapper;
this.questionMapper = questionMapper;
this.idempotencyStore = idempotencyStore;
@@ -67,6 +70,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
this.wrongQuestionService = wrongQuestionService;
this.scoringService = scoringService;
this.entitlementService = entitlementServiceProvider.getIfAvailable();
this.badgeGrantService = badgeGrantServiceProvider.getIfAvailable();
}
@Override
@Transactional(rollbackFor = Exception.class)
@@ -371,6 +375,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
if (!"ACTIVE".equals(session.getStatus())) {
if ("SUBMITTED".equals(session.getStatus())) {
PracticeSubmitRespVO existingResponse = buildExistingSubmitResponse(session, tenantId, userId);
evaluatePracticeBadges(tenantId, userId, existingResponse.getReportId());
return completeSubmitClaim(claim, existingResponse);
}
throw exception(switch (session.getStatus()) {
@@ -392,6 +397,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
int reportInserted = reportMapper.insertIgnore(report);
if (reportInserted == 0) {
PracticeSubmitRespVO winnerResponse = buildExistingSubmitResponse(session, tenantId, userId);
evaluatePracticeBadges(tenantId, userId, winnerResponse.getReportId());
return completeSubmitClaim(claim, winnerResponse);
}
report = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
@@ -411,6 +417,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
throw exception(SUBMIT_CONCURRENT_CONFLICT);
}
evaluatePracticeBadges(tenantId, userId, report.getId());
return completeSubmitClaim(claim, scoringService.buildSubmitResponse(report, details));
}
@@ -438,6 +446,10 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
return response;
}
private void evaluatePracticeBadges(Long tenantId, Long userId, Long reportId) {
if (badgeGrantService != null) badgeGrantService.evaluatePractice(tenantId, userId, reportId);
}
@Override
public PracticeSubmitRespVO getReport(Long sessionId, Long userId, Long tenantId) {
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(sessionId, tenantId);

View File

@@ -1,10 +1,22 @@
package cn.iocoder.yudao.module.education.service.question.authoring;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
/** 租户自有题目的显式发布生命周期。 */
public interface TenantQuestionLifecycleService {
default PageResult<QuestionDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default QuestionDO get(Long questionId) { throw new UnsupportedOperationException(); }
Long createDraft(QuestionDraftCommand command);
default int reviseDraft(Long questionId, QuestionDraftCommand command, int expectedContentVersion) {
throw new UnsupportedOperationException();
}
int place(Long questionId, QuestionPlacementCommand command);
void publish(Long questionId, Long actorId);

View File

@@ -40,6 +40,10 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
private static final String DRAFT = "DRAFT";
private static final String PUBLISHED = "PUBLISHED";
private static final String ARCHIVED = "ARCHIVED";
private static final int MAX_STEM_LENGTH = 20_000;
private static final int MAX_OPTION_LENGTH = 10_000;
private static final int MAX_ANSWER_LENGTH = 500;
private static final int MAX_EXPLANATION_LENGTH = 50_000;
private final EducationProperties properties;
private final QuestionMapper questionMapper;
@@ -87,17 +91,10 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
@Transactional(rollbackFor = Exception.class)
public Long createDraft(QuestionDraftCommand command) {
assertAuthoringMode();
if (command == null) throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
Long tenantId = TenantContextHolder.getRequiredTenantId();
List<QuestionDraftCommand.QuestionDraftOption> suppliedOptions = command.options() != null
? command.options() : List.of();
List<QuestionDraftCommand.QuestionDraftOption> options = IntStream.range(0, suppliedOptions.size())
.mapToObj(index -> {
QuestionDraftCommand.QuestionDraftOption option = suppliedOptions.get(index);
return new QuestionDraftCommand.QuestionDraftOption(
option.label(), option.content(),
option.order() != null ? option.order() : index + 1D);
})
.toList();
List<QuestionDraftCommand.QuestionDraftOption> options = normalizeOptions(command);
validateDraft(command, options);
QuestionDO question = new QuestionDO();
question.setTenantId(tenantId);
@@ -115,11 +112,65 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
question.setSortOrder(0);
questionMapper.insert(question);
appendVersion(tenantId, question, 1);
return question.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public int reviseDraft(Long questionId, QuestionDraftCommand command, int expectedContentVersion) {
assertAuthoringMode();
if (command == null || expectedContentVersion < 1) {
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
}
Long tenantId = TenantContextHolder.getRequiredTenantId();
QuestionDO existing = questionMapper.selectTenantOwnedById(tenantId, questionId);
if (existing == null) {
throw exception(QUESTION_AUTHORING_NOT_FOUND);
}
if (!DRAFT.equals(existing.getStatus())
|| !Integer.valueOf(expectedContentVersion).equals(existing.getContentVersion())) {
throw exception(QUESTION_LIFECYCLE_CONFLICT, "修改");
}
List<QuestionDraftCommand.QuestionDraftOption> options = normalizeOptions(command);
validateDraft(command, options);
QuestionDO revised = new QuestionDO();
revised.setStem(command.stem());
revised.setType(command.type());
revised.setDifficulty(command.difficulty());
revised.setOptions(JsonUtils.toJsonString(options));
revised.setCorrectAnswer(command.correctAnswer());
revised.setExplanation(command.explanation());
revised.setAnalysis(command.analysis());
int nextVersion = expectedContentVersion + 1;
if (questionMapper.updateDraftContentCas(tenantId, questionId, revised, expectedContentVersion) != 1) {
throw exception(QUESTION_LIFECYCLE_CONFLICT, "修改");
}
revised.setId(questionId);
appendVersion(tenantId, revised, nextVersion);
return nextVersion;
}
private List<QuestionDraftCommand.QuestionDraftOption> normalizeOptions(QuestionDraftCommand command) {
List<QuestionDraftCommand.QuestionDraftOption> suppliedOptions = command.options() != null
? command.options() : List.of();
return IntStream.range(0, suppliedOptions.size())
.mapToObj(index -> {
QuestionDraftCommand.QuestionDraftOption option = suppliedOptions.get(index);
return option == null ? null : new QuestionDraftCommand.QuestionDraftOption(
option.label(), option.content(),
option.order() != null ? option.order() : index + 1D);
})
.toList();
}
private void appendVersion(Long tenantId, QuestionDO question, int versionNumber) {
QuestionVersionDO version = new QuestionVersionDO();
version.setTenantId(tenantId);
version.setScope(TENANT_OWNED);
version.setQuestionId(question.getId());
version.setVersionNumber(1);
version.setVersionNumber(versionNumber);
version.setStem(question.getStem());
version.setType(question.getType());
version.setDifficulty(question.getDifficulty());
@@ -128,7 +179,6 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
version.setExplanation(question.getExplanation());
version.setAnalysis(question.getAnalysis());
versionMapper.insert(version);
return question.getId();
}
@Override
@@ -182,6 +232,44 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
auditMapper.insert(audit);
}
private void validateDraft(QuestionDraftCommand command,
List<QuestionDraftCommand.QuestionDraftOption> options) {
if (blankOrLong(command.stem(), MAX_STEM_LENGTH)
|| blankOrLong(command.type(), 32) || tooLong(command.difficulty(), 32)
|| blankOrLong(command.correctAnswer(), MAX_ANSWER_LENGTH)
|| tooLong(command.explanation(), MAX_EXPLANATION_LENGTH)
|| tooLong(command.analysis(), MAX_EXPLANATION_LENGTH)) {
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
}
try {
List<QuestionContentSafety.SafeOption> safeOptions = options.stream()
.map(option -> option == null ? null : new QuestionContentSafety.SafeOption(
option.label(), option.content(), option.order()))
.toList();
safeOptions = validateOptionLengths(command.type(), safeOptions);
validateAnswerKey(command.type(), command.correctAnswer(), safeOptions);
} catch (ServiceException ex) {
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
}
}
private List<QuestionContentSafety.SafeOption> validateOptionLengths(String type,
List<QuestionContentSafety.SafeOption> options) {
if (options.size() > 8 || options.stream().anyMatch(option -> option == null
|| blankOrLong(option.label(), 20) || blankOrLong(option.content(), MAX_OPTION_LENGTH))) {
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
}
return QuestionContentSafety.restoreSnapshotOptions(type, JsonUtils.toJsonString(options));
}
private boolean blankOrLong(String value, int maxLength) {
return value == null || value.isBlank() || value.length() > maxLength;
}
private boolean tooLong(String value, int maxLength) {
return value != null && value.length() > maxLength;
}
private void validatePublishable(QuestionDO question) {
if (question.getStem() == null || question.getStem().isBlank()
|| question.getCorrectAnswer() == null || question.getCorrectAnswer().isBlank()) {

Some files were not shown because too many files have changed in this diff Show More