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> <artifactId>yudao-spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </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> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <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; package cn.iocoder.yudao.module.education.controller.admin.blueprint;
import cn.iocoder.yudao.framework.common.pojo.CommonResult; 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.controller.admin.blueprint.vo.*;
import cn.iocoder.yudao.module.education.service.blueprint.authoring.*; import cn.iocoder.yudao.module.education.service.blueprint.authoring.*;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -19,11 +22,20 @@ public class PracticeBlueprintAuthoringController {
private final PracticeBlueprintAuthoringService service; private final PracticeBlueprintAuthoringService service;
public PracticeBlueprintAuthoringController(PracticeBlueprintAuthoringService service) { this.service = 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')") @PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:author')")
public CommonResult<Long> create(@Valid @RequestBody PracticeBlueprintDraftReqVO request) { public CommonResult<Long> create(@Valid @RequestBody PracticeBlueprintDraftReqVO request) {
return success(service.createDraft(toCommand(request, null))); 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) { public CommonResult<Integer> revise(@PathVariable Long id, @Valid @RequestBody PracticeBlueprintReviseReqVO request) {
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion()))); 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; package cn.iocoder.yudao.module.education.controller.admin.category;
import cn.iocoder.yudao.framework.common.pojo.CommonResult; 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.CategoryDraftReqVO;
import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryReviseReqVO; 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.CategoryAuthoringCommand;
import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringService; import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -23,6 +27,21 @@ public class CategoryAuthoringController {
public CategoryAuthoringController(CategoryAuthoringService service) { this.service = service; } 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") @PostMapping("/drafts")
@PreAuthorize("@ss.hasPermission('education:category:author')") @PreAuthorize("@ss.hasPermission('education:category:author')")
public CommonResult<Long> create(@Valid @RequestBody CategoryDraftReqVO request) { 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; package cn.iocoder.yudao.module.education.controller.admin.collection;
import cn.iocoder.yudao.framework.common.pojo.CommonResult; 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.controller.admin.collection.vo.*;
import cn.iocoder.yudao.module.education.service.collection.authoring.*; import cn.iocoder.yudao.module.education.service.collection.authoring.*;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -19,15 +22,24 @@ public class QuestionCollectionAuthoringController {
private final QuestionCollectionAuthoringService service; private final QuestionCollectionAuthoringService service;
public QuestionCollectionAuthoringController(QuestionCollectionAuthoringService service) { this.service = 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')") @PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:collection:author')")
public CommonResult<Long> create(@Valid @RequestBody QuestionCollectionDraftReqVO request) { public CommonResult<Long> create(@Valid @RequestBody QuestionCollectionDraftReqVO request) {
return success(service.createDraft(toCommand(request, null))); 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) { public CommonResult<Integer> revise(@PathVariable Long id, @Valid @RequestBody QuestionCollectionReviseReqVO request) {
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion()))); 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) { public CommonResult<Integer> replaceMembership(@PathVariable Long id, @Valid @RequestBody QuestionCollectionMembershipReqVO request) {
return success(service.replaceMembership(id, request.getQuestionIds(), request.getExpectedAuthoringVersion())); 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; package cn.iocoder.yudao.module.education.controller.admin.contentnode;
import cn.iocoder.yudao.framework.common.pojo.CommonResult; 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.ContentNodeDraftReqVO;
import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeReviseReqVO; import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeReviseReqVO;
import cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand; import cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand;
@@ -22,6 +26,18 @@ public class ContentNodeAuthoringController {
private final ContentNodeAuthoringService service; private final ContentNodeAuthoringService service;
public ContentNodeAuthoringController(ContentNodeAuthoringService service) { this.service = 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") @PostMapping("/drafts")
@PreAuthorize("@ss.hasPermission('education:content-node:author')") @PreAuthorize("@ss.hasPermission('education:content-node:author')")
public CommonResult<Long> create(@Valid @RequestBody ContentNodeDraftReqVO request) { public CommonResult<Long> create(@Valid @RequestBody ContentNodeDraftReqVO request) {
@@ -29,7 +45,7 @@ public class ContentNodeAuthoringController {
} }
@PutMapping("/{id}/draft") @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, public CommonResult<Integer> revise(@PathVariable("id") Long id,
@Valid @RequestBody ContentNodeReviseReqVO request) { @Valid @RequestBody ContentNodeReviseReqVO request) {
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion()))); 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 previewQuestionCount;
private Integer importedQuestionCount; private Integer importedQuestionCount;
private String failureCode; private String failureCode;
private String previewPayload;
private String resultSummary;
public static QuestionImportJobRespVO from(QuestionImportJobProjection source) { public static QuestionImportJobRespVO from(QuestionImportJobProjection source) {
QuestionImportJobRespVO target = new QuestionImportJobRespVO(); QuestionImportJobRespVO target = new QuestionImportJobRespVO();
target.id = source.id(); target.status = source.status(); target.scanStatus = source.scanStatus(); 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.parserStatus = source.parserStatus(); target.fileName = source.fileName(); target.fileSize = source.fileSize();
target.previewQuestionCount = source.previewQuestionCount(); target.importedQuestionCount = source.importedQuestionCount(); 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; 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; package cn.iocoder.yudao.module.education.controller.admin.question;
import cn.iocoder.yudao.framework.common.pojo.CommonResult; 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.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.controller.admin.question.vo.QuestionPlacementReqVO;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand; import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand; import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand;
@@ -32,18 +37,31 @@ public class QuestionAuthoringController {
this.lifecycleService = lifecycleService; 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") @PostMapping("/drafts")
@Operation(summary = "创建租户题目草稿") @Operation(summary = "创建租户题目草稿")
@PreAuthorize("@ss.hasPermission('education:question:author')") @PreAuthorize("@ss.hasPermission('education:question:author')")
public CommonResult<Long> createDraft(@Valid @RequestBody QuestionDraftCreateReqVO reqVO) { public CommonResult<Long> createDraft(@Valid @RequestBody QuestionDraftCreateReqVO reqVO) {
List<QuestionDraftCommand.QuestionDraftOption> options = reqVO.getOptions().stream() return success(lifecycleService.createDraft(toCommand(reqVO)));
.map(option -> new QuestionDraftCommand.QuestionDraftOption( }
option.getLabel(), option.getContent(), option.getOrder()))
.toList(); @PutMapping("/{id}/draft")
QuestionDraftCommand command = new QuestionDraftCommand( @Operation(summary = "修订租户题目草稿")
reqVO.getStem(), reqVO.getType(), reqVO.getDifficulty(), options, @PreAuthorize("@ss.hasPermission('education:question:update')")
reqVO.getCorrectAnswer(), reqVO.getExplanation(), reqVO.getAnalysis()); public CommonResult<Integer> reviseDraft(@PathVariable("id") Long id,
return success(lifecycleService.createDraft(command)); @Valid @RequestBody QuestionDraftReviseReqVO reqVO) {
return success(lifecycleService.reviseDraft(id, toCommand(reqVO), reqVO.getExpectedContentVersion()));
} }
@PutMapping("/{id}/placement") @PutMapping("/{id}/placement")
@@ -70,4 +88,13 @@ public class QuestionAuthoringController {
lifecycleService.archive(id, getLoginUserId()); lifecycleService.archive(id, getLoginUserId());
return success(true); 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.LearningSummaryRespVO;
import cn.iocoder.yudao.module.education.controller.app.engagement.vo.StudentFeedbackCreateReqVO; 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.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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -41,6 +43,8 @@ public class EducationEngagementController {
@Resource @Resource
private EducationEngagementService engagementService; private EducationEngagementService engagementService;
@Resource
private BadgeGrantService badgeGrantService;
@PostMapping("/feedback") @PostMapping("/feedback")
@Operation(summary = "提交学生反馈") @Operation(summary = "提交学生反馈")
@@ -71,6 +75,14 @@ public class EducationEngagementController {
return success(engagementService.getLeaderboard(getTenantId(), limit)); 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() { private LoginUser getStudentPrincipal() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null || loginUser.getId() == null 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 cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@TableName("education_learning_award")
@KeySequence("education_learning_award_seq") @KeySequence("education_learning_award_seq")
@TableName(value = "education_learning_award", autoResultMap = true)
public class LearningAwardDO extends TenantBaseDO { public class LearningAwardDO extends TenantBaseDO {
@TableId @TableId
private Long id; private Long id;
@@ -21,4 +23,13 @@ public class LearningAwardDO extends TenantBaseDO {
private Integer pointAmount; private Integer pointAmount;
private String pointStatus; private String pointStatus;
private String pointError; 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.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@TableName("education_student_feedback") @TableName("education_student_feedback")
@@ -19,4 +21,12 @@ public class StudentFeedbackDO extends TenantBaseDO {
private String content; private String content;
private String contact; private String contact;
private String status; 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 name;
private String description; private String description;
private String status; 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; package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; 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 cn.iocoder.yudao.module.education.dal.dataobject.LearningAwardDO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.Data;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List; import java.util.List;
@@ -23,12 +28,69 @@ public interface LearningAwardMapper extends BaseMapperX<LearningAwardDO> {
@Options(useGeneratedKeys = true, keyProperty = "id") @Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(LearningAwardDO record); 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) { default LearningAwardDO selectOwnerEvent(Long tenantId, Long userId, String eventKey, String awardType) {
return selectOne(new LambdaQueryWrapper<LearningAwardDO>().eq(LearningAwardDO::getTenantId, tenantId) return selectOne(new LambdaQueryWrapper<LearningAwardDO>().eq(LearningAwardDO::getTenantId, tenantId)
.eq(LearningAwardDO::getUserId, userId).eq(LearningAwardDO::getEventKey, eventKey) .eq(LearningAwardDO::getUserId, userId).eq(LearningAwardDO::getEventKey, eventKey)
.eq(LearningAwardDO::getAwardType, awardType)); .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) { default int claimPoints(Long id, Long tenantId, Long userId) {
return update(null, new LambdaUpdateWrapper<LearningAwardDO>().eq(LearningAwardDO::getId, id) return update(null, new LambdaUpdateWrapper<LearningAwardDO>().eq(LearningAwardDO::getId, id)
.eq(LearningAwardDO::getTenantId, tenantId).eq(LearningAwardDO::getUserId, userId) .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); List<LeaderboardRow> selectLeaderboard(Long tenantId, int limit);
interface LeaderboardRow { default PageResult<LearningAwardDO> selectAdminPage(PageParam pageParam, Long tenantId, Long userId,
Long getUserId(); String awardType, String pointStatus, Boolean hasBadge) {
Long getLearningScore(); 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.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDO; import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDO;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Data;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Options;
@@ -64,4 +65,17 @@ public interface PracticeReportMapper extends BaseMapperX<PracticeReportDO> {
""") """)
Long sumCorrectByOwner(Long tenantId, Long userId); 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; package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; 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 cn.iocoder.yudao.module.education.dal.dataobject.StudentFeedbackDO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List; import java.util.List;
@@ -15,4 +20,97 @@ public interface StudentFeedbackMapper extends BaseMapperX<StudentFeedbackDO> {
.eq(StudentFeedbackDO::getDeleted, false).orderByDesc(StudentFeedbackDO::getCreateTime) .eq(StudentFeedbackDO::getDeleted, false).orderByDesc(StudentFeedbackDO::getCreateTime)
.last("LIMIT 100")); .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.Insert;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -33,4 +34,10 @@ public interface VocabularyProgressMapper extends BaseMapperX<VocabularyProgress
.le(VocabularyProgressDO::getNextReviewTime, now).orderByAsc(VocabularyProgressDO::getNextReviewTime) .le(VocabularyProgressDO::getNextReviewTime, now).orderByAsc(VocabularyProgressDO::getNextReviewTime)
.last("LIMIT " + limit)); .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; 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.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
@@ -8,6 +10,15 @@ import java.util.List;
@Mapper @Mapper
public interface CategoryMapper extends BaseMapperX<CategoryDO> { 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) { default CategoryDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<CategoryDO>().eq(CategoryDO::getId, id) return selectOne(new LambdaQueryWrapperX<CategoryDO>().eq(CategoryDO::getId, id)
.eq(CategoryDO::getTenantId, tenantId).eq(CategoryDO::getScope, "TENANT_OWNED") .eq(CategoryDO::getTenantId, tenantId).eq(CategoryDO::getScope, "TENANT_OWNED")

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog; 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.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO; 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, ContentNodeDO selectAvailableParentForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
@Param("parentId") Long parentId); @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) { default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<ContentNodeDO>().eq(ContentNodeDO::getId, id) return selectOne(new LambdaQueryWrapperX<ContentNodeDO>().eq(ContentNodeDO::getId, id)
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED") .eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog; 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.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintDO; 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; if (collectionId == null && nodeId == null) return null;
return selectAvailableForStudent(tenantId, collectionId, nodeId); 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) { default PracticeBlueprintDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<PracticeBlueprintDO>().eq(PracticeBlueprintDO::getId, id) return selectOne(new LambdaQueryWrapperX<PracticeBlueprintDO>().eq(PracticeBlueprintDO::getId, id)
.eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED") .eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED")

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog; 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.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO; 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) { 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); 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) { default QuestionCollectionDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id) return selectOne(new LambdaQueryWrapperX<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id)
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED") .eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog; 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.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO; 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)); 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) { default QuestionDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<QuestionDO>() return selectOne(new LambdaQueryWrapperX<QuestionDO>()
.eq(QuestionDO::getId, id) .eq(QuestionDO::getId, id)
@@ -179,6 +189,35 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
.eq(QuestionDO::getScope, "TENANT_OWNED")); .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, default int updateLifecycle(Long tenantId, Long id, String expectedStatus,
String targetStatus, boolean published, Integer expectedPlacementVersion) { String targetStatus, boolean published, Integer expectedPlacementVersion) {
return update(null, new LambdaUpdateWrapper<QuestionDO>() return update(null, new LambdaUpdateWrapper<QuestionDO>()

View File

@@ -1,9 +1,20 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog; 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.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper @Mapper
public interface QuestionVersionMapper extends BaseMapperX<QuestionVersionDO> { 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::getResourceId, resourceId)
.eq(EducationResourceProductBindingDO::getDeleted, false)); .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 next_attempt_at=CURRENT_TIMESTAMP, update_time=CURRENT_TIMESTAMP
WHERE tenant_id=#{tenantId} AND id=#{id} AND status='PREVIEW_READY' WHERE tenant_id=#{tenantId} AND id=#{id} AND status='PREVIEW_READY'
AND scan_status='CLEAN' AND parser_status='PARSED' AND deleted=false 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, int requestExecute(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("executeKey") String executeKey, @Param("executeRequestHash") String executeRequestHash); @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_TYPE_INVALID = new ErrorCode(1_005_004_040, "不支持的学习奖励类型:{}");
ErrorCode LEARNING_AWARD_POINT_FAILED = new ErrorCode(1_005_004_041, "学习积分发放失败,请稍后使用相同事件重试"); 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_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 ========== // ========== 题目导入任务 1-005-003-080 ~ 1-005-003-089 ==========
ErrorCode QUESTION_IMPORT_NOT_FOUND = new ErrorCode(1_005_003_080, "题目导入任务不存在或无权访问"); 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_SCAN_NOT_CLEAN = new ErrorCode(1_005_003_083, "导入文件未通过安全扫描");
ErrorCode QUESTION_IMPORT_PARSER_UNAVAILABLE = new ErrorCode(1_005_003_084, "导入解析器不可用"); 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_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 ========== // ========== 内容导入任务 1-005-004-060 ~ 1-005-004-069 ==========
ErrorCode CONTENT_IMPORT_JOB_NOT_FOUND = new ErrorCode(1_005_004_060, "内容导入任务不存在或无权访问"); 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) { 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); "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.config.EducationProperties;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties; import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder; import java.util.concurrent.atomic.LongAdder;
@Component
public class ScalarLegacyDependencyTelemetry implements EducationRuntimeDependency { public class ScalarLegacyDependencyTelemetry implements EducationRuntimeDependency {
private final EducationProperties educationProperties; 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, FileDescriptor descriptor = fileApi.createFile(new FileContent(bytes, name,
"education/import-assets/" + tenantId, contentType)); "education/import-assets/" + tenantId, contentType));
FileScanStatus scanStatus = fileApi.scan(descriptor); 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(); EducationImportAssetDO asset = new EducationImportAssetDO();
asset.setTenantId(tenantId); asset.setTenantId(tenantId);
@@ -63,7 +65,7 @@ public class EducationAssetAdmissionServiceImpl implements EducationAssetAdmissi
asset.setOriginalName(name); asset.setOriginalName(name);
asset.setContentType(contentType); asset.setContentType(contentType);
asset.setSize((long) bytes.length); 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.setFileReference(descriptor.reference());
asset.setScanStatus(scanStatus.name()); asset.setScanStatus(scanStatus.name());
assetMapper.insert(asset); 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; 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 { public interface PracticeBlueprintAuthoringService {
default PageResult<PracticeBlueprintDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default PracticeBlueprintDO get(Long id) { throw new UnsupportedOperationException(); }
Long createDraft(PracticeBlueprintAuthoringCommand command); Long createDraft(PracticeBlueprintAuthoringCommand command);
int reviseDraft(Long id, PracticeBlueprintAuthoringCommand command); int reviseDraft(Long id, PracticeBlueprintAuthoringCommand command);
int activate(Long id, int expectedVersion, Long actorId); int activate(Long id, int expectedVersion, Long actorId);

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.service.blueprint.authoring; 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.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties; 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; 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) @Override @Transactional(rollbackFor = Exception.class)
public Long createDraft(PracticeBlueprintAuthoringCommand command) { public Long createDraft(PracticeBlueprintAuthoringCommand command) {
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); Target target = validate(tenantId, 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; 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 { public interface CategoryAuthoringService {
default PageResult<CategoryDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default CategoryDO get(Long categoryId) { throw new UnsupportedOperationException(); }
Long createDraft(CategoryAuthoringCommand command); Long createDraft(CategoryAuthoringCommand command);
int reviseDraft(Long categoryId, CategoryAuthoringCommand command); int reviseDraft(Long categoryId, CategoryAuthoringCommand command);
int activate(Long categoryId, int expectedAuthoringVersion, Long actorId); 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.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; 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.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryLifecycleAuditDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryLifecycleAuditDO;
@@ -34,6 +36,18 @@ public class CategoryAuthoringServiceImpl implements CategoryAuthoringService {
this.subjectMapper = subjectMapper; 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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Long createDraft(CategoryAuthoringCommand command) { public Long createDraft(CategoryAuthoringCommand command) {

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.service.collection.authoring; 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.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties; 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; 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) @Override @Transactional(rollbackFor = Exception.class)
public Long createDraft(QuestionCollectionAuthoringCommand command) { public Long createDraft(QuestionCollectionAuthoringCommand command) {
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId();

View File

@@ -1,6 +1,12 @@
package cn.iocoder.yudao.module.education.service.contentnode.authoring; 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 { public interface ContentNodeAuthoringService {
default PageResult<ContentNodeDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default ContentNodeDO get(Long nodeId) { throw new UnsupportedOperationException(); }
Long createDraft(ContentNodeAuthoringCommand command); Long createDraft(ContentNodeAuthoringCommand command);
int reviseDraft(Long nodeId, ContentNodeAuthoringCommand command); int reviseDraft(Long nodeId, ContentNodeAuthoringCommand command);
int activate(Long nodeId, int expectedAuthoringVersion, Long actorId); int activate(Long nodeId, int expectedAuthoringVersion, Long actorId);

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.module.education.service.contentnode.authoring; 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.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties; import cn.iocoder.yudao.module.education.config.EducationProperties;
@@ -30,6 +32,18 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ
this.auditMapper = auditMapper; 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) @Override @Transactional(rollbackFor = Exception.class)
public Long createDraft(ContentNodeAuthoringCommand command) { public Long createDraft(ContentNodeAuthoringCommand command) {
assertMode(); 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; package cn.iocoder.yudao.module.education.service.importjob;
public interface ImportObjectScanGateway { public interface ImportObjectScanGateway {
enum ScanResult { CLEAN, INFECTED, ERROR } enum ScanResult { CLEAN, INFECTED, ERROR, UNAVAILABLE }
ScanResult scan(String objectKey); 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, public record QuestionImportJobProjection(Long id, String status, String scanStatus, String parserStatus,
String fileName, Long fileSize, Integer previewQuestionCount, 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.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportAssetDO; 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.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.ContentImportAssetMapper;
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportJobMapper; import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportJobMapper;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; 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 cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -28,7 +33,8 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
public class QuestionImportJobServiceImpl implements QuestionImportJobService { public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private static final int MAX_ATTEMPTS = 5; 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 EducationProperties properties;
private final ContentImportAssetMapper assetMapper; private final ContentImportAssetMapper assetMapper;
@@ -36,18 +42,26 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private final Optional<ImportObjectScanGateway> scanGateway; private final Optional<ImportObjectScanGateway> scanGateway;
private final Optional<QuestionImportParser> parser; private final Optional<QuestionImportParser> parser;
private final TenantQuestionLifecycleService lifecycleService; private final TenantQuestionLifecycleService lifecycleService;
private final QuestionMapper questionMapper;
private final QuestionVersionMapper versionMapper;
private final TransactionTemplate transactionTemplate;
public QuestionImportJobServiceImpl(EducationProperties properties, ContentImportAssetMapper assetMapper, public QuestionImportJobServiceImpl(EducationProperties properties, ContentImportAssetMapper assetMapper,
ContentImportJobMapper jobMapper, ContentImportJobMapper jobMapper,
Optional<ImportObjectScanGateway> scanGateway, Optional<ImportObjectScanGateway> scanGateway,
Optional<QuestionImportParser> parser, Optional<QuestionImportParser> parser,
TenantQuestionLifecycleService lifecycleService) { TenantQuestionLifecycleService lifecycleService,
QuestionMapper questionMapper, QuestionVersionMapper versionMapper,
TransactionTemplate transactionTemplate) {
this.properties = properties; this.properties = properties;
this.assetMapper = assetMapper; this.assetMapper = assetMapper;
this.jobMapper = jobMapper; this.jobMapper = jobMapper;
this.scanGateway = scanGateway; this.scanGateway = scanGateway;
this.parser = parser; this.parser = parser;
this.lifecycleService = lifecycleService; this.lifecycleService = lifecycleService;
this.questionMapper = questionMapper;
this.versionMapper = versionMapper;
this.transactionTemplate = transactionTemplate;
} }
@Override @Override
@@ -117,7 +131,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
private void processClaimed(Long jobId, String expectedStatus) { private void processClaimed(Long jobId, String expectedStatus) {
jobMapper.failExhausted(); jobMapper.failExhausted();
String token = UUID.randomUUID().toString(); 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)); if (job != null) TenantUtils.execute(job.getTenantId(), () -> process(job, token));
} }
@@ -133,7 +148,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
finishFailed(job, token, "SCAN_UNAVAILABLE"); finishFailed(job, token, "SCAN_UNAVAILABLE");
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN); 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) { if (scanResult != ImportObjectScanGateway.ScanResult.CLEAN) {
finishFailed(job, token, "SCAN_" + scanResult.name()); finishFailed(job, token, "SCAN_" + scanResult.name());
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN); throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
@@ -147,8 +163,8 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
QuestionImportParser.ParsedImport parsed = availableParser.parse(asset.getObjectKey()); QuestionImportParser.ParsedImport parsed = availableParser.parse(asset.getObjectKey());
parserStatus = "PARSED"; parserStatus = "PARSED";
parsedPayload = parsed.payload(); parsedPayload = parsed.payload();
questionCount = parsed.questions().size(); questionCount = parsed.questionCount();
previewPayload = "{\"mode\":\"PARSED\",\"executable\":true}"; previewPayload = parsed.previewPayload();
} }
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "PREVIEW_PENDING", "PREVIEW_READY", int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "PREVIEW_PENDING", "PREVIEW_READY",
token, "CLEAN", parserStatus, previewPayload, parsedPayload, questionCount, null, null, null, null); 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) { private void processExecute(ContentImportJobDO job, String token) {
if (!"CLEAN".equals(job.getScanStatus())) throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN); if (!"CLEAN".equals(job.getScanStatus())) throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
QuestionImportParser availableParser = parser.orElseThrow(() -> exception(QUESTION_IMPORT_PARSER_UNAVAILABLE)); QuestionImportParser availableParser = parser.orElseThrow(() -> exception(QUESTION_IMPORT_PARSER_UNAVAILABLE));
List<QuestionDraftCommand> questions = availableParser.restore(job.getParsedPayload()); transactionTemplate.executeWithoutResult(status -> {
for (QuestionDraftCommand question : questions) lifecycleService.createDraft(question); List<QuestionDraftCommand> questions = availableParser.restore(job.getParsedPayload());
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "EXECUTE_PENDING", "COMPLETED", if (job.getPreviewQuestionCount() == null || questions.size() != job.getPreviewQuestionCount()) {
token, null, null, null, job.getParsedPayload(), job.getPreviewQuestionCount(), throw exception(QUESTION_IMPORT_STATE_CONFLICT);
"{\"result\":\"COMPLETED\"}", questions.size(), null, null); }
if (updated != 1) throw exception(CONTENT_IMPORT_LEASE_LOST); 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) { 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) { private QuestionImportJobProjection project(ContentImportJobDO job, ContentImportAssetDO asset) {
return new QuestionImportJobProjection(job.getId(), job.getStatus(), job.getScanStatus(), return new QuestionImportJobProjection(job.getId(), job.getStatus(), job.getScanStatus(),
job.getParserStatus(), asset.getFileName(), asset.getFileSizeBytes(), job.getPreviewQuestionCount(), 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; package cn.iocoder.yudao.module.education.service.importjob;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand; import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import java.util.List; import java.util.List;
public interface QuestionImportParser { public interface QuestionImportParser {
ParsedImport parse(String objectKey); ParsedImport parse(String objectKey);
List<QuestionDraftCommand> restore(String payload); 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.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*; import cn.iocoder.yudao.module.education.dal.mysql.*;
import cn.iocoder.yudao.module.education.service.commercialization.EducationEntitlementService; 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.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety; import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO; 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 WrongQuestionService wrongQuestionService;
private final ScoringService scoringService; private final ScoringService scoringService;
private final EducationEntitlementService entitlementService; private final EducationEntitlementService entitlementService;
private final BadgeGrantService badgeGrantService;
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper, public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
PracticeQuestionMapper questionMapper, PracticeQuestionMapper questionMapper,
@@ -57,7 +59,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
QuestionCatalogProvider questionCatalogProvider, QuestionCatalogProvider questionCatalogProvider,
WrongQuestionService wrongQuestionService, WrongQuestionService wrongQuestionService,
ScoringService scoringService, ScoringService scoringService,
ObjectProvider<EducationEntitlementService> entitlementServiceProvider) { ObjectProvider<EducationEntitlementService> entitlementServiceProvider,
ObjectProvider<BadgeGrantService> badgeGrantServiceProvider) {
this.sessionMapper = sessionMapper; this.sessionMapper = sessionMapper;
this.questionMapper = questionMapper; this.questionMapper = questionMapper;
this.idempotencyStore = idempotencyStore; this.idempotencyStore = idempotencyStore;
@@ -67,6 +70,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
this.wrongQuestionService = wrongQuestionService; this.wrongQuestionService = wrongQuestionService;
this.scoringService = scoringService; this.scoringService = scoringService;
this.entitlementService = entitlementServiceProvider.getIfAvailable(); this.entitlementService = entitlementServiceProvider.getIfAvailable();
this.badgeGrantService = badgeGrantServiceProvider.getIfAvailable();
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -371,6 +375,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
if (!"ACTIVE".equals(session.getStatus())) { if (!"ACTIVE".equals(session.getStatus())) {
if ("SUBMITTED".equals(session.getStatus())) { if ("SUBMITTED".equals(session.getStatus())) {
PracticeSubmitRespVO existingResponse = buildExistingSubmitResponse(session, tenantId, userId); PracticeSubmitRespVO existingResponse = buildExistingSubmitResponse(session, tenantId, userId);
evaluatePracticeBadges(tenantId, userId, existingResponse.getReportId());
return completeSubmitClaim(claim, existingResponse); return completeSubmitClaim(claim, existingResponse);
} }
throw exception(switch (session.getStatus()) { throw exception(switch (session.getStatus()) {
@@ -392,6 +397,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
int reportInserted = reportMapper.insertIgnore(report); int reportInserted = reportMapper.insertIgnore(report);
if (reportInserted == 0) { if (reportInserted == 0) {
PracticeSubmitRespVO winnerResponse = buildExistingSubmitResponse(session, tenantId, userId); PracticeSubmitRespVO winnerResponse = buildExistingSubmitResponse(session, tenantId, userId);
evaluatePracticeBadges(tenantId, userId, winnerResponse.getReportId());
return completeSubmitClaim(claim, winnerResponse); return completeSubmitClaim(claim, winnerResponse);
} }
report = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId); report = reportMapper.selectBySessionIdAndTenant(session.getId(), tenantId);
@@ -411,6 +417,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
throw exception(SUBMIT_CONCURRENT_CONFLICT); throw exception(SUBMIT_CONCURRENT_CONFLICT);
} }
evaluatePracticeBadges(tenantId, userId, report.getId());
return completeSubmitClaim(claim, scoringService.buildSubmitResponse(report, details)); return completeSubmitClaim(claim, scoringService.buildSubmitResponse(report, details));
} }
@@ -438,6 +446,10 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
return response; return response;
} }
private void evaluatePracticeBadges(Long tenantId, Long userId, Long reportId) {
if (badgeGrantService != null) badgeGrantService.evaluatePractice(tenantId, userId, reportId);
}
@Override @Override
public PracticeSubmitRespVO getReport(Long sessionId, Long userId, Long tenantId) { public PracticeSubmitRespVO getReport(Long sessionId, Long userId, Long tenantId) {
PracticeSessionDO session = sessionMapper.selectByIdAndTenant(sessionId, tenantId); PracticeSessionDO session = sessionMapper.selectByIdAndTenant(sessionId, tenantId);

View File

@@ -1,10 +1,22 @@
package cn.iocoder.yudao.module.education.service.question.authoring; 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 { public interface TenantQuestionLifecycleService {
default PageResult<QuestionDO> getPage(PageParam pageParam) { throw new UnsupportedOperationException(); }
default QuestionDO get(Long questionId) { throw new UnsupportedOperationException(); }
Long createDraft(QuestionDraftCommand command); Long createDraft(QuestionDraftCommand command);
default int reviseDraft(Long questionId, QuestionDraftCommand command, int expectedContentVersion) {
throw new UnsupportedOperationException();
}
int place(Long questionId, QuestionPlacementCommand command); int place(Long questionId, QuestionPlacementCommand command);
void publish(Long questionId, Long actorId); 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 DRAFT = "DRAFT";
private static final String PUBLISHED = "PUBLISHED"; private static final String PUBLISHED = "PUBLISHED";
private static final String ARCHIVED = "ARCHIVED"; 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 EducationProperties properties;
private final QuestionMapper questionMapper; private final QuestionMapper questionMapper;
@@ -87,17 +91,10 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Long createDraft(QuestionDraftCommand command) { public Long createDraft(QuestionDraftCommand command) {
assertAuthoringMode(); assertAuthoringMode();
if (command == null) throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
Long tenantId = TenantContextHolder.getRequiredTenantId(); Long tenantId = TenantContextHolder.getRequiredTenantId();
List<QuestionDraftCommand.QuestionDraftOption> suppliedOptions = command.options() != null List<QuestionDraftCommand.QuestionDraftOption> options = normalizeOptions(command);
? command.options() : List.of(); validateDraft(command, options);
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();
QuestionDO question = new QuestionDO(); QuestionDO question = new QuestionDO();
question.setTenantId(tenantId); question.setTenantId(tenantId);
@@ -115,11 +112,65 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
question.setSortOrder(0); question.setSortOrder(0);
questionMapper.insert(question); 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(); QuestionVersionDO version = new QuestionVersionDO();
version.setTenantId(tenantId); version.setTenantId(tenantId);
version.setScope(TENANT_OWNED); version.setScope(TENANT_OWNED);
version.setQuestionId(question.getId()); version.setQuestionId(question.getId());
version.setVersionNumber(1); version.setVersionNumber(versionNumber);
version.setStem(question.getStem()); version.setStem(question.getStem());
version.setType(question.getType()); version.setType(question.getType());
version.setDifficulty(question.getDifficulty()); version.setDifficulty(question.getDifficulty());
@@ -128,7 +179,6 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
version.setExplanation(question.getExplanation()); version.setExplanation(question.getExplanation());
version.setAnalysis(question.getAnalysis()); version.setAnalysis(question.getAnalysis());
versionMapper.insert(version); versionMapper.insert(version);
return question.getId();
} }
@Override @Override
@@ -182,6 +232,44 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
auditMapper.insert(audit); 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) { private void validatePublishable(QuestionDO question) {
if (question.getStem() == null || question.getStem().isBlank() if (question.getStem() == null || question.getStem().isBlank()
|| question.getCorrectAnswer() == null || question.getCorrectAnswer().isBlank()) { || question.getCorrectAnswer() == null || question.getCorrectAnswer().isBlank()) {

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